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: # * :addon - Addon content displayd just after the input field. # * :help - A help text displayd below the input field. # * :span - Number of columns the input field should span. # * :caption - Different caption for the label. # * :field_method - 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: # * :addon - Addon content displayd just after the input field. # * :help - A help text displayd below the input field. # * :span - Number of columns the input field should span. # * :field_method - 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) || ' '.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: # * :span - Number of columns the content should span. # * :caption - 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