activescaffold/active_scaffold

View on GitHub
lib/active_scaffold/data_structures/column.rb

Summary

Maintainability
F
3 days
Test Coverage
B
89%
module ActiveScaffold::DataStructures
  class Column
    include ActiveScaffold::Configurable
    include ActiveScaffold::OrmChecks
    NO_PARAMS = Set.new.freeze
    NO_OPTIONS = {}.freeze

    attr_reader :active_record_class
    alias model active_record_class

    # this is the name of the getter on the ActiveRecord model. it is the only absolutely required attribute ... all others will be inferred from this name.
    attr_reader :name

    # Whether to enable inplace editing for this column. Currently works for text columns, in the List.
    attr_reader :inplace_edit
    def inplace_edit=(value)
      clear_link if value
      @inplace_edit = value
    end

    # :table to refresh list
    # true or :row to refresh row
    attr_accessor :inplace_edit_update

    # Whether this column set is collapsed by default in contexts where collapsing is supported
    attr_accessor :collapsed

    # Whether to enable add_existing for this column
    attr_accessor :allow_add_existing

    # What columns load from main table
    attr_accessor :select_columns

    # Any extra parameters this particular column uses.  This is for create/update purposes.
    def params
      return @params || NO_PARAMS if frozen?
      @params ||= NO_PARAMS.dup
    end

    # the display-name of the column. this will be used, for instance, as the column title in the table and as the field name in the form.
    # if left alone it will utilize human_attribute_name which includes localization
    attr_writer :label
    def label(record = nil, scope = nil)
      if @label.respond_to?(:call)
        if record
          @label.call(record, self, scope)
        else
          # sometimes label is called without a record in context (ie, from table
          # headers).  In this case fall back to the humanized attribute name
          # instead of the Proc
          active_record_class.human_attribute_name(name.to_s)
        end
      else
        as_(@label) || active_record_class.human_attribute_name(name.to_s)
      end
    end

    # a textual description of the column and its contents. this will be displayed with any associated form input widget, so you may want to consider adding a content example.
    attr_writer :description
    def description(record = nil, scope = nil)
      if @description&.respond_to?(:call)
        @description.call(record, self, scope)
      elsif @description
        @description
      else
        I18n.t name, :scope => [:activerecord, :description, active_record_class.to_s.underscore.to_sym], :default => ''
      end
    end

    # A placeholder text, to be used inside blank text fields to describe, what should be typed in
    attr_writer :placeholder
    def placeholder
      @placeholder || I18n.t(name, :scope => [:activerecord, :placeholder, active_record_class.to_s.underscore.to_sym], :default => '')
    end

    # this will be /joined/ to the :name for the td's class attribute. useful if you want to style columns on different ActiveScaffolds the same way, but the columns have different names.
    attr_accessor :css_class

    # whether the field is required or not. used on the form for visually indicating the fact to the user.
    # TODO: move into predicate
    attr_writer :required
    def required?(action = nil)
      if action && @required
        @required == true || @required.include?(action)
      else
        @required
      end
    end

    attr_reader :update_columns

    # update dependent columns after value change in form
    #  update_columns = :name
    #  update_columns = [:name, :age]
    def update_columns=(column_names)
      @update_columns = Array(column_names)
    end

    # send all the form instead of only new value when this column change
    cattr_accessor :send_form_on_update_column, instance_accessor: false
    attr_accessor :send_form_on_update_column

    # add a custom attr_accessor that can contain a Proc (or boolean or symbol)
    # that will be called when the column renders, such that we can dynamically
    # hide or show the column with an element that can be replaced by
    # update_columns, but won't affect the form submission.
    # The value can be set in the scaffold controller as follows to dynamically
    # hide the column based on a Proc's output:
    # config.columns[:my_column].hide_form_column_if = Proc.new { |record, column, scope| record.vehicle_type == 'tractor' }
    # OR to always hide the column:
    # config.columns[:my_column].hide_form_column_if = true
    # OR to call a method on the record to determine whether to hide the column:
    # config.columns[:my_column].hide_form_column_if = :hide_tractor_fields?
    attr_accessor :hide_form_column_if

    # sorting on a column can be configured four ways:
    #   sort = true               default, uses intelligent sorting sql default
    #   sort = false              sometimes sorting doesn't make sense
    #   sort = {:sql => ""}       define your own sql for sorting. this should be result in a sortable value in SQL. ActiveScaffold will handle the ascending/descending.
    #   sort = {:method => ""}    define ruby-side code for sorting. this is SLOW with large recordsets!
    def sort=(value)
      if value.is_a? Hash
        value.assert_valid_keys(:sql, :method)
        @sort = value
      else
        @sort = value ? true : false # force true or false
      end
    end

    def sort
      initialize_sort if @sort == true
      @sort
    end

    def sortable?
      sort != false && !sort.nil?
    end

    # a configuration helper for the self.sort property. simply provides a method syntax instead of setter syntax.
    def sort_by(options)
      self.sort = options
    end

    # supported options:
    #   * for association columns
    #     * :select - displays a simple <select> or a collection of checkboxes to (dis)associate records
    attr_reader :form_ui

    attr_reader :form_ui_options

    # value must be a Symbol, or an Array of form_ui and options hash which will be used with form_ui only
    def form_ui=(value)
      check_valid_action_ui_params(value)
      @form_ui, @form_ui_options = *value
    end

    # value must be a Symbol, or an Array of list_ui and options hash which will be used with list_ui only
    def list_ui=(value)
      check_valid_action_ui_params(value)
      @list_ui, @list_ui_options = *value
    end

    def list_ui
      @list_ui || form_ui
    end

    def list_ui_options
      @list_ui ? @list_ui_options : form_ui_options
    end

    # value must be a Symbol, or an Array of show_ui and options hash which will be used with show_ui only
    def show_ui=(value)
      check_valid_action_ui_params(value)
      @show_ui, @show_ui_options = *value
    end

    def show_ui
      @show_ui || list_ui
    end

    def show_ui_options
      @show_ui ? @show_ui_options : list_ui_options
    end

    # value must be a Symbol, or an Array of search_ui and options hash which will be used with search_ui only
    def search_ui=(value)
      check_valid_action_ui_params(value)
      @search_ui, @search_ui_options = *value
    end

    def search_ui
      @search_ui || @form_ui || (:select if association && !association.polymorphic?)
    end

    def search_ui_options
      @search_ui ? @search_ui_options : form_ui_options
    end

    # a place to store dev's column specific options
    attr_writer :options
    def options
      return @options || NO_OPTIONS if frozen?
      @options ||= NO_OPTIONS.dup
    end

    def link
      return @link.call(self) if frozen? && @link.is_a?(Proc)
      @link = @link.call(self) if @link.is_a? Proc
      @link
    end

    # associate an action_link with this column
    def set_link(action, options = {})
      if action.is_a?(ActiveScaffold::DataStructures::ActionLink) || (action.is_a? Proc)
        @link = action
      else
        options[:label] ||= label
        options[:position] ||= :after unless options.key?(:position)
        options[:type] ||= :member
        @link = ActiveScaffold::DataStructures::ActionLink.new(action, options)
      end
    end

    # set an action_link to nested list or inline form in this column
    def autolink?
      @autolink
    end

    # this should not only delete any existing link but also prevent column links from being automatically added by later routines
    def clear_link
      @link = nil
      @autolink = false
    end

    # define a calculation for the column. anything that ActiveRecord::Calculations::ClassMethods#calculate accepts will do.
    attr_accessor :calculate

    # get whether to run a calculation on this column
    def calculation?
      !(@calculate == false || @calculate.nil?)
    end

    # a collection of associations to pre-load when finding the records on a page
    attr_reader :includes
    def includes=(value)
      @includes =
        case value
        when Array then value
        else value ? [value] : value # not convert nil to [nil]
        end
    end

    # a collection of associations to do left join when this column is included on search
    def search_joins
      @search_joins || @includes
    end

    def search_joins=(value)
      @search_joins =
        case value
        when Array then value
        else [value] # automatically convert to an array
        end
    end

    # a collection of columns to load when eager loading is disabled, if it's nil all columns will be loaded
    attr_accessor :select_associated_columns

    # describes how to search on a column
    #   search = true           default, uses intelligent search sql
    #   search = "CONCAT(a, b)" define your own sql for searching. this should be the "left-side" of a WHERE condition. the operator and value will be supplied by ActiveScaffold.
    #   search = [:a, :b]       searches in both fields
    def search_sql=(value)
      @search_sql =
        if value
          value == true || value.is_a?(Proc) ? value : Array(value)
        else
          value
        end
    end

    def search_sql
      initialize_search_sql if @search_sql == true
      @search_sql
    end

    def searchable?
      search_sql.present?
    end

    # to modify the default order of columns
    attr_accessor :weight

    # to set how many associated records a column with plural association must show in list
    cattr_accessor :associated_limit, instance_accessor: false
    @@associated_limit = 3
    attr_accessor :associated_limit

    # whether the number of associated records must be shown or not
    cattr_accessor :associated_number, instance_accessor: false
    @@associated_number = true
    attr_writer :associated_number
    def associated_number?
      @associated_number
    end

    # what string to use to join records from plural associations
    attr_accessor :association_join_text

    # whether a blank row must be shown in the subform
    cattr_accessor :show_blank_record, instance_accessor: false
    @@show_blank_record = true
    attr_writer :show_blank_record
    def show_blank_record?(associated)
      return false unless @show_blank_record
      return false unless association.klass.authorized_for?(:crud_type => :create) && !association.readonly?
      association.collection? || (association.singular? && associated.blank?)
    end

    # methods for automatic links in singular association columns
    cattr_accessor :actions_for_association_links, instance_accessor: false
    @@actions_for_association_links = %i[new edit show]
    attr_accessor :actions_for_association_links

    cattr_accessor :association_form_ui
    @@association_form_ui = nil

    # ----------------------------------------------------------------- #
    # the below functionality is intended for internal consumption only #
    # ----------------------------------------------------------------- #

    # the ConnectionAdapter::*Column object from the ActiveRecord class
    attr_reader :column

    # the association from the ActiveRecord class
    attr_reader :association

    # the singular association which this column belongs to
    attr_reader :delegated_association

    # an interpreted property. the column is virtual if it isn't from the active record model or any associated models
    def virtual?
      column.nil? && association.nil?
    end

    attr_writer :number
    def number?
      @number
    end

    def text?
      @text
    end

    # this is so that array.delete and array.include?, etc., will work by column name
    def ==(other) #:nodoc:
      # another column
      if other.respond_to?(:name) && other.class == self.class
        name == other.name.to_sym
      elsif other.is_a? Symbol
        name == other
      elsif other.is_a? String
        name.to_s == other # avoid creating new symbols
      else # unknown
        eql? other
      end
    end

    # cache key to cache column info
    attr_reader :cache_key

    # instantiation is handled internally through the DataStructures::Columns object
    def initialize(name, active_record_class, delegated_association = nil) #:nodoc:
      @name = name.to_sym
      @active_record_class = active_record_class
      @column = _columns_hash[name.to_s]
      if @column.nil? && active_record? && active_record_class._default_attributes.key?(name.to_s)
        @column = active_record_class._default_attributes[name.to_s]
      end
      @delegated_association = delegated_association
      @cache_key = [@active_record_class.name, name].compact.map(&:to_s).join('#')
      setup_association_info

      @link = nil
      @autolink = association.present?
      @table = _table_name
      @associated_limit = self.class.associated_limit
      @associated_number = self.class.associated_number
      @show_blank_record = self.class.show_blank_record
      @send_form_on_update_column = self.class.send_form_on_update_column
      @actions_for_association_links = self.class.actions_for_association_links.dup if association
      @select_columns = default_select_columns

      @text = @column.nil? || [:string, :text, :citext, String].include?(column_type)
      @number = false
      setup_defaults_for_column if @column
      @allow_add_existing = true
      @form_ui = self.class.association_form_ui if @association && self.class.association_form_ui

      self.includes = [association.name] if association&.allow_join?
      if delegated_association
        self.includes = includes ? [delegated_association.name => includes] : [delegated_association.name]
      end

      # default all the configurable variables
      self.css_class = ''
      validators_force_require_on = active_record_class.validators_on(name)
                                                       .map { |val| validator_force_required?(val) }
                                                       .select(&:present?)
      self.required = validators_force_require_on.any? { |opt| opt == true } ||
                      validators_force_require_on.reject { |opt| opt == true }.flatten.presence
      self.sort = true
      self.search_sql = true

      @weight = estimate_weight
    end

    # just the field (not table.field)
    def field_name
      return nil if virtual?
      @field_name ||= column ? quoted_field_name(column.name) : quoted_field_name(association.foreign_key)
    end

    def <=>(other)
      order_weight = weight <=> other.weight
      order_weight.nonzero? ? order_weight : name.to_s <=> other.name.to_s
    end

    def convert_to_native?
      number? && options[:format] && form_ui != :number
    end

    def number_to_native(value)
      return value if value.blank? || !value.is_a?(String)
      native = '.' # native ruby separator
      format = {:separator => '', :delimiter => ''}.merge! I18n.t('number.format', :default => {})
      specific =
        case options[:format]
        when :currency
          I18n.t('number.currency.format', :default => nil)
        when :size
          I18n.t('number.human.format', :default => nil)
        when :percentage
          I18n.t('number.percentage.format', :default => nil)
        end
      format.merge! specific unless specific.nil?
      if format[:separator].blank? || !value.include?(format[:separator]) && value.include?(native) && (format[:delimiter] != native || value !~ /\.\d{3}$/)
        value
      else
        value.gsub(/[^0-9\-#{format[:separator]}]/, '').gsub(format[:separator], native)
      end
    end

    def default_for_empty_value
      return nil unless column
      if column.is_a?(ActiveModel::Attribute)
        column.value
      elsif active_record?
        null? ? nil : column.default
      elsif mongoid?
        column.default_val
      end
    end

    def null?
      if active_record? && !column.is_a?(ActiveModel::Attribute)
        column&.null
      else
        true
      end
    end

    # the table.field name for this column, if applicable
    def field
      @field ||= quoted_field(field_name)
    end

    def quoted_foreign_type
      quoted_field(quoted_field_name(association.foreign_type))
    end

    def type_for_attribute
      ActiveScaffold::OrmChecks.type_for_attribute active_record_class, name
    end

    def column_type
      ActiveScaffold::OrmChecks.column_type active_record_class, name
    end

    def default_value
      ActiveScaffold::OrmChecks.column_type active_record_class, name
    end

    def attributes=(opts)
      opts.each do |setting, value|
        send "#{setting}=", value
      end
    end

    def cast(value)
      ActiveScaffold::OrmChecks.cast active_record_class, name, value
    end

    protected

    def setup_defaults_for_column
      if active_record_class.respond_to?(:defined_enums) && active_record_class.defined_enums[name.to_s]
        @form_ui = :select
        @options = {:options => active_record_class.send(name.to_s.pluralize).keys.map(&:to_sym)}
      elsif column_number?
        @number = true
        @form_ui = :number
        @options = {:format => :i18n_number}
      else
        @form_ui =
          case column_type
          when :boolean then null? ? :boolean : :checkbox
          when :text then :textarea
          end
      end
    end

    def setup_association_info
      assoc = active_record_class.reflect_on_association(name)
      @association =
        if assoc
          if active_record?
            Association::ActiveRecord.new(assoc)
          elsif mongoid?
            Association::Mongoid.new(assoc)
          end
        elsif defined?(ActiveMongoid) && model < ActiveMongoid::Associations
          assoc = active_record_class.reflect_on_am_association(name)
          Association::ActiveMongoid.new(assoc) if assoc
        end
    end

    def validator_force_required?(val)
      return false if val.options[:if] || val.options[:unless]
      case val
      when ActiveModel::Validations::PresenceValidator
        validator_required_on(val)
      when ActiveModel::Validations::InclusionValidator
        if !val.options[:allow_nil] && !val.options[:allow_blank] && !inclusion_validator_for_checkbox?(val)
          validator_required_on(val)
        end
      end
    end

    def validator_required_on(val)
      val.options[:on] ? Array(val.options[:on]) : true
    end

    def inclusion_validator_for_checkbox?(val)
      @form_ui == :checkbox &&
        [[true, false], [false, true]].include?(val.options[:with] || val.options[:within] || val.options[:in])
    end

    def default_select_columns
      if association.nil? && column
        [field]
      elsif association&.polymorphic?
        [field, quoted_field(quoted_field_name(association.foreign_type))]
      elsif association
        if association.belongs_to?
          [field]
        else
          columns = []
          if _columns_hash[count_column = "#{association.name}_count"]
            columns << quoted_field(quoted_field_name(count_column))
          end
          if association.through_reflection&.belongs_to?
            columns << quoted_field(quoted_field_name(association.through_reflection.foreign_key))
          end
          columns
        end
      end
    end

    def column_number?
      return %i[float decimal integer].include? column_type if active_record?
      return @column.type < Numeric if mongoid?
    end

    def quoted_field_name(column_name)
      if active_record?
        @active_record_class.connection.quote_column_name(column_name)
      else
        column_name.to_s
      end
    end

    def quoted_field(name)
      active_record? ? [_quoted_table_name, name].compact.join('.') : name
    end

    def initialize_sort
      self.sort =
        if column && !tableless?
          {:sql => field}
        else
          false
        end
    end

    def initialize_search_sql
      self.search_sql =
        unless virtual?
          if association.nil?
            field.to_s unless tableless?
          elsif association.allow_join?
            [association.quoted_table_name, association.quoted_primary_key].join('.') unless association.klass < ActiveScaffold::Tableless
          end
        end
    end

    # the table name from the ActiveRecord class
    attr_reader :table

    def estimate_weight
      if association&.singular?
        400
      elsif association&.collection?
        500
      elsif %i[created_at updated_at].include?(name)
        600
      elsif %i[name label title].include?(name)
        100
      elsif required?
        200
      else
        300
      end
    end

    def check_valid_action_ui_params(value)
      return true if valid_action_ui_params?(value)
      raise ArgumentError, 'value must be a Symbol, or an array of Symbol and Hash'
    end

    def valid_action_ui_params?(value)
      if value.is_a?(Array)
        value.size <= 2 && value[0].is_a?(Symbol) && (value[1].nil? || value[1].is_a?(Hash))
      else
        value.nil? || value.is_a?(Symbol)
      end
    end
  end
end