feat(view): add Prism, FormBuilder and FormHelpers
This commit is contained in:
1 parent
8ec5cbe0ed
commit
17c4602543
2 files changed
+197
No files matched your search
@@ -0,0 +1,152 @@
|
|||||||
|
module Prism
|
||||||
|
# Binds a set of <input>/<select>/<textarea> tags to a model instance:
|
||||||
|
# field names/ids follow Rails' `object[attribute]` convention so params
|
||||||
|
# arrive nested and ready for StrongParameters (`params.require(:post).permit(...)`),
|
||||||
|
# and values/errors are read straight off the bound object.
|
||||||
|
class FormBuilder
|
||||||
|
include TagBuilder
|
||||||
|
|
||||||
|
attr_reader :object, :object_name
|
||||||
|
|
||||||
|
def initialize(object, object_name: nil)
|
||||||
|
@object = object
|
||||||
|
@object_name = (object_name || infer_object_name(object)).to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def label(attribute, text = nil, **attrs)
|
||||||
|
content_tag(:label, text || humanize(attribute), attrs.merge(for: field_id(attribute)))
|
||||||
|
end
|
||||||
|
|
||||||
|
def hidden_field(attribute, **attrs)
|
||||||
|
input_tag(attribute, 'hidden', attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def text_field(attribute, **attrs)
|
||||||
|
input_tag(attribute, 'text', attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def password_field(attribute, **attrs)
|
||||||
|
options = attrs.dup
|
||||||
|
options[:value] = options.fetch(:value, '')
|
||||||
|
input_tag(attribute, 'password', options)
|
||||||
|
end
|
||||||
|
|
||||||
|
def email_field(attribute, **attrs)
|
||||||
|
input_tag(attribute, 'email', attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def number_field(attribute, **attrs)
|
||||||
|
input_tag(attribute, 'number', attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def date_field(attribute, **attrs)
|
||||||
|
input_tag(attribute, 'date', attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def text_area(attribute, **attrs)
|
||||||
|
options = attrs.dup
|
||||||
|
value = options.key?(:value) ? options.delete(:value) : field_value(attribute)
|
||||||
|
"<textarea#{html_attributes({ name: field_name(attribute), id: field_id(attribute) }.merge(options))}>#{escape_html(value.to_s)}</textarea>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_box(attribute, checked_value: '1', unchecked_value: '0', **attrs)
|
||||||
|
options = attrs.dup
|
||||||
|
checked = options.key?(:checked) ? options.delete(:checked) : truthy?(field_value(attribute))
|
||||||
|
hidden = tag(:input, type: 'hidden', name: field_name(attribute), value: unchecked_value)
|
||||||
|
box = tag(:input, { type: 'checkbox', name: field_name(attribute), id: field_id(attribute),
|
||||||
|
value: checked_value, checked: checked }.merge(options))
|
||||||
|
hidden + box
|
||||||
|
end
|
||||||
|
|
||||||
|
def select(attribute, choices, include_blank: false, **attrs)
|
||||||
|
current = field_value(attribute)
|
||||||
|
body = +''
|
||||||
|
body << option_tag('', '', current.nil? || current == '') if include_blank
|
||||||
|
choices.each do |choice|
|
||||||
|
text, value = choice.is_a?(Array) ? choice : [choice, choice]
|
||||||
|
body << option_tag(text, value, value.to_s == current.to_s)
|
||||||
|
end
|
||||||
|
"<select#{html_attributes({ name: field_name(attribute), id: field_id(attribute) }.merge(attrs))}>#{body}</select>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def submit(value = 'Save', **attrs)
|
||||||
|
tag(:input, { type: 'submit', value: value }.merge(attrs))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Nested-attributes counterpart to a plain field: yields a fresh
|
||||||
|
# FormBuilder per record in `collection` (defaults to
|
||||||
|
# `object.public_send(association)`), each named
|
||||||
|
# "post[comments_attributes][0]" so submitted fields land at
|
||||||
|
# "post[comments_attributes][0][body]" - exactly the bracket shape
|
||||||
|
# Prism::Params.nested_from expands back into an indexed Hash, which is
|
||||||
|
# what StrongParameters#permit and Model#accepts_nested_attributes_for
|
||||||
|
# both expect on the way back in. Every yielded builder is over an
|
||||||
|
# ALREADY-PERSISTED record (`f.hidden_field(:id)` inside the block is
|
||||||
|
# what makes update-vs-destroy resolution possible server-side) -
|
||||||
|
# building brand-new nested rows in a form is a separate, JS-assisted
|
||||||
|
# concern this helper does not attempt.
|
||||||
|
def fields_for(association, collection = nil)
|
||||||
|
records = collection || @object.public_send(association)
|
||||||
|
Array(records).each_with_index do |record, index|
|
||||||
|
yield FormBuilder.new(record, object_name: "#{@object_name}[#{association}_attributes][#{index}]")
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def errors_for(attribute)
|
||||||
|
return [] unless @object.respond_to?(:errors)
|
||||||
|
@object.errors[attribute]
|
||||||
|
end
|
||||||
|
|
||||||
|
def error_messages(attribute, wrapper: :span, **attrs)
|
||||||
|
messages = errors_for(attribute)
|
||||||
|
return '' if messages.empty?
|
||||||
|
content_tag(wrapper, messages.join(', '), { class: 'field-error' }.merge(attrs))
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def escape_html(text)
|
||||||
|
CGI.escapeHTML(text)
|
||||||
|
end
|
||||||
|
|
||||||
|
def input_tag(attribute, type, attrs)
|
||||||
|
options = attrs.dup
|
||||||
|
value = options.key?(:value) ? options.delete(:value) : field_value(attribute)
|
||||||
|
tag(:input, { type: type, name: field_name(attribute), id: field_id(attribute), value: value }.merge(options))
|
||||||
|
end
|
||||||
|
|
||||||
|
def option_tag(text, value, selected)
|
||||||
|
"<option#{html_attributes(value: value, selected: selected)}>#{escape_html(text.to_s)}</option>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def field_name(attribute)
|
||||||
|
"#{@object_name}[#{attribute}]"
|
||||||
|
end
|
||||||
|
|
||||||
|
# For a plain object_name ("post") this is a no-op; for a fields_for
|
||||||
|
# nested builder ("post[comments_attributes][0]") it turns the bracket
|
||||||
|
# notation used in `name=` into a flat, HTML-id-legal token
|
||||||
|
# ("post_comments_attributes_0") the same way Rails does.
|
||||||
|
def field_id(attribute)
|
||||||
|
"#{@object_name.tr('[]', '_').squeeze('_').sub(/_\z/, '')}_#{attribute}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def field_value(attribute)
|
||||||
|
@object.respond_to?(attribute) ? @object.public_send(attribute) : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def humanize(attribute)
|
||||||
|
attribute.to_s.tr('_', ' ').capitalize
|
||||||
|
end
|
||||||
|
|
||||||
|
def truthy?(value)
|
||||||
|
!(value.nil? || value == false || value == '' || value == 0 || value == '0')
|
||||||
|
end
|
||||||
|
|
||||||
|
def infer_object_name(object)
|
||||||
|
klass = object.is_a?(Class) ? object : object.class
|
||||||
|
Inflector.underscore(klass.name.to_s.split('::').last)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
module Prism
|
||||||
|
# `form_for`/`form_with` are block-taking helpers, so (like content_tag
|
||||||
|
# with a block) they are invoked from a plain `<% %>` template tag and
|
||||||
|
# append their own markup to @__out. Non-GET/POST verbs are shipped as a
|
||||||
|
# POST with a hidden `_method` field; Prism::Request#request_method
|
||||||
|
# reads it back so `resources` routes for PATCH/PUT/DELETE still match.
|
||||||
|
module FormHelpers
|
||||||
|
MethodOverrideVerbs = %w[PUT PATCH DELETE].freeze
|
||||||
|
|
||||||
|
def form_for(record, url:, method: nil, object_name: nil, html: {})
|
||||||
|
verb = (method || infer_form_method(record)).to_s.upcase
|
||||||
|
builder = FormBuilder.new(record, object_name: object_name)
|
||||||
|
@__out << form_open_tag(url, verb, html)
|
||||||
|
yield builder
|
||||||
|
@__out << '</form>'
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
alias_method :form_with, :form_for
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def infer_form_method(record)
|
||||||
|
record.respond_to?(:persisted?) && record.persisted? ? :patch : :post
|
||||||
|
end
|
||||||
|
|
||||||
|
def form_open_tag(url, verb, html)
|
||||||
|
submit_verb = MethodOverrideVerbs.include?(verb) ? 'post' : verb.downcase
|
||||||
|
markup = +"<form action=\"#{escape_html(url)}\" method=\"#{submit_verb}\"#{html_attributes(html)}>"
|
||||||
|
markup << hidden_method_field(verb) if MethodOverrideVerbs.include?(verb)
|
||||||
|
markup << csrf_hidden_field unless verb == 'GET'
|
||||||
|
markup
|
||||||
|
end
|
||||||
|
|
||||||
|
def hidden_method_field(verb)
|
||||||
|
%(<input type="hidden" name="_method" value="#{verb}">)
|
||||||
|
end
|
||||||
|
|
||||||
|
def csrf_hidden_field
|
||||||
|
return '' unless respond_to?(:csrf_token)
|
||||||
|
token = csrf_token
|
||||||
|
return '' unless token
|
||||||
|
%(<input type="hidden" name="authenticity_token" value="#{escape_html(token)}">)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in new issue
Block a user