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