Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor Builder using modules #134

Merged
merged 10 commits into from
Feb 4, 2023
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Changed
- Split `Form::Builder` into modules, to allow including only some modules instead of inheriting the whole class (#134)

### Fixed
- Update dependencies (#128)
- Add Ruby 3.2 to CI (#128)
Expand Down
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ This allows you to pick the namespace your components will be loaded from.
# lib/custom_form_builder.rb
class CustomFormBuilder < ViewComponent::Form::Builder
# Set the namespace you want to use for your own components
namespace Custom::Form
namespace "Custom::Form"
end
```

Expand All @@ -116,7 +116,32 @@ bin/rails generate vcf:builder AnotherCustomFormBuilder --namespace AnotherCusto
# app/forms/another_custom_form_builder.rb
class AnotherCustomFormBuilder < ViewComponent::Form::Builder
# Set the namespace you want to use for your own components
namespace AnotherCustom::Form
namespace "AnotherCustom::Form"
end
```

Another approach is to include only some modules instead of inheriting from the whole class:

```rb
# app/forms/modular_custom_form_builder.rb
class ModularCustomFormBuilder < ActionView::Helpers::FormBuilder
# Provides `render_component` method and namespace management
include ViewComponent::Form::Renderer

# Exposes a `validation_context` to your components
include ViewComponent::Form::ValidationContext

# All standard Rails form helpers
include ViewComponent::Form::Helpers::Rails

# Backports of newest Rails form helpers
# include ViewComponent::Form::Helpers::RailsBackports

# Additional form helpers provided by ViewComponent::Form
# include ViewComponent::Form::Helpers::Custom

# Set the namespace you want to use for your own components
namespace "AnotherCustom::Form"
end
```

Expand Down
21 changes: 21 additions & 0 deletions lib/generators/vcf/builder/templates/builder.rb.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
# frozen_string_literal: true

class <%= class_name %> < ViewComponent::Form::Builder
# Instead of inheriting from ViewComponent::Form::Builder,
# you can also inherit from ActionView::Helpers::FormBuilder
# then include only the modules you need:

# Provides `render_component` method and namespace management
# include ViewComponent::Form::Renderer

# Exposes a `validation_context` to your components
# include ViewComponent::Form::ValidationContext

# All standard Rails form helpers
# include ViewComponent::Form::Helpers::Rails

# Backports of newest Rails form helpers
# include ViewComponent::Form::Helpers::RailsBackports

# Additional form helpers provided by ViewComponent::Form
# include ViewComponent::Form::Helpers::Custom

# Set the namespace you want to use for your own components
# requires inheriting from ViewComponent::Form::Builder
# or including ViewComponent::Form::Renderer
namespace "<%= components_namespace %>"
end
245 changes: 5 additions & 240 deletions lib/view_component/form/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,246 +3,11 @@
module ViewComponent
module Form
class Builder < ActionView::Helpers::FormBuilder
class Error < StandardError; end

class NotImplementedComponentError < Error; end

class NamespaceAlreadyAddedError < Error; end

class_attribute :lookup_namespaces, default: [ViewComponent::Form]

class << self
def inherited(base)
base.lookup_namespaces = lookup_namespaces.dup

super
end

def namespace(namespace)
if lookup_namespaces.include?(namespace)
raise NamespaceAlreadyAddedError, "The component namespace '#{namespace}' is already added"
end

lookup_namespaces.prepend namespace
end
end

attr_reader :validation_context

def initialize(*)
@__component_klass_cache = {}

super

@validation_context = options[:validation_context]
end

(field_helpers - %i[
check_box
datetime_field
datetime_local_field
fields
fields_for
file_field
hidden_field
label
phone_field
radio_button
]).each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {}) # def text_field(method, options = {})
render_component( # render_component(
:#{selector}, # :text_field,
@object_name, # @object_name,
method, # method,
objectify_options(options), # objectify_options(options),
) # )
end # end
RUBY_EVAL
end
alias phone_field telephone_field

# See: https://github.com/rails/rails/blob/33d60cb02dcac26d037332410eabaeeb0bdc384c/actionview/lib/action_view/helpers/form_helper.rb#L2280
def label(method, text = nil, options = {}, &block)
render_component(:label, @object_name, method, text, objectify_options(options), &block)
end

def datetime_field(method, options = {})
render_component(
:datetime_local_field, @object_name, method, objectify_options(options)
)
end
alias datetime_locale_field datetime_field

def check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
render_component(
:check_box, @object_name, method, checked_value, unchecked_value, objectify_options(options)
)
end

def radio_button(method, tag_value, options = {})
render_component(
:radio_button, @object_name, method, tag_value, objectify_options(options)
)
end

def file_field(method, options = {})
self.multipart = true
render_component(:file_field, @object_name, method, objectify_options(options))
end

def submit(value = nil, options = {})
if value.is_a?(Hash)
options = value
value = nil
end
value ||= submit_default_value
render_component(:submit, value, options)
end

def button(value = nil, options = {}, &block)
if value.is_a?(Hash)
options = value
value = nil
end
value ||= submit_default_value
render_component(:button, value, options, &block)
end

# See: https://github.com/rails/rails/blob/fe76a95b0d252a2d7c25e69498b720c96b243ea2/actionview/lib/action_view/helpers/form_options_helper.rb
def select(method, choices = nil, options = {}, html_options = {}, &block)
render_component(
:select, @object_name, method, choices, objectify_options(options),
@default_html_options.merge(html_options), &block
)
end

# rubocop:disable Metrics/ParameterLists
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
render_component(
:collection_select, @object_name, method, collection, value_method, text_method,
objectify_options(options), @default_html_options.merge(html_options)
)
end

def grouped_collection_select(
method, collection,
group_method, group_label_method, option_key_method, option_value_method,
options = {}, html_options = {}
)
render_component(
:grouped_collection_select, @object_name, method, collection, group_method,
group_label_method, option_key_method, option_value_method,
objectify_options(options), @default_html_options.merge(html_options)
)
end

def collection_check_boxes(method, collection, value_method, text_method, options = {}, html_options = {}, &block)
render_component(
:collection_check_boxes, @object_name, method, collection, value_method, text_method,
objectify_options(options), @default_html_options.merge(html_options), &block
)
end

def collection_radio_buttons(
method, collection,
value_method, text_method,
options = {}, html_options = {},
&block
)
render_component(
:collection_radio_buttons, @object_name, method, collection, value_method, text_method,
objectify_options(options), @default_html_options.merge(html_options), &block
)
end
# rubocop:enable Metrics/ParameterLists

def date_select(method, options = {}, html_options = {})
render_component(
:date_select, @object_name, method,
objectify_options(options), @default_html_options.merge(html_options)
)
end

def datetime_select(method, options = {}, html_options = {})
render_component(
:datetime_select, @object_name, method,
objectify_options(options), @default_html_options.merge(html_options)
)
end

def time_select(method, options = {}, html_options = {})
render_component(
:time_select, @object_name, method,
objectify_options(options), @default_html_options.merge(html_options)
)
end

def time_zone_select(method, priority_zones = nil, options = {}, html_options = {})
render_component(
:time_zone_select, @object_name, method, priority_zones,
objectify_options(options), @default_html_options.merge(html_options)
)
end

if defined?(ActionView::Helpers::Tags::ActionText)
def rich_text_area(method, options = {})
render_component(:rich_text_area, @object_name, method, objectify_options(options))
end
end

def error_message(method, options = {})
render_component(:error_message, @object_name, method, objectify_options(options))
end

def hint(method, text = nil, options = {}, &block)
render_component(:hint, @object_name, method, text, objectify_options(options), &block)
end

# Backport field_id from Rails 7.0
if Rails::VERSION::MAJOR < 7
def field_id(method_name, *suffixes, namespace: @options[:namespace], index: @index)
object_name = object_name.model_name.singular if object_name.respond_to?(:model_name)

sanitized_object_name = object_name.to_s.gsub(/\]\[|[^-a-zA-Z0-9:.]/, "_").delete_suffix("_")

sanitized_method_name = method_name.to_s.delete_suffix("?")

[
namespace,
sanitized_object_name.presence,
(index unless sanitized_object_name.empty?),
sanitized_method_name,
*suffixes
].tap(&:compact!).join("_")
end
end

private

def render_component(component_name, *args, &block)
component = component_klass(component_name).new(self, *args)
component.render_in(@template, &block)
end

def objectify_options(options)
@default_options.merge(options.merge(object: @object))
end

def component_klass(component_name)
@__component_klass_cache[component_name] ||= begin
component_klass = self.class.lookup_namespaces.filter_map do |namespace|
"#{namespace}::#{component_name.to_s.camelize}Component".safe_constantize || false
end.first

unless component_klass.is_a?(Class) && component_klass < ViewComponent::Base
raise NotImplementedComponentError, "Component named #{component_name} doesn't exist " \
"or is not a ViewComponent::Base class"
end

component_klass
end
end
include ViewComponent::Form::Renderer
include ViewComponent::Form::ValidationContext
include ViewComponent::Form::Helpers::Rails
include ViewComponent::Form::Helpers::RailsBackports
include ViewComponent::Form::Helpers::Custom
end
end
end
17 changes: 17 additions & 0 deletions lib/view_component/form/helpers/custom.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module ViewComponent
module Form
module Helpers
module Custom
def error_message(method, options = {})
render_component(:error_message, @object_name, method, objectify_options(options))
end

def hint(method, text = nil, options = {}, &block)
render_component(:hint, @object_name, method, text, objectify_options(options), &block)
end
end
end
end
end
Loading