codez/dry_crud

View on GitHub
app/helpers/dry_crud/form/builder.rb

Summary

Maintainability
A
3 hrs
Test Coverage
module DryCrud
  module Form

    # A form builder that automatically selects the corresponding input field
    # for ActiveRecord column types. Convenience methods for each column type
    # allow one to customize the different fields.
    #
    # All field methods may be prefixed with +labeled_+ in order to render
    # a standard label, required mark and an optional help block with them.
    #
    # Use #labeled_input_field or #input_field to render a input field
    # corresponding to the given attribute.
    #
    # See the Control class for how to customize the html rendered for a
    # single input field.
    class Builder < ActionView::Helpers::FormBuilder

      class_attribute :control_class
      self.control_class = Control

      attr_reader :template

      delegate :association, :column_type, :column_property, :captionize,
               :ti, :ta, :link_to, :tag, :safe_join, :capture,
               :add_css_class, :assoc_and_id_attr,
               to: :template

      ### INPUT FIELDS

      # Render multiple input controls together with a label for the given
      # attributes.
      def labeled_input_fields(*attrs, **options)
        safe_join(attrs) { |a| labeled_input_field(a, **options.dup) }
      end

      # Render a corresponding input control and label for the given attribute.
      # The input field is chosen based on the ActiveRecord column type.
      #
      # The following options may be passed:
      # * <tt>:addon</tt> - Addon content displayd just after the input field.
      # * <tt>:help</tt> - A help text displayd below the input field.
      # * <tt>:span</tt> - Number of columns the input field should span.
      # * <tt>:caption</tt> - Different caption for the label.
      # * <tt>:field_method</tt> - Different method to create the input field.
      #
      # Use additional html_options for the input element.
      def labeled_input_field(attr, **html_options)
        control_class.new(self, attr, **html_options).render_labeled
      end

      # Render a corresponding input control for the given attribute.
      # The input field is chosen based on the ActiveRecord column type.
      #
      # The following options may be passed:
      # * <tt>:addon</tt> - Addon content displayd just after the input field.
      # * <tt>:help</tt> - A help text displayd below the input field.
      # * <tt>:span</tt> - Number of columns the input field should span.
      # * <tt>:field_method</tt> - Different method to create the input field.
      #
      # Use additional html_options for the input element.
      def input_field(attr, **html_options)
        control_class.new(self, attr, **html_options).render_content
      end

      # Render a standard string field with column contraints.
      def string_field(attr, **html_options)
        html_options[:maxlength] ||= column_property(@object, attr, :limit)
        text_field(attr, **html_options)
      end

      # Render a boolean field.
      def boolean_field(attr, **html_options)
        tag.div(class: 'checkbox') do
          tag.label do
            detail = html_options.delete(:detail) || '&nbsp;'.html_safe
            safe_join([check_box(attr, html_options), ' ', detail])
          end
        end
      end

      # Add form-control class to all input fields.
      %w[text_field password_field email_field
         number_field date_field time_field datetime_field].each do |method|
        define_method(method) do |attr, **html_options|
          add_css_class(html_options, 'form-control')
          super(attr, html_options)
        end
      end

      def integer_field(attr, **html_options)
        html_options[:step] ||= 1
        number_field(attr, **html_options)
      end

      def float_field(attr, **html_options)
        html_options[:step] ||= 'any'
        number_field(attr, **html_options)
      end

      def decimal_field(attr, **html_options)
        html_options[:step] ||=
          (10**-column_property(object, attr, :scale)).to_f
        number_field(attr, **html_options)
      end

      # Customize the standard text area to have 5 rows by default.
      def text_area(attr, **html_options)
        add_css_class(html_options, 'form-control')
        html_options[:rows] ||= 5
        super(attr, **html_options)
      end

      # Render a select element for a :belongs_to association defined by attr.
      # Use additional html_options for the select element.
      # To pass a custom element list, specify the list with the :list key or
      # define an instance variable with the pluralized name of the
      # association.
      def belongs_to_field(attr, **html_options)
        list = association_entries(attr, **html_options).to_a
        if list.present?
          add_css_class(html_options, 'form-control')
          collection_select(attr, list, :id, :to_s,
                            select_options(attr, **html_options),
                            **html_options)
        else
          # rubocop:disable Rails/OutputSafety
          none = ta(:none_available, association(@object, attr)).html_safe
          # rubocop:enable Rails/OutputSafety
          static_text(none)
        end
      end

      # rubocop:disable Naming/PredicateName

      # Render a multi select element for a :has_many or
      # :has_and_belongs_to_many association defined by attr.
      # Use additional html_options for the select element.
      # To pass a custom element list, specify the list with the :list key or
      # define an instance variable with the pluralized name of the
      # association.
      def has_many_field(attr, **html_options)
        html_options[:multiple] = true
        add_css_class(html_options, 'multiselect')
        belongs_to_field(attr, **html_options)
      end
      # rubocop:enable Naming/PredicateName

      ### VARIOUS FORM ELEMENTS

      # Render the error messages for the current form.
      def error_messages
        @template.render('shared/error_messages',
                         errors: @object.errors,
                         object: @object)
      end

      # Renders the given content with an addon.
      def with_addon(content, addon)
        tag.div(class: 'input-group') do
          content + tag.span(addon, class: 'input-group-text')
        end
      end

      # Renders a static text where otherwise form inputs appear.
      def static_text(text)
        tag.p(text, class: 'form-control-static')
      end

      # Generates a help block for fields
      def help_block(text)
        tag.p(text, class: 'help-block')
      end

      # Render a submit button and a cancel link for this form.
      def standard_actions(submit_label = ti('button.save'), cancel_url = nil)
        tag.div(class: 'col-md-offset-2 col-md-8') do
          safe_join([submit_button(submit_label),
                     cancel_link(cancel_url)],
                    ' ')
        end
      end

      # Render a standard submit button with the given label.
      def submit_button(label = ti('button.save'))
        button(label, class: 'btn btn-primary', data: { disable_with: label })
      end

      # Render a cancel link pointing to the given url.
      def cancel_link(url = nil)
        url ||= cancel_url
        link_to(ti('button.cancel'), url, class: 'cancel')
      end

      # Depending if the given attribute must be present, return
      # only an initial selection prompt or a blank option, respectively.
      def select_options(attr, **options)
        prompt = options.delete(:prompt)
        blank = options.delete(:include_blank)
        if options[:multiple]
          {}
        elsif prompt
          { prompt: prompt }
        elsif blank
          { include_blank: blank }
        else
          assoc = association(@object, attr)
          if required?(attr)
            { prompt: ta(:please_select, assoc) }
          else
            { include_blank: ta(:no_entry, assoc) }
          end
        end
      end

      # Returns true if the given attribute must be present.
      def required?(attr)
        attr, attr_id = assoc_and_id_attr(attr)
        validators = @object.class.validators_on(attr) +
                     @object.class.validators_on(attr_id)
        validators.any? do |v|
          v.kind == :presence &&
            !v.options.key?(:if) &&
            !v.options.key?(:unless)
        end
      end

      # Render a label for the given attribute with the passed content.
      # The content may be given as an argument or as a block:
      #   labeled(:attr) { #content }
      #   labeled(:attr, content)
      #
      # The following options may be passed:
      # * <tt>:span</tt> - Number of columns the content should span.
      # * <tt>:caption</tt> - Different caption for the label.
      def labeled(attr, content = {}, options = {}, &block)
        if block_given?
          options = content
          content = capture(&block)
        end
        control = control_class.new(self, attr, **options)
        control.render_labeled(content)
      end

      # Dispatch methods starting with 'labeled_' to render a label and the
      # corresponding input field.
      # E.g. labeled_boolean_field(:checked, class: 'bold')
      # To add an additional help text, use the help option.
      # E.g. labeled_boolean_field(:checked, help: 'Some Help')
      def method_missing(name, *args)
        field_method = labeled_field_method?(name)
        if field_method
          build_labeled_field(field_method, *args)
        else
          super
        end
      end

      # Overriden to fullfill contract with method_missing 'labeled_' methods.
      def respond_to_missing?(name, include_private = false)
        labeled_field_method?(name).present? || super
      end

      private

      # Checks if the passed name corresponds to a field method with a
      # 'labeled_' prefix.
      def labeled_field_method?(name)
        prefix = 'labeled_'
        if name.to_s.start_with?(prefix)
          field_method = name.to_s[prefix.size..]
          field_method if respond_to?(field_method)
        end
      end

      # Renders the corresponding field together with a label, required mark
      # and an optional help block.
      def build_labeled_field(field_method, *args, **options)
        options[:field_method] = field_method
        control_class.new(self, *args, **options).render_labeled
      end

      # Returns the list of association entries, either from options[:list] or
      # the instance variable with the pluralized association name.
      # Otherwise, if the association defines a #options_list or #list scope,
      # this is used to load the entries.
      # As a last resort, all entries from the association class are returned.
      def association_entries(attr, **options)
        list = options.delete(:list)
        unless list
          assoc = association(@object, attr)
          ivar = :"@#{assoc.name.to_s.pluralize}"
          list = @template.send(:instance_variable_defined?, ivar) &&
                 @template.send(:instance_variable_get, ivar)
          list ||= load_association_entries(assoc)
        end
        list
      end

      # Automatically load the entries for the given association.
      def load_association_entries(assoc)
        klass = assoc.klass
        list = klass.all
        list = list.merge(assoc.scope) if assoc.scope
        # Use special scopes if they are defined
        if klass.respond_to?(:options_list)
          list.options_list
        elsif klass.respond_to?(:list)
          list.list
        else
          list
        end
      end

      # Get the cancel url for the given object considering options:
      # 1. Use :cancel_url_new or :cancel_url_edit option, if present
      # 2. Use :cancel_url option, if present
      def cancel_url
        if @object.new_record?
          options[:cancel_url_new] || options[:cancel_url]
        else
          options[:cancel_url_edit] || options[:cancel_url]
        end
      end

    end
  end
end