diff --git a/lib/prism/view/form_builder.rb b/lib/prism/view/form_builder.rb new file mode 100644 index 0000000..fd29990 --- /dev/null +++ b/lib/prism/view/form_builder.rb @@ -0,0 +1,152 @@ +module Prism + # Binds a set of /" + 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 + "#{body}" + 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) + "#{escape_html(text.to_s)}" + 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 diff --git a/lib/prism/view/form_helpers.rb b/lib/prism/view/form_helpers.rb new file mode 100644 index 0000000..8995d63 --- /dev/null +++ b/lib/prism/view/form_helpers.rb @@ -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 << '' + 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 = +"
" + markup << hidden_method_field(verb) if MethodOverrideVerbs.include?(verb) + markup << csrf_hidden_field unless verb == 'GET' + markup + end + + def hidden_method_field(verb) + %() + end + + def csrf_hidden_field + return '' unless respond_to?(:csrf_token) + token = csrf_token + return '' unless token + %() + end + end +end