Skip to content

Commit

Permalink
Add form component to the admin
Browse files Browse the repository at this point in the history
Introducing a form component brings a two-fold benefit:

1. It wraps all the form component dependencies in a single place
   so that we don't need to redeclare them in every other form (e.g.,
   the product creation form).
2. It allows defining a form from the outside, which is a first step
   towards making the admin forms configurable.

Internally, it uses Rails' `form_with` helper [1], and all the given
arguments are passed to it. The only exception is the `elements:` key,
which is used to define the form elements.

The form elements are simple builders of renderable things that happen
within a form context. That's to say, to be rendered, both the form
component instance and the Rails' form builder are given to a `#call`
method.

We ship with out-of-the-box form elements for fields, fieldsets and
arbitrary components or HTML.

Notice that there's a circular dependency between form and form
elements. While the form calls the elements to get the renderable
content, the elements can use the form to get the configured
dependencies. For instance, when rendering a text field element, it'll
use the configured text_field dependency in the form. This complexity is
an acceptable trade-off for a double benefit: the ability to change all
the form dependencies at once (e.g., using a different text field
component across a form), and the removal of boilerplate from the form
element definitions. Nonetheless, the default behavior can be overridden
by passing a different component to the form element. Having access to
the form component is also used by the fieldset element to render the
nested elements.

This commit also makes some tangential changes that were necessary:

- We rename the `form:` parameter to `builder:` in the components within
  the form namespace. As we have now a form component, that makes it
  more explicit that it corresponds to the Rails' form builder.
- We add `.rb` files within the preview directories to Tailwind
  watchable files.
- We modify the `mock_component` test helper to accept not providing a
  block.

[1] - https://api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-form_with

Ref. #5329
  • Loading branch information
waiting-for-dev authored and elia committed Sep 29, 2023
1 parent 839dfb3 commit 182f93f
Show file tree
Hide file tree
Showing 27 changed files with 615 additions and 39 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<%= form_with(**@attributes) do |builder| %>
<%= render_elements(@elements, builder) %>
<% end %>
53 changes: 53 additions & 0 deletions admin/app/components/solidus_admin/ui/forms/form/component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

class SolidusAdmin::UI::Forms::Form::Component < SolidusAdmin::BaseComponent
# @param elements [Array<#call(form, builder)>] Builders of renderable
# elements within a form context. They need to implement `#call(form,
# builder)`, where the arguments are an instance of this class and an
# instance of `ActionView::Helpers::FormBuilder`. The method needs to return
# something responding to `#render_in(view_context)`. See the following
# classes for examples:
# - {SolidusAdmin::Form::Elements::Field}
# - {SolidusAdmin::Form::Elements::Fieldset}
# - {SolidusAdmin::Form::Elements::Component}
# - {SolidusAdmin::Form::Elements::HTML}
# @param attributes [Hash] Attributes to pass to the Rails `form_with` helper,
# which is used to render the form.
def initialize(
elements:,
fieldset_component: component("ui/forms/fieldset"),
text_field_component: component("ui/forms/text_field"),
text_area_component: component("ui/forms/text_area"),
**attributes
)
@elements = elements
@fieldset_component = fieldset_component
@text_field_component = text_field_component
@text_area_component = text_area_component
@attributes = attributes
end

# @return [Hash{Symbol => SolidusAdmin::BaseComponent}] Hash of component
# classes dependencies given on initialization.
def dependencies
{
fieldset: @fieldset_component,
text_field: @text_field_component,
text_area: @text_area_component
}
end

# @api private
def render_elements(elements, builder)
safe_join(
elements.map do |element|
render_element(element, builder)
end
)
end

# @api private
def render_element(element, builder)
render element.call(self, builder)
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

