thambley/activeadmin-xls

View on GitHub
lib/active_admin/xls/builder.rb

Summary

Maintainability
A
3 hrs
Test Coverage
require 'stringio'
require 'spreadsheet'

module ActiveAdmin
  module Xls
    # Builder for xls data.
    class Builder
      include MethodOrProcHelper

      # @param [Class] resource_class The resource this builder generate column
      #   information for.
      # @param [Hash] options the options for the builder
      # @option options [Hash] :header_format A hash of format properties to
      #   apply to the header row. Any properties specified will be merged with
      #   the default header styles.
      # @option options [Array] :i18n_scope the I18n scope to use when looking
      #   up localized column headers.
      # @param [Block] block Block given will evaluated against this instance of
      #   Builder. That means you can call any method on the builder from within
      #   that block.
      # @example
      #   ActiveAdmin::Xls::Builder.new(Post, i18n: [:xls]) do
      #     delete_columns :id, :created_at, :updated_at
      #     column(:author_name) { |post| post.author.name }
      #     after_filter do |sheet|
      #       # todo
      #     end
      #   end
      #
      # @see DSL
      # @see https://github.com/zdavatz/spreadsheet/blob/master/lib/spreadsheet/format.rb
      def initialize(resource_class, options = {}, &block)
        @skip_header = false
        @resource_class = resource_class
        @columns = []
        @columns_loaded = false
        @column_updates = []
        parse_options options
        instance_eval(&block) if block_given?
      end

      # The default header style
      # @return [Hash]
      #
      # @see https://github.com/zdavatz/spreadsheet/blob/master/lib/spreadsheet/format.rb
      def header_format
        @header_format ||= {}
      end

      alias header_style header_format

      # This has can be used to override the default header style for your
      # sheet. Any values you provide will be merged with the default styles.
      # Precedence is given to your hash
      #
      # @example In Builder.new
      #   options = {
      #     header_format: { weight: :bold },
      #     i18n_scope: %i[xls post]
      #   }
      #   Builder.new(Post, options) do
      #     skip_header
      #   end
      #
      # @example With DSL
      #   ActiveAdmin.register Post do
      #     xls(header_format: { weight: :bold }, i18n_scope: %i[xls post]) do
      #       skip_header
      #     end
      #   end
      #
      # @example Simple DSL without block
      #   xls header_format: { weight: :bold }
      #
      # @see https://github.com/zdavatz/spreadsheet/blob/master/lib/spreadsheet/format.rb
      def header_format=(format_hash)
        @header_format = header_format.merge(format_hash)
      end

      alias header_style= header_format=

      # Indicates that we do not want to serialize the column headers
      #
      # @example In Builder.new
      #   options = {
      #     header_format: { weight: :bold },
      #     i18n_scope: %i[xls post]
      #   }
      #   Builder.new(Post, options) do
      #     skip_header
      #   end
      #
      # @example With DSL
      #   ActiveAdmin.register Post do
      #     xls(header_format: { weight: :bold }, i18n_scope: %i[xls post]) do
      #       skip_header
      #     end
      #   end
      def skip_header
        @skip_header = true
      end

      # The I18n scope that will be used when looking up your
      # column names in the current I18n locale.
      # If you set it to [:active_admin, :resources, :posts] the
      # serializer will render the value at active_admin.resources.posts.title
      # in the current translations
      #
      # @note If you do not set this, the column name will be titleized.
      attr_accessor :i18n_scope

      # The stored block that will be executed after your report is generated.
      #
      # @yieldparam sheet [Spreadsheet::Worksheet] the worksheet where the
      #   collection has been serialized
      #
      # @example With DSL
      #   xls do
      #     after_filter do |sheet|
      #       row_number = sheet.dimensions[1]
      #       sheet.update_row(row_number)
      #       row_number += 1
      #       sheet.update_row(row_number, 'Author Name', 'Number of Posts')
      #       users = collection.map(&:author).uniq(&:id)
      #       users.each do |user|
      #         row_number += 1
      #         sheet.update_row(row_number,
      #                          "#{user.first_name} #{user.last_name}",
      #                          user.posts.size)
      #       end
      #     end
      #   end
      def after_filter(&block)
        @after_filter = block
      end

      # the stored block that will be executed before your report is generated.
      #
      # @yieldparam sheet [Spreadsheet::Worksheet] the worksheet where the
      #   collection has been serialized
      #
      # @example with DSL
      #   xls do
      #     before_filter do |sheet|
      #       users = collection.map(&:author)
      #       users.each do |user|
      #         user.first_name = 'Set In Proc' if user.first_name == 'bob'
      #       end
      #       row_number = sheet.dimensions[1]
      #       sheet.update_row(row_number, 'Created', Time.zone.now)
      #       row_number += 1
      #       sheet.update_row(row_number, '')
      #     end
      #   end
      def before_filter(&block)
        @before_filter = block
      end

      # Returns the columns the builder will serialize.
      #
      # @return [Array<Column>] columns configued on the builder.
      def columns
        # execute each update from @column_updates
        # set @columns_loaded = true
        load_columns unless @columns_loaded
        @columns
      end

      # The collection we are serializing.
      #
      # @note This is only available after serialize has been called,
      # and is reset on each subsequent call.
      attr_reader :collection

      # Removes all columns from the builder. This is useful when you want to
      # only render specific columns. To remove specific columns use
      # ignore_column.
      #
      # @example Using alias whitelist
      #   Builder.new(Post, header_style: {}, i18n_scope: %i[xls post]) do
      #     whitelist
      #     column :title
      #   end
      def clear_columns
        @columns_loaded = true
        @column_updates = []

        @columns = []
      end

      # Clears the default columns array so you can whitelist only the columns
      # you want to export
      alias whitelist clear_columns

      # Add a column
      # @param [Symbol] name The name of the column.
      # @param [Proc] block A block of code that is executed on the resource
      #                     when generating row data for this column.
      #
      # @example With block
      #   xls(i18n_scope: [:rspec], header_style: { size: 20 }) do
      #     delete_columns :id, :created_at
      #     column(:author) { |post| post.author.first_name }
      #   end
      #
      # @example With default value
      #   Builder.new(Post, header_style: {}, i18n_scope: %i[xls post]) do
      #     whitelist
      #     column :title
      #   end
      def column(name, &block)
        if @columns_loaded
          columns << Column.new(name, block)
        else
          column_lambda = lambda do
            column(name, &block)
          end
          @column_updates << column_lambda
        end
      end

      # Removes columns by name.
      # Each column_name should be a symbol.
      #
      # @example In Builder.new
      #   options = {
      #     header_style: { size: 10, color: 'red' },
      #     i18n_scope: %i[xls post]
      #   }
      #   Builder.new(Post, options) do
      #     delete_columns :id, :created_at, :updated_at
      #     column(:author) do |resource|
      #       "#{resource.author.first_name} #{resource.author.last_name}"
      #     end
      #   end
      def delete_columns(*column_names)
        if @columns_loaded
          columns.delete_if { |column| column_names.include?(column.name) }
        else
          delete_lambda = lambda do
            delete_columns(*column_names)
          end
          @column_updates << delete_lambda
        end
      end

      # Removes all columns, and add columns by name.
      # Each column_name should be a symbol
      #
      # @example
      #   config.xls_builder.only_columns :title, :author
      def only_columns(*column_names)
        clear_columns
        column_names.each do |column_name|
          column column_name
        end
      end

      # Serializes the collection provided
      #
      # @param collection [Enumerable] list of resources to serialize
      # @param view_context object on which unknown methods may be executed
      # @return [Spreadsheet::Workbook]
      def serialize(collection, view_context = nil)
        @collection = collection
        @view_context = view_context
        book = Spreadsheet::Workbook.new
        sheet = book.create_worksheet
        load_columns unless @columns_loaded
        apply_filter @before_filter, sheet
        export_collection collection, sheet
        apply_filter @after_filter, sheet
        to_stream book
      end

      # Xls column information
      class Column
        # @param name [String, Symbol] Name of the column. If the name of the
        #   column is an existing attribute of the resource class, the value
        #   can be retreived automatically if no block is specified
        # @param block [Proc] A procedure to generate data for the column
        #   instead of retreiving the value from the resource directly
        def initialize(name, block = nil)
          @name = name
          @data = block || @name
        end

        # @return [String, Symbol] Column name
        attr_reader :name

        # @return [String, Symbol, Proc] The column name used to look up the
        #   value, or a block used to generate the value to display.
        attr_reader :data

        # Returns a localized version of the column name if a scope is given.
        # Otherwise, it returns the titleized column name without translation.
        #
        # @param i18n_scope [String, Symbol, Array<String>, Array<Symbol>]
        #   Translation scope.  If not provided, the column name will be used.
        #
        # @see I18n
        def localized_name(i18n_scope = nil)
          return name.to_s.titleize unless i18n_scope
          I18n.t name, scope: i18n_scope
        end
      end

      private

      def load_columns
        return if @columns_loaded
        @columns = resource_columns(@resource_class)
        @columns_loaded = true
        @column_updates.each(&:call)
        @column_updates = []
        columns
      end

      def to_stream(book)
        stream = StringIO.new('')
        book.write stream
        stream.string
      end

      def export_collection(collection, sheet)
        return if columns.none?
        row_index = sheet.dimensions[1]

        unless @skip_header
          header_row(sheet.row(row_index), collection)
          row_index += 1
        end

        collection.each do |resource|
          fill_row(sheet.row(row_index), resource_data(resource))
          row_index += 1
        end
      end

      # tranform column names into array of localized strings
      # @return [Array]
      def header_row(row, collection)
        apply_format_to_row(row, create_format(header_format))
        fill_row(row, header_data_for(collection))
      end

      def header_data_for(collection)
        resource = collection.first || @resource_class.new
        columns.map do |column|
          column.localized_name(i18n_scope) if in_scope(resource, column)
        end.compact
      end

      def apply_filter(filter, sheet)
        filter.call(sheet) if filter
      end

      def parse_options(options)
        options.each do |key, value|
          send("#{key}=", value) if respond_to?("#{key}=") && !value.nil?
        end
      end

      def resource_data(resource)
        columns.map do |column|
          call_method_or_proc_on resource, column.data if in_scope(resource,
                                                                   column)
        end
      end

      def in_scope(resource, column)
        return true unless column.name.is_a?(Symbol)
        resource.respond_to?(column.name)
      end

      def resource_columns(resource)
        [Column.new(:id)] + resource.content_columns.map do |column|
          Column.new(column.name.to_sym)
        end
      end

      def create_format(format_hash)
        Spreadsheet::Format.new format_hash
      end

      def apply_format_to_row(row, format)
        row.default_format = format if format
      end

      def fill_row(row, column)
        case column
        when Hash
          column.each_value { |values| fill_row(row, values) }
        when Array
          column.each { |value| fill_row(row, value) }
        else
          # raise ArgumentError,
          #       "column #{column} has an invalid class (#{ column.class })"
          row.push(column)
        end
      end

      def method_missing(method_name, *arguments)
        if @view_context.respond_to? method_name
          @view_context.send method_name, *arguments
        else
          super
        end
      end

      def respond_to_missing?(method_name, include_private = false)
        @view_context.respond_to?(method_name) || super
      end
    end
  end
end