feat(view): add Prism, FormBuilder and FormHelpers

This commit is contained in:
dualfroz committed 2026-09-08 23:49:47 +02:00
1 parent 8ec5cbe0ed
commit 17c4602543
2 files changed
+197

No files matched your search

+152
View File
@@ -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
+45
View File
@@ -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