# @api private
class SolidusAdmin::UI::Forms::Guidance::Component < SolidusAdmin::BaseComponent
def initialize(field:, form:, hint:, errors:, disabled: false)
def initialize(field:, builder:, hint:, errors:, disabled: false)
@field = field
@form = form
@builder = builder
@hint = hint
@disabled = disabled
@errors = errors || @form.object&.errors || raise(ArgumentError, <<~MSG
@errors = errors || @builder.object&.errors || raise(ArgumentError, <<~MSG
When the form builder is not bound to a model instance, you must pass an
errors Hash (`{ field_name: [errors] }`) to the component.
MSG
Expand Down Expand Up @@ -57,7 +57,7 @@ def error_id
end

def prefix
"#{@form.object_name}_#{@field}"
"#{@builder.object_name}_#{@field}"
end

def aria_describedby
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

# @api private
class SolidusAdmin::UI::Forms::Label::Component < SolidusAdmin::BaseComponent
def initialize(field:, form:)
def initialize(field:, builder:)
@field = field
@form = form
@builder = builder
end

def call
@form.label(@field, class: "block mb-0.5 body-tiny-bold")
@builder.label(@field, class: "block mb-0.5 body-tiny-bold")
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ class SolidusAdmin::UI::Forms::TextArea::Component < SolidusAdmin::BaseComponent
}.freeze

# @param field [Symbol] the name of the field. Usually a model attribute.
# @param form [ActionView::Helpers::FormBuilder] the form builder instance.
# @param builder [ActionView::Helpers::FormBuilder] the form builder instance.
# @param size [Symbol] the size of the field: `:s`, `:m` or `:l`.
# @param hint [String, null] helper text to display below the field.
# @param errors [Hash, nil] a Hash of errors for the field. If `nil` and the
# form is bound to a model instance, the component will automatically fetch
# builder is bound to a model instance, the component will automatically fetch
# the errors from the model.
# @param attributes [Hash] additional HTML attributes to add to the field.
# @raise [ArgumentError] when the form builder is not bound to a model
# instance and no `errors` Hash is passed to the component.
def initialize(
field:,
form:,
builder:,
size: :m,
hint: nil,
errors: nil,
Expand All @@ -28,7 +28,7 @@ def initialize(
**attributes
)
@field = field
@form = form
@builder = builder
@size = size
@hint = hint
@attributes = HashWithIndifferentAccess.new(attributes)
Expand All @@ -40,7 +40,7 @@ def initialize(
def call
guidance = @guidance_component.new(
field: @field,
form: @form,
builder: @builder,
hint: @hint,
errors: @errors,
disabled: @attributes[:disabled]
Expand All @@ -52,7 +52,7 @@ def call
end

def field_tag(guidance)
@form.text_area(
@builder.text_area(
@field,
class: field_classes(guidance),
**field_aria_describedby_attribute(guidance),
Expand Down Expand Up @@ -100,7 +100,7 @@ def field_error_attributes(guidance)
end

def label_tag
render @label_component.new(field: @field, form: @form)
render @label_component.new(field: @field, builder: @builder)
end

def guidance_tag(guidance)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,19 @@ class SolidusAdmin::UI::Forms::TextField::Component < SolidusAdmin::BaseComponen
}.freeze

# @param field [Symbol] the name of the field. Usually a model attribute.
# @param form [ActionView::Helpers::FormBuilder] the form builder instance.
# @param builder [ActionView::Helpers::FormBuilder] the form builder instance.
# @param type [Symbol] the type of the field. Defaults to `:text`.
# @param size [Symbol] the size of the field: `:s`, `:m` or `:l`.
# @param hint [String, null] helper text to display below the field.
# @param errors [Hash, nil] a Hash of errors for the field. If `nil` and the
# form is bound to a model instance, the component will automatically fetch
# builder is bound to a model instance, the component will automatically fetch
# the errors from the model.
# @param attributes [Hash] additional HTML attributes to add to the field.
# @raise [ArgumentError] when the form builder is not bound to a model
# instance and no `errors` Hash is passed to the component.
def initialize(
field:,
form:,
builder:,
type: :text,
size: :m,
hint: nil,
Expand All @@ -47,7 +47,7 @@ def initialize(
**attributes
)
@field = field
@form = form
@builder = builder
@type = type
@size = size
@hint = hint
Expand All @@ -61,7 +61,7 @@ def initialize(
def call
guidance = @guidance_component.new(
field: @field,
form: @form,
builder: @builder,
hint: @hint,
errors: @errors,
disabled: @attributes[:disabled]
Expand All @@ -73,7 +73,7 @@ def call
end

def field_tag(guidance)
@form.send(
@builder.send(
field_helper,
@field,
class: field_classes(guidance),
Expand Down Expand Up @@ -126,7 +126,7 @@ def field_error_attributes(guidance)
end

def label_tag
render @label_component.new(field: @field, form: @form)
render @label_component.new(field: @field, builder: @builder)
end

def guidance_tag(guidance)
Expand Down
2 changes: 1 addition & 1 deletion admin/lib/solidus_admin/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class Configuration < Spree::Preferences::Configuration
SolidusAdmin::Engine.root.join("app/assets/javascripts/**/*.js"),
SolidusAdmin::Engine.root.join("app/views/**/*.erb"),
SolidusAdmin::Engine.root.join("app/components/**/*.{rb,erb,js}"),
SolidusAdmin::Engine.root.join("spec/components/previews/**/*.erb"),
SolidusAdmin::Engine.root.join("spec/components/previews/**/*.{erb,rb}"),

Rails.root.join("public/solidus_admin/*.html"),
Rails.root.join("app/helpers/solidus_admin/**/*.rb"),
Expand Down
27 changes: 27 additions & 0 deletions admin/lib/solidus_admin/form/element/component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

module SolidusAdmin
module Form
module Element
# Builds an arbitrary component in a form context.
#
# This class can be used to render an arbitrary components in a form.
#
# This is useful when there's the need to render a component that's not
# strictly related to a form definition, but still needs to be within the
# form tags.
class Component
# @param component [ViewComponent::Base] the component instance to
# render.
def initialize(component:)
@component = component
end

# @api private
def call(_form, _builder)
@component
end
end
end
end
end
45 changes: 45 additions & 0 deletions admin/lib/solidus_admin/form/element/field.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

module SolidusAdmin
module Form
module Element
# Builds a form field component.
#
# This class encapsulates a form field definition and its resolution to a
# component.
class Field
# @param component [Symbol, ViewComponent::Base] the component to be
# used when rendering. It can be a component class (which needs to
# accept the `builder:` parameter on initialization) or a Symbol. When
# the latter, it's used to infer the one configured in the form
# instance. For instance, for a `:text_field` type, the component used
# will be the one given to the form component as the
# `text_field_component` keyword argument on initialization.
# @param attributes [Hash] attributes to pass to the field component.
def initialize(component:, **attributes)
@component = component
@attributes = attributes
end

# @api private
def call(form, builder)
component_class(form).new(
builder: builder,
**@attributes
)
end

private

def component_class(form)
case @component
when Symbol
form.dependencies[@component]
else
@component
end
end
end
end
end
end
47 changes: 47 additions & 0 deletions admin/lib/solidus_admin/form/element/fieldset.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

module SolidusAdmin
module Form
module Element
# Builds a form fieldset component.
#
# This class encapsulates a form fieldset definition and its resolution to
# a component.
class Fieldset
# @param elements [Array<#call(form, builder)>] See
# {SolidusAdmin::UI::Forms::Form::Component#initialize}.
# @param component [ViewComponent::Base, nil] the component to be
# used when rendering. When `nil`, the component configured in the form
# `fieldset_component` keyword argument on initialization is used.
# @param attributes [Hash] Attributes to pass to the fieldset
# component.
def initialize(elements:, component: nil, **attributes)
@elements = elements
@component = component
@attributes = attributes
end

# @api private
def call(form, builder)
component_class(form).new(
**@attributes
).with_content(
render_elements(form, builder)
)
end

private

def component_class(form)
@component || form.dependencies[:fieldset]
end

def render_elements(form, builder)
return "" if @elements.empty?

form.render_elements(@elements, builder)
end
end
end
end
end
32 changes: 32 additions & 0 deletions admin/lib/solidus_admin/form/element/html.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

module SolidusAdmin
module Form
module Element
# Builds arbitrary HTML in a form.
#
# This class can be used to render arbitrary content in a form.
#
# This is useful when there's the need to render content that's not
# strictly related to a form definition, but still needs to be within the
# form tags. If the content is a component, it's better to use
# {SolidusAdmin::Form::Element::Component} instead.
class HTML
# @param html [String] the HTML to render.
def initialize(html:)
@html = html
end

# @api private
def call(_form, _builder)
self
end

# @api private
def render_in(_view_context)
@html
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ class SolidusAdmin::UI::Forms::Fieldset::ComponentPreview < ViewComponent::Previ

# The fieldset component is used to render a set of fields in a form.
#
# In its most basic form, it wraps the yielded content in a fieldset tag:
# Most commonly, it'll be used indirectly through the definition given to a
# [form component](../form/overview).
#
# For standalone usage, it wraps the yielded content in a fieldset tag:
#
# ```erb
# <%= render components('ui/forms/fieldset').new do %>
Expand Down

0 comments on commit 182f93f

Please sign in to comment.