lib/comfy_bootstrap_form/form_builder.rb
# frozen_string_literal: true
require_relative "bootstrap_options"
module ComfyBootstrapForm
class FormBuilder < ActionView::Helpers::FormBuilder
FIELD_HELPERS = %w[
color_field date_field datetime_field email_field month_field
password_field phone_field range_field search_field text_area
text_field rich_text_area time_field url_field week_field
].freeze
DATE_SELECT_HELPERS = %w[
date_select datetime_select time_select
].freeze
delegate :content_tag, :capture, :concat, to: :@template
# Bootstrap settings set on the form itself
attr_accessor :form_bootstrap
def initialize(object_name, object, template, options)
@form_bootstrap = ComfyBootstrapForm::BootstrapOptions.new(options.delete(:bootstrap))
super(object_name, object, template, options)
end
# Wrapper for all field helpers. Example usage:
#
# bootstrap_form_with model: @user do |form|
# form.text_field :name
# end
#
# Output of the `text_field` will be wrapped in Bootstrap markup
#
FIELD_HELPERS.each do |field_helper|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{field_helper}(method, options = {})
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
return super if bootstrap.disabled
draw_form_group(bootstrap, method, options) do
super(method, options)
end
end
RUBY_EVAL
end
# Wrapper for datetime select helpers. Boostrap options are sent via options hash:
#
# date_select :birthday, bootstrap: {label: {text: "Custom"}}
#
DATE_SELECT_HELPERS.each do |select_helper|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{select_helper}(method, options = {}, html_options = {}, &block)
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
return super if bootstrap.disabled
add_css_class!(html_options, "d-inline-block w-auto")
add_css_class!(html_options, "custom-select") if bootstrap.custom_control
draw_form_group(bootstrap, method, html_options) do
content_tag(:div, class: "#{select_helper}") do
super(method, options, html_options, &block)
end
end
end
RUBY_EVAL
end
# Wrapper for the number field. It has default changed from `step: "1"` to `step: "any"`
# to prevent confusion when dealing with decimal numbers.
#
# number_field :amount, step: 5
#
def number_field(method, options = {})
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
options.reverse_merge!(step: "any")
return super(method, options) if bootstrap.disabled
draw_form_group(bootstrap, method, options) do
super(method, options)
end
end
# Wrapper for select helper. Boostrap options are sent via options hash:
#
# select :choices, ["a", "b"], bootstrap: {label: {text: "Custom"}}
#
def select(method, choices = nil, options = {}, html_options = {}, &block)
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
return super if bootstrap.disabled
add_css_class!(html_options, "custom-select") if bootstrap.custom_control
draw_form_group(bootstrap, method, html_options) do
super(method, choices, options, html_options, &block)
end
end
# Wrapper for collection_select helper. Boostrap options are sent via options hash:
#
# collection_select :collection, [["a", "aa"], ["b", "bb"]], :first, :last, bootstrap: {label: {text: "Custom"}}
#
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
return super if bootstrap.disabled
add_css_class!(html_options, "custom-select") if bootstrap.custom_control
draw_form_group(bootstrap, method, html_options) do
super(method, collection, value_method, text_method, options, html_options)
end
end
# Wrapper for file_field helper. It can accept `custom_control` option.
#
# file_field :photo, bootstrap: {custom_control: true}
#
def file_field(method, options = {})
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
return super if bootstrap.disabled
draw_form_group(bootstrap, method, options) do
if bootstrap.custom_control
content_tag(:div, class: "custom-file") do
add_css_class!(options, "custom-file-input")
remove_css_class!(options, "form-control")
label_text = options.delete(:placeholder)
concat super(method, options)
label_options = { class: "custom-file-label" }
label_options[:for] = options[:id] if options[:id].present?
concat label(method, label_text, label_options)
end
else
super(method, options)
end
end
end
# Wrapper around radio button. Example usage:
#
# radio_button :choice, "value", bootstrap: {label: {text: "Do you agree?"}}
#
def radio_button(method, tag_value, options = {})
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
return super if bootstrap.disabled
help_text = draw_help(bootstrap)
errors = draw_errors(bootstrap, method)
add_css_class!(options, "form-check-input")
add_css_class!(options, "is-invalid") if errors.present?
label_text = nil
if (custom_text = bootstrap.label[:text]).present?
label_text = custom_text
end
fieldset_css_class = "form-group"
fieldset_css_class += " row" if bootstrap.horizontal?
fieldset_css_class += " #{bootstrap.inline_margin_class}" if bootstrap.inline?
content_tag(:fieldset, class: fieldset_css_class) do
draw_control_column(bootstrap, offset: true) do
if bootstrap.custom_control
content_tag(:div, class: "custom-control custom-radio") do
add_css_class!(options, "custom-control-input")
remove_css_class!(options, "form-check-input")
concat super(method, tag_value, options)
concat label(method, label_text, value: tag_value, class: "custom-control-label")
concat errors if errors.present?
concat help_text if help_text.present?
end
else
content_tag(:div, class: "form-check") do
concat super(method, tag_value, options)
concat label(method, label_text, value: tag_value, class: "form-check-label")
concat errors if errors.present?
concat help_text if help_text.present?
end
end
end
end
end
# Wrapper around checkbox. Example usage:
#
# checkbox :agree, bootstrap: {label: {text: "Do you agree?"}}
#
def check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
return super if bootstrap.disabled
help_text = draw_help(bootstrap)
errors = draw_errors(bootstrap, method)
add_css_class!(options, "form-check-input")
add_css_class!(options, "is-invalid") if errors.present?
label_text = nil
if (custom_text = bootstrap.label[:text]).present?
label_text = custom_text
end
fieldset_css_class = "form-group"
fieldset_css_class += " row" if bootstrap.horizontal?
fieldset_css_class += " #{bootstrap.inline_margin_class}" if bootstrap.inline?
content_tag(:fieldset, class: fieldset_css_class) do
draw_control_column(bootstrap, offset: true) do
if bootstrap.custom_control
content_tag(:div, class: "custom-control custom-checkbox") do
add_css_class!(options, "custom-control-input")
remove_css_class!(options, "form-check-input")
concat super(method, options, checked_value, unchecked_value)
concat label(method, label_text, class: "custom-control-label")
concat errors if errors.present?
concat help_text if help_text.present?
end
else
content_tag(:div, class: "form-check") do
concat super(method, options, checked_value, unchecked_value)
concat label(method, label_text, class: "form-check-label")
concat errors if errors.present?
concat help_text if help_text.present?
end
end
end
end
end
# Helper to generate multiple radio buttons. Example usage:
#
# collection_radio_buttons :choices, ["a", "b"], :to_s, :to_s %>
# collection_radio_buttons :choices, [["a", "Label A"], ["b", "Label B"]], :first, :second
# collection_radio_buttons :choices, Choice.all, :id, :label
#
# Takes bootstrap options:
# inline: true - to render inputs inline
# label: {text: "Custom"} - to specify a label
# label: {hide: true} - to not render label at all
#
def collection_radio_buttons(method, collection, value_method, text_method, options = {}, html_options = {})
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
return super if bootstrap.disabled
args = [bootstrap, :radio_button, method, collection, value_method, text_method, options, html_options]
draw_choices(*args) do |m, v, opts|
radio_button(m, v, opts.merge(bootstrap: { disabled: true }))
end
end
# Helper to generate multiple checkboxes. Same options as for radio buttons.
# Example usage:
#
# collection_check_boxes :choices, Choice.all, :id, :label
#
def collection_check_boxes(method, collection, value_method, text_method, options = {}, html_options = {})
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
return super if bootstrap.disabled
content = "".html_safe
unless options[:include_hidden] == false
content << hidden_field(method, multiple: true, value: "")
end
args = [bootstrap, :check_box, method, collection, value_method, text_method, options, html_options]
content << draw_choices(*args) do |m, v, opts|
opts[:multiple] = true
opts[:include_hidden] = false
check_box(m, opts.merge(bootstrap: { disabled: true }), v)
end
end
# Bootstrap wrapper for readonly text field that is shown as plain text.
#
# plaintext(:value)
#
def plaintext(method, options = {})
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
draw_form_group(bootstrap, method, options) do
remove_css_class!(options, "form-control")
add_css_class!(options, "form-control-plaintext")
options[:readonly] = true
ActionView::Helpers::FormBuilder.instance_method(:text_field).bind(self).call(method, options)
end
end
# Add bootstrap formatted submit button. If you need to change its type or
# add another css class, you need to override all css classes like so:
#
# submit(class: "btn btn-info custom-class")
#
# You may add additional content that directly follows the button. Here's
# an example of a cancel link:
#
# submit do
# link_to("Cancel", "/", class: "btn btn-link")
# end
#
def submit(value = nil, options = {}, &block)
if value.is_a?(Hash)
options = value
value = nil
end
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
return super if bootstrap.disabled
add_css_class!(options, "btn")
form_group_class = "form-group"
form_group_class += " row" if bootstrap.horizontal?
content_tag(:div, class: form_group_class) do
draw_control_column(bootstrap, offset: true) do
out = super(value, options)
out << capture(&block) if block_given?
out
end
end
end
# Same as submit button, only with btn-primary class added
def primary(value = nil, options = {}, &block)
add_css_class!(options, "btn-primary")
submit(value, options, &block)
end
# Helper method to put arbitrary content in markup that renders correctly
# for the Bootstrap form. Example:
#
# form_group bootstrap: {label: {text: "Label"}} do
# "Some content"
# end
#
def form_group(options = {})
bootstrap = form_bootstrap.scoped(options.delete(:bootstrap))
label_options = bootstrap.label.clone
label_text = label_options.delete(:text)
label =
if label_text.present?
if bootstrap.horizontal?
add_css_class!(label_options, "col-form-label")
add_css_class!(label_options, bootstrap.label_col_class)
add_css_class!(label_options, bootstrap.label_align_class)
elsif bootstrap.inline?
add_css_class!(label_options, bootstrap.inline_margin_class)
end
content_tag(:label, label_text, label_options)
end
form_group_class = "form-group"
form_group_class += " row" if bootstrap.horizontal?
form_group_class += " mr-sm-2" if bootstrap.inline?
content_tag(:div, class: form_group_class) do
content = "".html_safe
content << label if label.present?
content << draw_control_column(bootstrap, offset: label.blank?) do
yield
end
end
end
private
# form group wrapper for input fields
def draw_form_group(bootstrap, method, options)
label = draw_label(bootstrap, method, for_attr: options[:id])
errors = draw_errors(bootstrap, method)
control = draw_control(bootstrap, errors, method, options) do
yield
end
form_group_class = "form-group"
form_group_class += " row" if bootstrap.horizontal?
form_group_class += " mr-sm-2" if bootstrap.inline?
content_tag(:div, class: form_group_class) do
concat label
concat control
end
end
def draw_errors(bootstrap, method)
errors = []
if bootstrap.error.present?
errors = [bootstrap.error]
else
return if object.nil?
errors = object.errors[method]
# If error is on association like `belongs_to :foo`, we need to render it
# on an input field with `:foo_id` name.
if errors.blank?
errors = object.errors[method.to_s.sub(%r{_id$}, "")]
end
end
return if errors.blank?
content_tag(:div, class: "invalid-feedback") do
errors.join(", ")
end
end
# Renders label for a given field. Takes following bootstrap options:
#
# :text - replace default label text
# :class - css class on the label
# :hide - if `true` will render for screen readers only
#
# This is how those options can be passed in:
#
# text_field(:value, bootstrap: {label: {text: "Custom", class: "custom"}})
#
# You may also just set the label text by passing a string instead of label hash:
#
# text_field(:value, bootstrap: {label: "Custom Label"})
#
def draw_label(bootstrap, method, for_attr: nil)
options = bootstrap.label.dup
text = options.delete(:text)
options[:for] = for_attr if for_attr.present?
add_css_class!(options, "sr-only") if options.delete(:hide)
add_css_class!(options, bootstrap.inline_margin_class) if bootstrap.inline?
if bootstrap.horizontal?
add_css_class!(options, "col-form-label")
add_css_class!(options, bootstrap.label_col_class)
add_css_class!(options, bootstrap.label_align_class)
end
label(method, text, options)
end
# Renders control for a given field
def draw_control(bootstrap, errors, _method, options)
add_css_class!(options, "form-control")
add_css_class!(options, "is-invalid") if errors.present?
draw_control_column(bootstrap, offset: bootstrap.label[:hide]) do
draw_input_group(bootstrap, errors) do
yield
end
end
end
# Wrapping in control in column wrapper
#
def draw_control_column(bootstrap, offset:)
return yield unless bootstrap.horizontal?
css_class = bootstrap.control_col_class.to_s
css_class += " #{bootstrap.offset_col_class}" if offset
content_tag(:div, class: css_class) do
yield
end
end
# Wraps input field in input group container that allows prepending and
# appending text or html. Example:
#
# text_field(:value, bootstrap: {prepend: "$.$$"}})
# text_field(:value, bootstrap: {append: {html: "<button>Go</button>"}}})
#
def draw_input_group(bootstrap, errors, &block)
prepend_html = draw_input_group_content(bootstrap, :prepend)
append_html = draw_input_group_content(bootstrap, :append)
help_text = draw_help(bootstrap)
# Not prepending or appending anything. Bail.
if prepend_html.blank? && append_html.blank?
content = capture(&block)
content << errors if errors.present?
content << help_text if help_text.present?
return content
end
content = "".html_safe
content << content_tag(:div, class: "input-group") do
concat prepend_html if prepend_html.present?
concat capture(&block)
concat append_html if append_html.present?
concat errors if errors.present?
end
content << help_text if help_text.present?
content
end
def draw_input_group_content(bootstrap, type)
value = bootstrap.send(type)
return unless value.present?
content_tag(:div, class: "input-group-#{type}") do
if value.is_a?(Hash) && value[:html].present?
value[:html]
else
content_tag(:span, value, class: "input-group-text")
end
end
end
# Drawing boostrap form field help text. Example usage:
#
# text_field(:value, bootstrap: {help: "help text"})
#
def draw_help(bootstrap)
text = bootstrap.help
return if text.blank?
content_tag(:small, text, class: "form-text text-muted")
end
# Rendering of choices for checkboxes and radio buttons
def draw_choices(bootstrap, type, method, collection, value_method, text_method, _options, html_options)
draw_form_group_fieldset(bootstrap, method) do
if bootstrap.custom_control
label_css_class = "custom-control-label"
form_check_css_class = "custom-control"
form_check_css_class +=
case type
when :radio_button then " custom-radio"
when :check_box then " custom-checkbox"
end
form_check_css_class += " custom-control-inline" if bootstrap.check_inline
add_css_class!(html_options, "custom-control-input")
else
label_css_class = "form-check-label"
form_check_css_class = "form-check"
form_check_css_class += " form-check-inline" if bootstrap.check_inline
add_css_class!(html_options, "form-check-input")
end
errors = draw_errors(bootstrap, method)
help_text = draw_help(bootstrap)
add_css_class!(html_options, "is-invalid") if errors.present?
content = "".html_safe
collection.each_with_index do |item, index|
item_value = item.send(value_method)
item_text = item.send(text_method)
content << content_tag(:div, class: form_check_css_class) do
concat yield method, item_value, html_options
concat label(method, item_text, value: item_value, class: label_css_class)
if ((collection.count - 1) == index) && !bootstrap.check_inline
concat errors if errors.present?
concat help_text if help_text.present?
end
end
end
if bootstrap.check_inline
content << errors if errors.present?
content << help_text if help_text.present?
end
content
end
end
# Wrapper for collections of radio buttons and checkboxes
def draw_form_group_fieldset(bootstrap, method)
options = {}
unless bootstrap.label[:hide]
label_text = bootstrap.label[:text]
label_text ||= ActionView::Helpers::Tags::Label::LabelBuilder
.new(@template, @object_name.to_s, method, @object, nil).translation
add_css_class!(options, "col-form-label pt-0")
add_css_class!(options, bootstrap.label[:class])
if bootstrap.horizontal?
add_css_class!(options, bootstrap.label_col_class)
add_css_class!(options, bootstrap.label_align_class)
end
label = content_tag(:legend, options) do
label_text
end
end
content_tag(:fieldset, class: "form-group") do
content = "".html_safe
content << label if label.present?
content << draw_control_column(bootstrap, offset: bootstrap.label[:hide]) do
yield
end
if bootstrap.horizontal?
content_tag(:div, content, class: "row")
else
content
end
end
end
def add_css_class!(options, string)
css_class = [options[:class], string].compact.join(" ")
options[:class] = css_class if css_class.present?
end
def remove_css_class!(options, string)
css_class = options[:class].to_s.split(" ")
options[:class] = (css_class - [string]).compact.join(" ")
options.delete(:class) if options[:class].blank?
end
end
end