From 316483a61f4f69045a0180e7bf631ebee066bd95 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Fri, 31 Jul 2020 23:10:45 -0400 Subject: [PATCH] Proxy FormBuilder view calls through TemplateProxy One major drawback to the original `ViewPartialFormBuilder::FormBuilder` implementation was its obligation to totally re-create the underlying `FormBuilder` behavior, including minor implementation details like calling `objectify_options` and setting and unsetting instance variables like `@emitted_hidden_id = true if method == :id`. Another drawback is that if the method wasn't declared, there would be no means of declaring a corresponding view partial. This is exactly what happened with the `date_select` helper (fixed in [#13][]). This new implementation proxies calls to render ActionView instances through the `ViewPartialFormBuilder::TemplateProxy`, which first attempts to resolve a view partial that matches the method call. It is not without its quirks. For example, the `#button` and `#submit` methods are special case, because they invoke a method on the `@template` that is suffixed with `_tag`. Additionally, the `#label` helper is a special case because of the optional nature of the `text = nil` parameter. [#13]: https://github.com/seanpdoyle/view_partial_form_builder/pull/13 --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 3 + lib/view_partial_form_builder/form_builder.rb | 276 +----------------- .../lookup_override.rb | 8 + .../template_proxy.rb | 108 +++++++ 5 files changed, 125 insertions(+), 272 deletions(-) create mode 100644 lib/view_partial_form_builder/template_proxy.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c46196..d61554e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: include: - ruby: "2.7" rails: "5.2" + - ruby: "3.2" + rails: "main" env: RAILS_VERSION: "${{ matrix.rails }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index d186e80..3560bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ version links. ## main +Implement the `FormBuilder` interface by proxying its underlying view +`@template` instance to `ViewPartialFormBuilder::TemplateProxy`. + Remove support for `partial:` option to override which partial is to be rendered by `ViewPartialFormBuilder::FormBuilder`. diff --git a/lib/view_partial_form_builder/form_builder.rb b/lib/view_partial_form_builder/form_builder.rb index e63a8e7..9472eaa 100644 --- a/lib/view_partial_form_builder/form_builder.rb +++ b/lib/view_partial_form_builder/form_builder.rb @@ -4,280 +4,12 @@ class FormBuilder < ActionView::Helpers::FormBuilder def initialize(*) super - - @default = ActionView::Helpers::FormBuilder.new( - object_name, - object, - @template, - options, - ) - @lookup_override = LookupOverride.new( - prefixes: @template.lookup_context.prefixes, - object_name: object&.model_name || object_name, - view_partial_directory: ViewPartialFormBuilder.view_partial_directory, - ) - end - - def label(method, text_or_options = nil, options = {}, &block) - if text_or_options.is_a?(Hash) - options.merge! text_or_options - text_or_options = nil - end - - locals = { - method: method, - text: text_or_options, - options: options, - block: block, - } - - render_partial("label", locals, fallback: -> { super }, &block) - end - - def check_box(method, options = {}, checked_value = "1", unchecked_value = "0") - locals = { - method: method, - options: options, - checked_value: checked_value, - unchecked_value: unchecked_value, - } - - render_partial("check_box", locals, fallback: -> { super }) - end - - def radio_button(method, tag_value, options = {}) - locals = { - method: method, - tag_value: tag_value, - options: options, - } - - render_partial("radio_button", locals, fallback: -> { super }) - end - - def select(method, choices = nil, options = {}, html_options = {}, &block) - html_options = @default_html_options.merge(html_options) - - locals = { - method: method, - choices: choices, - options: options, - html_options: html_options, - block: block, - } - - render_partial("select", locals, fallback: -> { super }, &block) - end - - def collection_select(method, collection, value_method, text_method, options = {}, html_options = {}) - html_options = @default_html_options.merge(html_options) - - locals = { - method: method, - collection: collection, - value_method: value_method, - text_method: text_method, - options: options, - html_options: html_options, - } - - render_partial("collection_select", locals, fallback: -> { super }) - end - - def collection_check_boxes(method, collection, value_method, text_method, options = {}, html_options = {}, &block) - html_options = @default_html_options.merge(html_options) - - locals = { - method: method, - collection: collection, - value_method: value_method, - text_method: text_method, - options: options, - html_options: html_options, - block: block, - } - - render_partial("collection_check_boxes", locals, fallback: -> { super }, &block) - end - - def collection_radio_buttons(method, collection, value_method, text_method, options = {}, html_options = {}, &block) - html_options = @default_html_options.merge(html_options) - - locals = { - method: method, - collection: collection, - value_method: value_method, - text_method: text_method, - options: options, - html_options: html_options, - block: block, - } - - render_partial("collection_radio_buttons", locals, fallback: -> { super }, &block) - end - - def grouped_collection_select(method, collection, group_method, group_label_method, option_key_method, option_value_method, options = {}, html_options = {}) - html_options = @default_html_options.merge(html_options) - - locals = { - method: method, - collection: collection, - group_method: group_method, - group_label_method: group_label_method, - option_key_method: option_key_method, - option_value_method: option_value_method, - html_options: html_options, - options: options, - } - - render_partial("grouped_collection_select", locals, fallback: -> { super }) - end - - def time_zone_select(method, priority_zones = nil, options = {}, html_options = {}) - html_options = @default_html_options.merge(html_options) - - locals = { - method: method, - priority_zones: priority_zones, - html_options: html_options, - options: options, - } - - render_partial("time_zone_select", locals, fallback: -> { super }) - end - - def date_select(method, options = {}, html_options = {}) - locals = { - method: method, - options: options, - html_options: html_options, - } - - render_partial("date_select", locals, fallback: -> { super }) - end - - def hidden_field(method, options = {}) - @emitted_hidden_id = true if method == :id - - locals = { - method: method, - options: options, - } - - render_partial("hidden_field", locals, fallback: -> { super }) - end - - def file_field(method, options = {}) - self.multipart = true - - locals = { - method: method, - options: options, - } - - render_partial("file_field", locals, fallback: -> { super }) - end - - def submit(value = nil, options = {}) - value, options = nil, value if value.is_a?(Hash) - value ||= submit_default_value - - locals = { - value: value, - options: options, - } - - render_partial("submit", locals, fallback: -> { super }) - end - - def button(value = nil, options = {}) - value, options = nil, value if value.is_a?(Hash) - value ||= submit_default_value - - locals = { - value: value, - options: options, - } - - render_partial("button", locals, fallback: -> { super }) - end - - DYNAMICALLY_DECLARED = ( - field_helpers + - [:rich_text_area] - - [:label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field] - ) - - DYNAMICALLY_DECLARED.each do |selector| - class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{selector}(method, options = {}) - render_partial( - "#{selector}", - { - method: method, - options: options, - }, - fallback: -> { super }, - ) - end - RUBY - end - - private - - def render_partial(field, locals, fallback:, &block) - options = objectify_options(locals.fetch(:options, {})) - locals = locals.merge(form: self) - - partial = find_partial(field, locals, prefixes: prefixes_after(field)) - - if partial.nil? || about_to_recurse_infinitely?(partial) - fallback.call - else - partial.render(@template, locals, &block) - end - end - - def find_partial(template_name, locals, prefixes:) - template_is_partial = true - - @template.lookup_context.find_all( - template_name, - prefixes, - template_is_partial, - locals.keys, - ).first.tap do |partial| - root_directory = ViewPartialFormBuilder.view_partial_directory - - if partial&.virtual_path == "#{root_directory}/_#{template_name}" - ActiveSupport::Deprecation.new("0.2.0", "ViewPartialFormBuilder").warn(<<~WARNING.strip) - Declare root-level partials in app/views/application/#{root_directory}/, not app/views/#{root_directory}/. - WARNING - end - end - end - - def prefixes_after(template_name) - prefixes = @lookup_override.prefixes - current_prefix = current_virtual_path.delete_suffix("/_#{template_name}") - - if prefixes.include?(current_prefix) - prefixes.from(prefixes.index(current_prefix).to_i + 1) - else - prefixes - end - end - - def about_to_recurse_infinitely?(partial) - partial.virtual_path == current_virtual_path + @default = dup + @template = TemplateProxy.new(builder: self, template: @template) end - def current_virtual_path - if (current_template = @template.instance_values["current_template"]) - current_template.virtual_path.to_s - else - @template.instance_values["virtual_path"].to_s - end + def _object_for_form_builder(object) + object.is_a?(Array) ? object.last : object end end end diff --git a/lib/view_partial_form_builder/lookup_override.rb b/lib/view_partial_form_builder/lookup_override.rb index b434876..eaf23ca 100644 --- a/lib/view_partial_form_builder/lookup_override.rb +++ b/lib/view_partial_form_builder/lookup_override.rb @@ -21,6 +21,14 @@ def prefixes prefixes.uniq end + def prefixes_after(current_prefix) + if prefixes.include?(current_prefix) + prefixes.from(prefixes.index(current_prefix).to_i + 1) + else + prefixes + end + end + private attr_reader :object_name, :view_partial_directory diff --git a/lib/view_partial_form_builder/template_proxy.rb b/lib/view_partial_form_builder/template_proxy.rb new file mode 100644 index 0000000..5e5a925 --- /dev/null +++ b/lib/view_partial_form_builder/template_proxy.rb @@ -0,0 +1,108 @@ +module ViewPartialFormBuilder + class TemplateProxy + def initialize(builder:, template:) + @template = template + @builder = builder + end + + def button_tag(value, options, &block) + render(:button, arguments: [value, options], block: block) do + @template.button_tag(value, options, &block) + end + end + + def submit_tag(value, options) + render(:submit, arguments: [value, options]) do + @template.submit_tag(value, options) + end + end + + def label(object_name, method, content_or_options = nil, options = nil, &block) + if content_or_options.is_a?(Hash) + options.merge! content_or_options + content = nil + else + content = content_or_options + end + + render(:label, arguments: [method, content, options], block: block) do + @template.label(object_name, method, content, options, &block) + end + end + + private + + def method_missing(name, *arguments, &block) + arguments_after_object_name = arguments.from(1) + + render(name, arguments: arguments_after_object_name, block: block) do + if @template.respond_to?(name) + @template.public_send(name, *arguments, &block) + else + super + end + end + end + + def respond_to_missing?(name, include_private = false) + @template.respond_to_missing?(name, include_private) + end + + def render(partial_name, arguments:, block: nil, &fallback) + locals = extract_partial_locals(partial_name, *arguments).merge( + form: @builder, + block: block + ) + + partial = find_partial(partial_name, locals) + + if partial.nil? || about_to_recurse_infinitely?(partial) + fallback.call + else + partial.render(@template, locals, &block) + end + end + + def extract_partial_locals(form_method, *arguments) + parameters = @builder.method(form_method).parameters + + parameters.each_with_index.each_with_object({}) { |(tuple, index), locals| + _type, parameter = tuple + + locals[parameter] = arguments[index] + } + end + + def find_partial(template_name, locals) + current_prefix = current_virtual_path.delete_suffix("/_#{template_name}") + template_is_partial = true + + @template.lookup_context.find_all( + template_name, + lookup_override.prefixes_after(current_prefix), + template_is_partial, + locals.keys + ).first + end + + def about_to_recurse_infinitely?(partial) + partial.virtual_path == current_virtual_path + end + + def current_virtual_path + if (current_template = @template.instance_values["current_template"]) + current_template.virtual_path.to_s + else + @template.instance_values["virtual_path"].to_s + end + end + + def lookup_override + LookupOverride.new( + prefixes: @template.lookup_context.prefixes, + object_name: @builder.object&.model_name || @builder.object_name, + view_partial_directory: ViewPartialFormBuilder.view_partial_directory + ) + end + end +end