From 55f3f53854deb53c116f2aba5c00035ecdb26925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Busqu=C3=A9?= Date: Tue, 29 Aug 2023 11:22:47 +0200 Subject: [PATCH] Add form component to the admin 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 --- .../solidus_admin/ui/forms/form/component.erb | 3 + .../solidus_admin/ui/forms/form/component.rb | 53 ++++++ .../ui/forms/guidance/component.rb | 8 +- .../solidus_admin/ui/forms/label/component.rb | 6 +- .../ui/forms/text_area/component.rb | 14 +- .../ui/forms/text_field/component.rb | 14 +- admin/lib/solidus_admin/configuration.rb | 2 +- .../solidus_admin/form/element/component.rb | 27 +++ admin/lib/solidus_admin/form/element/field.rb | 45 +++++ .../solidus_admin/form/element/fieldset.rb | 47 +++++ admin/lib/solidus_admin/form/element/html.rb | 32 ++++ .../ui/forms/fieldset/component_preview.rb | 5 +- .../component_preview/overview.html.erb | 8 +- .../ui/forms/form/component_preview.rb | 170 ++++++++++++++++++ .../form/component_preview/overview.html.erb | 5 + .../component_preview/overview.html.erb | 2 +- .../component_preview/playground.html.erb | 2 +- .../ui/forms/text_field/component_preview.rb | 10 +- .../component_preview/overview.html.erb | 2 +- .../component_preview/playground.html.erb | 2 +- .../ui/forms/form/component_spec.rb | 9 + .../ui/forms/guidance/component_spec.rb | 8 +- .../form/element/component_spec.rb | 16 ++ .../solidus_admin/form/element/field_spec.rb | 71 ++++++++ .../form/element/fieldset_spec.rb | 65 +++++++ .../solidus_admin/form/element/html_spec.rb | 26 +++ .../solidus_admin/component_helpers.rb | 2 +- 27 files changed, 615 insertions(+), 39 deletions(-) create mode 100644 admin/app/components/solidus_admin/ui/forms/form/component.erb create mode 100644 admin/app/components/solidus_admin/ui/forms/form/component.rb create mode 100644 admin/lib/solidus_admin/form/element/component.rb create mode 100644 admin/lib/solidus_admin/form/element/field.rb create mode 100644 admin/lib/solidus_admin/form/element/fieldset.rb create mode 100644 admin/lib/solidus_admin/form/element/html.rb create mode 100644 admin/spec/components/previews/solidus_admin/ui/forms/form/component_preview.rb create mode 100644 admin/spec/components/previews/solidus_admin/ui/forms/form/component_preview/overview.html.erb create mode 100644 admin/spec/components/solidus_admin/ui/forms/form/component_spec.rb create mode 100644 admin/spec/solidus_admin/form/element/component_spec.rb create mode 100644 admin/spec/solidus_admin/form/element/field_spec.rb create mode 100644 admin/spec/solidus_admin/form/element/fieldset_spec.rb create mode 100644 admin/spec/solidus_admin/form/element/html_spec.rb diff --git a/admin/app/components/solidus_admin/ui/forms/form/component.erb b/admin/app/components/solidus_admin/ui/forms/form/component.erb new file mode 100644 index 00000000000..7ea4458e700 --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/form/component.erb @@ -0,0 +1,3 @@ +<%= form_with(**@attributes) do |builder| %> + <%= render_elements(@elements, builder) %> +<% end %> diff --git a/admin/app/components/solidus_admin/ui/forms/form/component.rb b/admin/app/components/solidus_admin/ui/forms/form/component.rb new file mode 100644 index 00000000000..121b1397f78 --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/form/component.rb @@ -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 diff --git a/admin/app/components/solidus_admin/ui/forms/guidance/component.rb b/admin/app/components/solidus_admin/ui/forms/guidance/component.rb index 651fea70bb3..67d88cb189a 100644 --- a/admin/app/components/solidus_admin/ui/forms/guidance/component.rb +++ b/admin/app/components/solidus_admin/ui/forms/guidance/component.rb @@ -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 @@ -57,7 +57,7 @@ def error_id end def prefix - "#{@form.object_name}_#{@field}" + "#{@builder.object_name}_#{@field}" end def aria_describedby diff --git a/admin/app/components/solidus_admin/ui/forms/label/component.rb b/admin/app/components/solidus_admin/ui/forms/label/component.rb index cab416168b0..072042cc811 100644 --- a/admin/app/components/solidus_admin/ui/forms/label/component.rb +++ b/admin/app/components/solidus_admin/ui/forms/label/component.rb @@ -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 diff --git a/admin/app/components/solidus_admin/ui/forms/text_area/component.rb b/admin/app/components/solidus_admin/ui/forms/text_area/component.rb index 28365d06b33..d381f037b35 100644 --- a/admin/app/components/solidus_admin/ui/forms/text_area/component.rb +++ b/admin/app/components/solidus_admin/ui/forms/text_area/component.rb @@ -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, @@ -28,7 +28,7 @@ def initialize( **attributes ) @field = field - @form = form + @builder = builder @size = size @hint = hint @attributes = HashWithIndifferentAccess.new(attributes) @@ -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] @@ -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), @@ -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) diff --git a/admin/app/components/solidus_admin/ui/forms/text_field/component.rb b/admin/app/components/solidus_admin/ui/forms/text_field/component.rb index 7288b546af5..80574668c53 100644 --- a/admin/app/components/solidus_admin/ui/forms/text_field/component.rb +++ b/admin/app/components/solidus_admin/ui/forms/text_field/component.rb @@ -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, @@ -47,7 +47,7 @@ def initialize( **attributes ) @field = field - @form = form + @builder = builder @type = type @size = size @hint = hint @@ -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] @@ -73,7 +73,7 @@ def call end def field_tag(guidance) - @form.send( + @builder.send( field_helper, @field, class: field_classes(guidance), @@ -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) diff --git a/admin/lib/solidus_admin/configuration.rb b/admin/lib/solidus_admin/configuration.rb index 8a25052951f..cc8fadab439 100644 --- a/admin/lib/solidus_admin/configuration.rb +++ b/admin/lib/solidus_admin/configuration.rb @@ -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"), diff --git a/admin/lib/solidus_admin/form/element/component.rb b/admin/lib/solidus_admin/form/element/component.rb new file mode 100644 index 00000000000..c46751808ab --- /dev/null +++ b/admin/lib/solidus_admin/form/element/component.rb @@ -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 diff --git a/admin/lib/solidus_admin/form/element/field.rb b/admin/lib/solidus_admin/form/element/field.rb new file mode 100644 index 00000000000..9b54f22679e --- /dev/null +++ b/admin/lib/solidus_admin/form/element/field.rb @@ -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 diff --git a/admin/lib/solidus_admin/form/element/fieldset.rb b/admin/lib/solidus_admin/form/element/fieldset.rb new file mode 100644 index 00000000000..08f01e4674d --- /dev/null +++ b/admin/lib/solidus_admin/form/element/fieldset.rb @@ -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 diff --git a/admin/lib/solidus_admin/form/element/html.rb b/admin/lib/solidus_admin/form/element/html.rb new file mode 100644 index 00000000000..e9c681507b2 --- /dev/null +++ b/admin/lib/solidus_admin/form/element/html.rb @@ -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 diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/fieldset/component_preview.rb b/admin/spec/components/previews/solidus_admin/ui/forms/fieldset/component_preview.rb index 3298c4e0024..798e05de3a6 100644 --- a/admin/spec/components/previews/solidus_admin/ui/forms/fieldset/component_preview.rb +++ b/admin/spec/components/previews/solidus_admin/ui/forms/fieldset/component_preview.rb @@ -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 %> diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/fieldset/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/fieldset/component_preview/overview.html.erb index 836381be8a6..187c07c52f2 100644 --- a/admin/spec/components/previews/solidus_admin/ui/forms/fieldset/component_preview/overview.html.erb +++ b/admin/spec/components/previews/solidus_admin/ui/forms/fieldset/component_preview/overview.html.erb @@ -5,7 +5,7 @@ <%= render component('ui/forms/text_field').new( field: :name, - form: form, + builder: form, errors: {} ) %> @@ -16,7 +16,7 @@ <%= render component('ui/forms/text_field').new( field: :name, - form: form, + builder: form, errors: {} ) %> @@ -27,7 +27,7 @@ <%= render component('ui/forms/text_field').new( field: :name, - form: form, + builder: form, errors: {} ) %> @@ -38,7 +38,7 @@ <%= render component('ui/forms/text_field').new( field: :name, - form: form, + builder: form, errors: {} ) %> diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/form/component_preview.rb b/admin/spec/components/previews/solidus_admin/ui/forms/form/component_preview.rb new file mode 100644 index 00000000000..5674ee89ba4 --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/form/component_preview.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "solidus_admin/form/element/field" +require "solidus_admin/form/element/fieldset" +require "solidus_admin/form/element/component" +require "solidus_admin/form/element/html" + +# @component "ui/forms/form" +class SolidusAdmin::UI::Forms::Form::ComponentPreview < ViewComponent::Preview + include SolidusAdmin::Preview + + # The form component is used to render a form tag along with its content, most + # commonly form fields. + # + # Internally, the + # [`form_with`](https://edgeapi.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-form_with) + # Rails helper is used to render the form tag, and the component will dispatch + # given arguments to it. + # + # The definition of the form is provided from the outside through the + # `elements` parameter. This parameter is an array of builders of renderable + # elements, and Solidus Admin provides all the necessary ones to build a form + # following its UI: + # + # ## SolidusAdmin::Form::Element::Field + # + # This element renders a form field: + # + # ```erb + # <%= + # render components('ui/forms/form', + # model: Spree::Product.new, + # elements: [ + # SolidusAdmin::Form::Element::Field.new( + # component: :text_field, + # field: :name + # ) + # ] + # ) + # %> + # ``` + # + # The previous example will use the [`text_field` + # component](../text_field/overview), but you can use any of the available + # field component. + # + # ## SolidusAdmin::Form::Element::Fieldset + # + # Wraps a set of fields in a fieldset. + # + # You need to provide the inner fields akin to how it's done with the form + # component. [The fieldet component](../fieldset/overview) is used under the + # hood, and you can pass any of its attributes through the `attributes` + # parameter. + # + # ```erb + # <%= + # render components('ui/forms/form', + # model: Spree::Product.new, + # elements: [ + # SolidusAdmin::Form::Element::Fieldset.new( + # elements: [ + # SolidusAdmin::Form::Element::Field.new( + # component: :text_field, + # field: :name + # ) + # ], + # legend: "Product details", + # toggletip_attributes: { guide: "Minimal info", position: :right } + # ) + # ] + # ) + # %> + # ``` + # + # ## SolidusAdmin::Form::Element::Component + # + # This element allows you to render any component inside the form. + # + # ```erb + # <%= + # render components('ui/forms/form', + # model: Spree::Product.new, + # elements: [ + # SolidusAdmin::Form::Element::Component.new( + # component: MyCustomComponent.new + # ) + # ] + # ) + # %> + # ``` + # + # ## SolidusAdmin::Form::Element::HTML + # + # This element allows you to render any HTML inside the form. + # + # ```erb + # <%= + # render components('ui/forms/form', + # model: Spree::Product.new, + # elements: [ + # SolidusAdmin::Form::Element::HTML.new( + # html: "

Whatever HTML you want

".html_safe + # ) + # ] + # ) + # %> + # ``` + def overview + render_with_template( + locals: { + elements: elements + } + ) + end + + private + + def elements + [ + field_element, + fieldset_element, + component_element, + html_element + ] + end + + def field_element + SolidusAdmin::Form::Element::Field.new( + component: :text_field, + field: :name, + placeholder: "SolidusAdmin::Form::Element::Field", + errors: {} + ) + end + + def fieldset_element + SolidusAdmin::Form::Element::Fieldset.new( + elements: [ + SolidusAdmin::Form::Element::Field.new( + component: :text_field, + field: :name, + placeholder: "SolidusAdmin::Form::Element::Field", + errors: {} + ) + ], + legend: "SolidusAdmin::Form::Element::Fieldset" + ) + end + + def component_element + SolidusAdmin::Form::Element::Component.new( + component: Class.new(SolidusAdmin::BaseComponent) do + def self.name + "MyCustomComponent" + end + + def call + tag.p(class: "body-text-bold mb-2 italic") { "SolidusAdmin::Form::Element::Component" } + end + end.new + ) + end + + def html_element + SolidusAdmin::Form::Element::HTML.new( + html: "

SolidusAdmin::Form::Element::HTML

".html_safe + ) + end +end diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/form/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/form/component_preview/overview.html.erb new file mode 100644 index 00000000000..40dee9a8220 --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/form/component_preview/overview.html.erb @@ -0,0 +1,5 @@ +
+ <%= + render current_component.new(url: "#", scope: :overview, method: :get, class: "m-auto", elements: elements) + %> +
diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/overview.html.erb index 56bd273fe2a..8ded42897d1 100644 --- a/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/overview.html.erb +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/overview.html.erb @@ -15,7 +15,7 @@ <%= render current_component.new( - form: form, + builder: form, field: name, size: size, errors: definition[:errors], diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/playground.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/playground.html.erb index f2e792fdea0..c12a2684850 100644 --- a/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/playground.html.erb +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/playground.html.erb @@ -1,7 +1,7 @@ <%= form_with(url: "#", scope: :playground, method: :get, class: "w-60") do |form| %> <%= render current_component.new( - form: form, + builder: form, size: size, field: field, value: value, diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview.rb b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview.rb index 79a0d95e30a..72d46308dd8 100644 --- a/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview.rb +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview.rb @@ -6,7 +6,11 @@ class SolidusAdmin::UI::Forms::TextField::ComponentPreview < ViewComponent::Prev # The text field component is used to render a text field in a form. # - # It must be used within the block context yielded in the [`form_with` + # Most commonly, it'll be used indirectly through the definition given to a + # [form component](../form/overview). + # + # For standalone usage, it must be used within the block context yielded in + # the [`form_with` # ](https://api.rubyonrails.org/v5.1/classes/ActionView/Helpers/FormHelper.html#method-i-form_with) # or # [`form_for`](https://api.rubyonrails.org/v5.1/classes/ActionView/Helpers/FormHelper.html#method-i-form_for) @@ -18,7 +22,7 @@ class SolidusAdmin::UI::Forms::TextField::ComponentPreview < ViewComponent::Prev # ```erb # <%= form_with(url: search_path, method: :get) do |form| %> # <%= render components('ui/forms/text_field').new( - # form: form, + # builder: form, # field: :q, # errors: params[:q].present? ? {} : { # q: ["can't be blank"] @@ -34,7 +38,7 @@ class SolidusAdmin::UI::Forms::TextField::ComponentPreview < ViewComponent::Prev # ```erb # <%= form_with(model: @user) do |form| %> # <%= render components('ui/forms/text_field').new( - # form: form, + # builder: form, # field: :name # ) %> # <%= form.submit "Save" %> diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/overview.html.erb index 35f0ca4e694..200f131e93b 100644 --- a/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/overview.html.erb +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/overview.html.erb @@ -15,7 +15,7 @@ <%= render current_component.new( - form: form, + builder: form, field: name, size: size, errors: definition[:errors], diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/playground.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/playground.html.erb index 83f1ed221e3..76072bee203 100644 --- a/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/playground.html.erb +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/playground.html.erb @@ -1,7 +1,7 @@ <%= form_with(url: "#", scope: :playground, method: :get, class: "w-56") do |form| %> <%= render current_component.new( - form: form, + builder: form, size: size, type: type, field: field, diff --git a/admin/spec/components/solidus_admin/ui/forms/form/component_spec.rb b/admin/spec/components/solidus_admin/ui/forms/form/component_spec.rb new file mode 100644 index 00000000000..87d5382ba0c --- /dev/null +++ b/admin/spec/components/solidus_admin/ui/forms/form/component_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::UI::Forms::Form::Component, type: :component do + it "renders the overview preview" do + render_preview(:overview) + end +end diff --git a/admin/spec/components/solidus_admin/ui/forms/guidance/component_spec.rb b/admin/spec/components/solidus_admin/ui/forms/guidance/component_spec.rb index daf45f92d51..f5092e16c0d 100644 --- a/admin/spec/components/solidus_admin/ui/forms/guidance/component_spec.rb +++ b/admin/spec/components/solidus_admin/ui/forms/guidance/component_spec.rb @@ -7,7 +7,7 @@ it "uses given errors when form is bound to a model" do form = double("form", object: double("model", errors: {})) - component = described_class.new(form: form, field: :name, hint: nil, errors: { name: ["can't be blank"] }) + component = described_class.new(builder: form, field: :name, hint: nil, errors: { name: ["can't be blank"] }) expect(component.errors?).to be(true) end @@ -15,7 +15,7 @@ it "uses model errors when form is bound to a model and they are not given" do form = double("form", object: double("model", errors: { name: ["can't be blank"] })) - component = described_class.new(form: form, field: :name, hint: nil, errors: nil) + component = described_class.new(builder: form, field: :name, hint: nil, errors: nil) expect(component.errors?).to be(true) end @@ -23,7 +23,7 @@ it "uses given errors when form is not bound to a model" do form = double("form", object: nil) - component = described_class.new(form: form, field: :name, hint: nil, errors: { name: ["can't be blank"] }) + component = described_class.new(builder: form, field: :name, hint: nil, errors: { name: ["can't be blank"] }) expect(component.errors?).to be(true) end @@ -31,7 +31,7 @@ it "raises an error when form is not bound to a model and errors are not given" do form = double("form", object: nil) - expect { described_class.new(form: form, field: :name, errors: nil) }.to raise_error(ArgumentError) + expect { described_class.new(builder: form, field: :name, errors: nil) }.to raise_error(ArgumentError) end end end diff --git a/admin/spec/solidus_admin/form/element/component_spec.rb b/admin/spec/solidus_admin/form/element/component_spec.rb new file mode 100644 index 00000000000..eda06035b50 --- /dev/null +++ b/admin/spec/solidus_admin/form/element/component_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "spec_helper" +require "solidus_admin/form/element/component" + +RSpec.describe SolidusAdmin::Form::Element::Component do + describe "#call" do + it "returns the given instance component" do + element = described_class.new(component: :component) + + expect( + element.call(double("form"), double("builder")) + ).to be(:component) + end + end +end diff --git a/admin/spec/solidus_admin/form/element/field_spec.rb b/admin/spec/solidus_admin/form/element/field_spec.rb new file mode 100644 index 00000000000..855b2549b6f --- /dev/null +++ b/admin/spec/solidus_admin/form/element/field_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "spec_helper" +require "solidus_admin/form/element/field" + +RSpec.describe SolidusAdmin::Form::Element::Field do + include SolidusAdmin::ComponentHelpers + + describe "#call" do + it "returns an instance of the given component" do + component = mock_component do + def initialize(builder:); end + end + builder = double("builder") + + element = described_class.new(component: component) + + expect( + element.call(double("form"), builder) + ).to be_a(component) + end + + it "initializes the component with the given attributes" do + component = mock_component do + attr_reader :builder, :attributes + + def initialize(builder:, **attributes) + @builder = builder + @attributes = attributes + end + end + attributes = { foo: :bar } + element = described_class.new(component: component, **attributes) + + result = element.call(double("form"), double("builder")) + + expect(result.attributes).to eq(attributes) + end + + it "initializes the component with the given builder" do + component = mock_component do + attr_reader :builder + + def initialize(builder:) + @builder = builder + end + end + builder = double("builder") + element = described_class.new(component: component) + + result = element.call(double("form"), builder) + + expect(result.builder).to be(builder) + end + + it "infers the component class from the form dependencies when given as a Symbol" do + component = mock_component do + def initialize(builder:); end + end + element = described_class.new(component: :text_field) + form = SolidusAdmin::UI::Forms::Form::Component.new( + elements: [element], + text_field_component: component + ) + + result = element.call(form, double("builder")) + + expect(result).to be_a(component) + end + end +end diff --git a/admin/spec/solidus_admin/form/element/fieldset_spec.rb b/admin/spec/solidus_admin/form/element/fieldset_spec.rb new file mode 100644 index 00000000000..20864a08c20 --- /dev/null +++ b/admin/spec/solidus_admin/form/element/fieldset_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "spec_helper" +require "solidus_admin/form/element/fieldset" +require "solidus_admin/form/element/html" + +RSpec.describe SolidusAdmin::Form::Element::Fieldset do + include SolidusAdmin::ComponentHelpers + + describe "#call" do + it "returns an instance of the given component" do + component = mock_component + element = described_class.new(component: component, elements: []) + + expect( + element.call(double("form"), double("builder")) + ).to be_a(component) + end + + it "initializes the component with the given attributes" do + component = mock_component do + attr_reader :attributes + + def initialize(**attributes) + @attributes = attributes + end + end + attributes = { foo: :bar } + element = described_class.new(component: component, elements: [], **attributes) + + result = element.call(double("form"), double("builder")) + + expect(result.attributes).to eq(attributes) + end + + it "gives the concatenation of the rendered elements as the content of the component" do + component = mock_component + elements = [ + SolidusAdmin::Form::Element::HTML.new(html: "foo"), + SolidusAdmin::Form::Element::HTML.new(html: "bar") + ] + element = described_class.new(component: component, elements: elements) + form = SolidusAdmin::UI::Forms::Form::Component.new(elements: [element]) + + # Workaround for view_context not being available in specs + expect(form).to receive(:render_element).with(elements[0], any_args).and_return("foo") + expect(form).to receive(:render_element).with(elements[1], any_args).and_return("bar") + + result = element.call(form, double("builder")) + + expect(result.content).to eq("foobar") + end + + it "uses the fieldset component from the form dependencies when component is not given" do + component = mock_component + element = described_class.new(elements: []) + form = SolidusAdmin::UI::Forms::Form::Component.new( + elements: [element], + fieldset_component: component + ) + + expect(element.call(form, double("builder"))).to be_a(component) + end + end +end diff --git a/admin/spec/solidus_admin/form/element/html_spec.rb b/admin/spec/solidus_admin/form/element/html_spec.rb new file mode 100644 index 00000000000..e6c0a7de0e8 --- /dev/null +++ b/admin/spec/solidus_admin/form/element/html_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "spec_helper" +require "solidus_admin/form/element/html" + +RSpec.describe SolidusAdmin::Form::Element::HTML do + describe "#call" do + it "returns itself" do + element = described_class.new(html: "foo") + + expect( + element.call(double("form"), double("builder")) + ).to be(element) + end + end + + describe "#render_in" do + it "returns the given HTML" do + element = described_class.new(html: "foo") + + expect( + element.render_in(double("view_context")) + ).to eq("foo") + end + end +end diff --git a/admin/spec/support/solidus_admin/component_helpers.rb b/admin/spec/support/solidus_admin/component_helpers.rb index 3b999589da8..e68d7d37c9d 100644 --- a/admin/spec/support/solidus_admin/component_helpers.rb +++ b/admin/spec/support/solidus_admin/component_helpers.rb @@ -18,7 +18,7 @@ def mock_component(&definition) def self.name "Foo" end - end.tap { |klass| klass.class_eval(&definition) } + end.tap { |klass| klass.class_eval(&definition) if definition } end end end