activescaffold/active_scaffold

View on GitHub
lib/active_scaffold/helpers/list_column_helpers.rb

Summary

Maintainability
D
2 days
Test Coverage
F
36%
module ActiveScaffold
  module Helpers
    # Helpers that assist with the rendering of a List Column
    module ListColumnHelpers
      def get_column_value(record, column)
        record = record.send(column.delegated_association.name) if column.delegated_association
        if record
          method = get_column_method(record, column)
          value = send(method, record, column)
        else
          value = nil
        end
        value = ' '.html_safe if value.nil? || value.blank? # fix for IE 6 # rubocop:disable Rails/OutputSafety
        value
      rescue StandardError => e
        logger.error "#{e.class.name}: #{e.message} -- on the ActiveScaffold column = :#{column.name} in #{controller.class}, record: #{record.inspect}"
        raise e
      end

      def get_column_method(record, column)
        # check for an override helper
        ActiveScaffold::Registry.cache :column_methods, column.cache_key do
          if (method = column_override(column))
            # we only pass the record as the argument. we previously also passed the formatted_value,
            # but mike perham pointed out that prohibited the usage of overrides to improve on the
            # performance of our default formatting. see issue #138.
            method
          # second, check if the dev has specified a valid list_ui for this column
          elsif column.list_ui && (method = override_column_ui(column.list_ui))
            method
          elsif column.column && (method = override_column_ui(column.column.type))
            method
          else
            :format_column_value
          end
        end
      end

      # TODO: move empty_field_text and   logic in here?
      # TODO: we need to distinguish between the automatic links *we* create and the ones that the dev specified. some logic may not apply if the dev specified the link.
      def render_list_column(text, column, record)
        if column.link && !skip_action_link?(column.link, record)
          link = column.link
          associated = record.send(column.association.name) if column.association
          authorized = link.action.nil?
          authorized, reason = column_link_authorized?(link, column, record, associated) unless authorized
          render_action_link(link, record, :link => text, :authorized => authorized, :not_authorized_reason => reason)
        elsif inplace_edit?(record, column)
          active_scaffold_inplace_edit(record, column, :formatted_column => text)
        elsif column_wrap_tag
          content_tag column_wrap_tag, text
        else
          text
        end
      rescue StandardError => e
        logger.error "#{e.class.name}: #{e.message} -- on the ActiveScaffold column = :#{column.name} in #{controller.class}"
        raise e
      end

      def column_wrap_tag
        return @_column_wrap_tag if defined? @_column_wrap_tag
        @_column_wrap_tag = (active_scaffold_config.list.wrap_tag if active_scaffold_config.actions.include?(:list))
      end

      # There are two basic ways to clean a column's value: h() and sanitize(). The latter is useful
      # when the column contains *valid* html data, and you want to just disable any scripting. People
      # can always use field overrides to clean data one way or the other, but having this override
      # lets people decide which way it should happen by default.
      #
      # Why is it not a configuration option? Because it seems like a somewhat rare request. But it
      # could eventually be an option in config.list (and config.show, I guess).
      def clean_column_value(v)
        h(v)
      end

      ##
      ## Overrides
      ##
      def active_scaffold_column_text(record, column)
        # `to_s` is necessary to convert objects in serialized columns to string before truncation.
        clean_column_value(truncate(record.send(column.name).to_s, :length => column.options[:truncate] || 50))
      end

      def active_scaffold_column_fulltext(record, column)
        clean_column_value(record.send(column.name))
      end

      def active_scaffold_column_marked(record, column)
        options = {:id => nil, :object => record}
        content_tag(:span, check_box(:record, column.name, options), :class => 'in_place_editor_field', :data => {:ie_id => record.to_param})
      end

      def active_scaffold_column_checkbox(record, column)
        options = {:disabled => true, :id => nil, :object => record}
        options.delete(:disabled) if inplace_edit?(record, column)
        check_box(:record, column.name, options)
      end

      def active_scaffold_column_percentage(record, column)
        options = column.options[:slider] || {}
        options = options.merge(min: record.send(options[:min_method])) if options[:min_method]
        options = options.merge(max: record.send(options[:max_method])) if options[:max_method]
        value = record.send(options[:value_method]) if options[:value_method]
        as_slider options.merge(value: value || record.send(column.name))
      end

      def active_scaffold_column_month(record, column)
        l record.send(column.name), format: :year_month
      end

      def active_scaffold_column_week(record, column)
        l record.send(column.name), format: :week
      end

      def tel_to(text)
        text = text.to_s
        groups = text.scan(/(?:^\+)?\d+/)
        extension = groups.pop if text.match?(/\s*[^\d\s]+\s*\d+$/)
        link_to text, "tel:#{[groups.join('-'), extension].compact.join(',')}"
      end

      def active_scaffold_column_telephone(record, column)
        phone = record.send column.name
        return if phone.blank?
        phone = number_to_phone(phone) unless column.options[:format] == false
        tel_to phone
      end

      def column_override(column)
        override_helper column, 'column'
      end
      alias column_override? column_override

      # the naming convention for overriding column types with helpers
      def override_column_ui(list_ui)
        ActiveScaffold::Registry.cache :column_ui_overrides, list_ui do
          method = "active_scaffold_column_#{list_ui}"
          method if respond_to? method
        end
      end
      alias override_column_ui? override_column_ui

      ##
      ## Formatting
      ##
      FORM_UI_WITH_OPTIONS = %i[select radio].freeze
      def format_column_value(record, column, value = nil)
        value ||= record.send(column.name) unless record.nil?
        if column.association.nil?
          if FORM_UI_WITH_OPTIONS.include?(column.form_ui) && column.options[:options]
            text, val = column.options[:options].find { |t, v| (v.nil? ? t : v).to_s == value.to_s }
            value = active_scaffold_translated_option(column, text, val).first if text
          end
          if grouped_search? && column == search_group_column && search_group_function
            format_grouped_search_column(value, column.options)
          elsif value.is_a? Numeric
            format_number_value(value, column.options)
          else
            format_value(value, column.options)
          end
        else
          if column.association.collection?
            associated_size = column_association_size(record, column, value) if column.associated_number? # get count before cache association
            if column.association.respond_to_target? && !value.loaded?
              cache_association(record.association(column.name), column, associated_size)
            end
          end
          format_association_value(value, column, associated_size)
        end
      end

      def column_association_size(record, column, value)
        cached_counts = @counts&.dig(column.name)
        if cached_counts
          key = column.association.primary_key if count_on_association_class?(column)
          cached_counts[record.send(key || :id)] || 0
        else
          value.size
        end
      end

      def format_number_value(value, options = {})
        if value
          value =
            case options[:format]
            when :size
              number_to_human_size(value, options[:i18n_options] || {})
            when :percentage
              number_to_percentage(value, options[:i18n_options] || {})
            when :currency
              number_to_currency(value, options[:i18n_options] || {})
            when :i18n_number
              send("number_with_#{value.is_a?(Integer) ? 'delimiter' : 'precision'}", value, options[:i18n_options] || {})
            else
              value
            end
        end
        clean_column_value(value)
      end

      def format_grouped_search_column(value, options = {})
        case search_group_function
        when 'year_month'
          year, month = value.to_s.scan(/(\d*)(\d{2})/)[0]
          I18n.l(Date.new(year.to_i, month.to_i, 1), format: options[:group_format] || search_group_function.to_sym)
        when 'year_quarter'
          year, quarter = value.to_s.scan(/(\d*)(\d)/)[0]
          I18n.t(options[:group_format] || search_group_function, scope: 'date.formats', year: year, quarter: quarter)
        when 'quarter'
          I18n.t(options[:group_format] || search_group_function, scope: 'date.formats', num: value)
        when 'month'
          I18n.l(Date.new(Time.zone.today.year, value, 1), format: options[:group_format] || search_group_function.to_sym)
        else value
        end
      end

      def association_join_text(column = nil)
        column_value = column&.association_join_text
        return column_value if column_value
        return @_association_join_text if defined? @_association_join_text
        @_association_join_text = active_scaffold_config.list.association_join_text
      end

      def format_collection_association_value(value, column, label_method, size)
        associated_limit = column.associated_limit
        if associated_limit.nil?
          firsts = value.collect(&label_method)
          safe_join firsts, association_join_text(column)
        elsif associated_limit.zero?
          size if column.associated_number?
        else
          firsts = value.loaded? ? value[0, associated_limit] : value.limit(associated_limit)
          firsts = firsts.map(&label_method)
          firsts << '…' if value.size > associated_limit
          text = safe_join firsts, association_join_text(column)
          text << " (#{size})" if column.associated_number? && associated_limit && value.size > associated_limit
          text
        end
      end

      def format_singular_association_value(value, column, label_method)
        if column.association.polymorphic?
          "#{value.class.model_name.human}: #{value.send(label_method)}"
        else
          value.send(label_method)
        end
      end

      def format_association_value(value, column, size)
        method = column.options[:label_method] || :to_label
        value =
          if column.association.collection?
            format_collection_association_value(value, column, method, size)
          elsif value
            format_singular_association_value(value, column, method)
          end
        format_value value
      end

      def format_value(column_value, options = {})
        value =
          if column_empty?(column_value)
            empty_field_text
          elsif column_value.is_a?(Time) || column_value.is_a?(Date)
            l(column_value, :format => options[:format] || :default)
          elsif !!column_value == column_value # rubocop:disable Style/DoubleNegation fast check for boolean
            as_(column_value.to_s.to_sym)
          else
            column_value.to_s
          end
        clean_column_value(value)
      end

      def cache_association(association, column, size)
        associated_limit = column.associated_limit
        # we are not using eager loading, cache firsts records in order not to query the database for whole association in a future
        if associated_limit.nil?
          logger.warn "ActiveScaffold: Enable eager loading for #{column.name} association to reduce SQL queries"
        elsif associated_limit.positive?
          # load at least one record more, is needed to display '...'
          association.target = association.reader.limit(associated_limit + 1).select(column.select_associated_columns || "#{association.klass.quoted_table_name}.*").to_a
        elsif @cache_associations
          # set array with at least one element if size > 0, so blank? or present? works, saving [nil] may cause exceptions
          association.target =
            if size.to_i.zero?
              []
            else
              ActiveScaffold::Registry.cache(:cached_empty_association, association.klass) { [association.klass.new] }
            end
        end
      end

      # ==========
      # = Inline Edit =
      # ==========

      def inplace_edit?(record, column)
        return unless column.inplace_edit
        if controller.respond_to?(:update_authorized?, true)
          return Array(controller.send(:update_authorized?, record, column.name))[0]
        end
        record.authorized_for?(:crud_type => :update, :column => column.name)
      end

      def inplace_edit_cloning?(column)
        column.inplace_edit != :ajax && (override_form_field?(column) || column.form_ui || (column.column && override_input?(column.column.type)))
      end

      def active_scaffold_inplace_edit_tag_options(record, column)
        @_inplace_edit_title ||= as_(:click_to_edit)
        cell_id = ActiveScaffold::Registry.cache :inplace_edit_id, column.cache_key do
          element_cell_id(id: '--ID--', action: 'update_column', name: column.name.to_s)
        end
        tag_options = {id: cell_id.sub('--ID--', record.id.to_s), class: 'in_place_editor_field',
                       title: @_inplace_edit_title, data: {:ie_id => record.to_param}}
        tag_options[:data][:ie_update] = column.inplace_edit if column.inplace_edit != true
        tag_options
      end

      def active_scaffold_inplace_edit(record, column, options = {})
        formatted_column = options[:formatted_column] || format_column_value(record, column)
        @_inplace_edit_handle ||= content_tag(:span, as_(:inplace_edit_handle), :class => 'handle')
        span = content_tag(:span, formatted_column, active_scaffold_inplace_edit_tag_options(record, column))
        @_inplace_edit_handle + span
      end

      def inplace_edit_control(column)
        return unless inplace_edit?(active_scaffold_config.model, column) && inplace_edit_cloning?(column)
        unless ActiveScaffold.threadsafe
          column = column.dup
          column.options = column.options.dup
        end
        column.form_ui = :select if column.association && column.form_ui.nil?
        options = active_scaffold_input_options(column).merge(:object => column.active_record_class.new)
        options[:class] = "#{options[:class]} inplace_field"
        options[:"data-id"] = options[:id]
        options[:id] = nil
        content_tag(:div, active_scaffold_input_for(column, nil, options), :style => 'display:none;', :class => inplace_edit_control_css_class)
      end

      def inplace_edit_control_css_class
        'as_inplace_pattern'
      end

      INPLACE_EDIT_PLURAL_FORM_UI = %i[select record_select].freeze
      def inplace_edit_data(column)
        data = {}
        data[:ie_url] = url_for(params_for(:action => 'update_column', :column => column.name, :id => '__id__'))
        data[:ie_cancel_text] = column.options[:cancel_text] || as_(:cancel)
        data[:ie_loading_text] = column.options[:loading_text] || as_(:loading)
        data[:ie_save_text] = column.options[:save_text] || as_(:update)
        data[:ie_saving_text] = column.options[:saving_text] || as_(:saving)
        data[:ie_rows] = column.options[:rows] || 5 if column.column&.type == :text
        data[:ie_cols] = column.options[:cols] if column.options[:cols]
        data[:ie_size] = column.options[:size] if column.options[:size]
        data[:ie_use_html] = column.options[:use_html] if column.options[:use_html]

        if column.list_ui == :checkbox
          data[:ie_mode] = :inline_checkbox
        elsif inplace_edit_cloning?(column)
          data[:ie_mode] = :clone
        elsif column.inplace_edit == :ajax
          url = url_for(params_for(:controller => params_for[:controller], :action => 'render_field', :id => '__id__', :update_column => column.name))
          plural = column.association&.collection? && !override_form_field?(column) && INPLACE_EDIT_PLURAL_FORM_UI.include?(column.form_ui)
          data[:ie_render_url] = url
          data[:ie_mode] = :ajax
          data[:ie_plural] = plural
        end
        data
      end

      # MARK

      def all_marked?
        if active_scaffold_config.mark.mark_all_mode == :page
          @page.items.detect { |record| !marked_records.include?(record.id) }.nil?
        else
          marked_records.length >= @page.pager.count.to_i
        end
      end

      def mark_column_heading
        tag_options = {
          :id => "#{controller_id}_mark_heading",
          :class => 'mark_heading in_place_editor_field'
        }
        content_tag(:span, check_box_tag("#{controller_id}_mark_heading_span_input", '1', all_marked?), tag_options)
      end

      # COLUMN HEADINGS

      def column_heading_attributes(column, sorting, sort_direction)
        {:id => active_scaffold_column_header_id(column), :class => column_heading_class(column, sorting), :title => strip_tags(column.description).presence}
      end

      def render_column_heading(column, sorting, sort_direction)
        tag_options = column_heading_attributes(column, sorting, sort_direction)
        if column.name == :as_marked
          tag_options[:data] = {
            :ie_mode => :inline_checkbox,
            :ie_url => url_for(params_for(:action => 'mark', :id => '__id__'))
          }
        elsif column.inplace_edit
          tag_options[:data] = inplace_edit_data(column)
        end
        content_tag(:th, column_heading_value(column, sorting, sort_direction) + inplace_edit_control(column), tag_options)
      end

      def column_heading_value(column, sorting, sort_direction)
        if column.name == :as_marked
          mark_column_heading
        elsif column.sortable?
          options = {:id => nil, :class => 'as_sort',
                     'data-page-history' => controller_id,
                     :remote => true, :method => :get}
          url_options = {action: :index, page: 1, sort: column.name, sort_direction: sort_direction}
          # :id needed because rails reuse it even if it was deleted from params (like do_refresh_list does)
          url_options[:id] = nil if @remove_id_from_list_links
          url_options = params_for(url_options)
          unless active_scaffold_config.store_user_settings
            url_options[:search] = search_params if respond_to?(:search_params) && search_params.present?
          end
          link_to column_heading_label(column), url_options, options
        else
          content_tag(:p, column_heading_label(column))
        end
      end

      def column_heading_label(column)
        column.label
      end

      # CALCULATIONS

      def column_calculation(column, id_condition: true)
        if column.calculate.instance_of? Proc
          column.calculate.call(@records)
        else
          calculate_query(id_condition).calculate(column.calculate, column.name)
        end
      end

      def render_column_calculation(column, id_condition: true)
        calculation = column_calculation(column, id_condition: id_condition)
        override_formatter = "render_#{column.name}_#{column.calculate.is_a?(Proc) ? :calculate : column.calculate}"
        calculation = send(override_formatter, calculation) if respond_to? override_formatter
        format_column_calculation(column, calculation)
      end

      def format_column_calculation(column, calculation)
        "#{"#{as_(column.calculate)}: " unless column.calculate.is_a? Proc}#{format_column_value nil, column, calculation}"
      end
    end
  end
end