From fde9b78c4d5e41b8f06eb4d3c431da1650b1400f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Busqu=C3=A9?= Date: Fri, 18 Aug 2023 12:24:46 +0200 Subject: [PATCH] Add text_area component for the admin As the logic and styling for the associated label, hint and error messages are the same as for the text_field component, we extract it into a couple of new components: one for the label and one for the guidance (hint and error messages). These new components are marked as private, as they are not meant to be used directly (at least for now). The original text_field component and the new text_area compose the new components but keep the responsibility of how to render the form inputs and which classes or attributes to use. We consider this approach more flexible than, for instance, using inheritance. Although it requires more code than just sharing a bunch of common styles, we keep them decoupled so they can change independently. We stop short of creating another form element component wrapping new extracted pure input and textarea components. We can do that in the future if we need to. Ref. #5329 --- .../ui/forms/guidance/component.rb | 65 +++++++++++ .../solidus_admin/ui/forms/label/component.rb | 13 +++ .../ui/forms/text_area/component.rb | 109 ++++++++++++++++++ .../ui/forms/text_field/component.rb | 105 +++++++---------- .../ui/forms/text_area/component_preview.rb | 69 +++++++++++ .../component_preview/overview.html.erb | 34 ++++++ .../component_preview/playground.html.erb | 14 +++ .../ui/forms/guidance/component_spec.rb | 37 ++++++ .../ui/forms/text_area/component_spec.rb | 13 +++ .../ui/forms/text_field/component_spec.rb | 32 ----- 10 files changed, 396 insertions(+), 95 deletions(-) create mode 100644 admin/app/components/solidus_admin/ui/forms/guidance/component.rb create mode 100644 admin/app/components/solidus_admin/ui/forms/label/component.rb create mode 100644 admin/app/components/solidus_admin/ui/forms/text_area/component.rb create mode 100644 admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview.rb create mode 100644 admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/overview.html.erb create mode 100644 admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/playground.html.erb create mode 100644 admin/spec/components/solidus_admin/ui/forms/guidance/component_spec.rb create mode 100644 admin/spec/components/solidus_admin/ui/forms/text_area/component_spec.rb diff --git a/admin/app/components/solidus_admin/ui/forms/guidance/component.rb b/admin/app/components/solidus_admin/ui/forms/guidance/component.rb new file mode 100644 index 00000000000..d47b6c2d3cb --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/guidance/component.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# @api private +class SolidusAdmin::UI::Forms::Guidance::Component < SolidusAdmin::BaseComponent + def initialize(field:, form:, hint:, errors:) + @field = field + @form = form + @hint = hint + @errors = errors || @form.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 + ) + end + + def call + return "" unless needed? + + tag.div(class: "mt-2") do + hint_tag + error_tag + end + end + + def hint_tag + return "".html_safe unless @hint + + tag.p(id: hint_id, class: "body-tiny text-gray-500 peer-disabled:text-gray-300") do + @hint + end + end + + def hint_id + "#{id_prefix}_hint" + end + + def error_tag + return "".html_safe unless errors? + + tag.p(id: error_id, class: "body-tiny text-red-400") do + @errors[@field].map do |error| + tag.span(class: "block") { error.capitalize } + end.reduce(&:+) + end + end + + def errors? + @errors[@field].present? + end + + def error_id + "#{id_prefix}_error" + end + + def id_prefix + "#{@form.object_name}_#{@field}" + end + + def aria_describedby + "#{hint_id if @hint} #{error_id if errors?}" + end + + def needed? + @hint || errors? + end +end diff --git a/admin/app/components/solidus_admin/ui/forms/label/component.rb b/admin/app/components/solidus_admin/ui/forms/label/component.rb new file mode 100644 index 00000000000..cab416168b0 --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/label/component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# @api private +class SolidusAdmin::UI::Forms::Label::Component < SolidusAdmin::BaseComponent + def initialize(field:, form:) + @field = field + @form = form + end + + def call + @form.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 new file mode 100644 index 00000000000..a79b441be2c --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/text_area/component.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +class SolidusAdmin::UI::Forms::TextArea::Component < SolidusAdmin::BaseComponent + SIZES = { + s: %w[h-20 body-small], + m: %w[h-28 body-small], + l: %w[h-36 body-text] + }.freeze + + # @param field [Symbol] the name of the field. Usually a model attribute. + # @param form [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 + # 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:, + size: :m, + hint: nil, + errors: nil, + label_component: component("ui/forms/label"), + guidance_component: component("ui/forms/guidance"), + **attributes + ) + @field = field + @form = form + @size = size + @hint = hint + @attributes = attributes + @errors = errors + @label_component = label_component + @guidance_component = guidance_component + end + + def call + guidance = @guidance_component.new( + field: @field, + form: @form, + hint: @hint, + errors: @errors + ) + + tag.div(class: "mb-6") do + label_tag + field_tag(guidance) + guidance_tag(guidance) + end + end + + def field_tag(guidance) + @form.text_area( + @field, + class: field_classes(guidance), + **field_aria_describedby_attribute(guidance), + **field_error_attributes(guidance), + **@attributes.except(:class) + ) + end + + def field_classes(guidance) + %w[ + peer + block px-3 py-4 w-full + text-black + bg-white border border-gray-300 rounded-sm + hover:border-gray-500 + placeholder:text-gray-400 + focus:border-gray-500 focus:shadow-[0_0_0_2px_#bbb] focus-visible:outline-none + disabled:bg-gray-50 disabled:text-gray-300 + ] + field_size_classes + field_error_classes(guidance) + Array(@attributes[:class]).compact + end + + def field_size_classes + SIZES.fetch(@size) + end + + def field_aria_describedby_attribute(guidance) + return {} unless guidance.needed? + + { + "aria-describedby": guidance.aria_describedby + } + end + + def field_error_classes(guidance) + return [] unless guidance.errors? + + %w[border-red-400 text-red-400] + end + + def field_error_attributes(guidance) + return {} unless guidance.errors? + + { + "aria-invalid": true + } + end + + def label_tag + render @label_component.new(field: @field, form: @form) + end + + def guidance_tag(guidance) + render guidance + end +end 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 1c9558018fa..164bd7f8063 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 @@ -35,7 +35,17 @@ class SolidusAdmin::UI::Forms::TextField::Component < SolidusAdmin::BaseComponen # @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:, type: :text, size: :m, hint: nil, errors: nil, **attributes) + def initialize( + field:, + form:, + type: :text, + size: :m, + hint: nil, + errors: nil, + label_component: component("ui/forms/label"), + guidance_component: component("ui/forms/guidance"), + **attributes + ) @field = field @form = form @type = type @@ -43,41 +53,36 @@ def initialize(field:, form:, type: :text, size: :m, hint: nil, errors: nil, **a @hint = hint @type = type @attributes = attributes - @errors = errors || @form.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 - ) + @errors = errors + @label_component = label_component + @guidance_component = guidance_component end def call - tag.div(class: "mb-6") do - label_tag + field_tag + info_wrapper - end - end + guidance = @guidance_component.new( + field: @field, + form: @form, + hint: @hint, + errors: @errors + ) - def info_wrapper - tag.div(class: "mt-2") do - hint_tag + error_tag + tag.div(class: "mb-6") do + label_tag + field_tag(guidance) + guidance_tag(guidance) end end - def label_tag - @form.label(@field, class: "block mb-0.5 body-tiny-bold") - end - - def field_tag + def field_tag(guidance) @form.send( field_helper, @field, - class: field_classes, - **field_aria_describedby_attribute, - **field_error_attributes, + class: field_classes(guidance), + **field_aria_describedby_attribute(guidance), + **field_error_attributes(guidance), **@attributes.except(:class) ) end - def field_classes + def field_classes(guidance) %w[ peer block px-3 py-1.5 w-full @@ -87,7 +92,7 @@ def field_classes placeholder:text-gray-400 focus:border-gray-500 focus:shadow-[0_0_0_2px_#bbb] focus-visible:outline-none disabled:bg-gray-50 disabled:text-gray-300 - ] + field_size_classes + field_error_classes + Array(@attributes[:class]).compact + ] + field_size_classes + field_error_classes(guidance) + Array(@attributes[:class]).compact end def field_helper @@ -98,59 +103,33 @@ def field_size_classes SIZES.fetch(@size) end - def field_error_classes - return [] unless errors? + def field_aria_describedby_attribute(guidance) + return {} unless guidance.needed? - %w[border-red-400 text-red-400] + { + "aria-describedby": guidance.aria_describedby + } end - def field_aria_describedby_attribute - return {} unless @hint || errors? + def field_error_classes(guidance) + return [] unless guidance.errors? - { - "aria-describedby": "#{hint_id if @hint} #{error_id if errors?}" - } + %w[border-red-400 text-red-400] end - def field_error_attributes - return {} unless errors? + def field_error_attributes(guidance) + return {} unless guidance.errors? { "aria-invalid": true } end - def hint_tag - return "".html_safe unless @hint - - tag.p(id: hint_id, class: "body-tiny text-gray-500 peer-disabled:text-gray-300") do - @hint - end - end - - def hint_id - "#{id_prefix}_hint" - end - - def error_tag - return "".html_safe unless errors? - - tag.p(id: error_id, class: "body-tiny text-red-400") do - @errors[@field].map do |error| - tag.span(class: "block") { error.capitalize } - end.reduce(&:+) - end - end - - def errors? - @errors[@field].present? - end - - def error_id - "#{id_prefix}_error" + def label_tag + render @label_component.new(field: @field, form: @form) end - def id_prefix - "#{@form.object_name}_#{@field}" + def guidance_tag(guidance) + render guidance end end diff --git a/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview.rb b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview.rb new file mode 100644 index 00000000000..cb7ce0cc416 --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# @component "ui/forms/text_area" +class SolidusAdmin::UI::Forms::TextArea::ComponentPreview < ViewComponent::Preview + include SolidusAdmin::Preview + + # The text area component is used to render a textarea in a form. + # + # See the [`ui/forms/text_field`](../text_field) component for usage + # instructions. + def overview + dummy_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu aliquam ultricies, urna elit aliquam urna, eu aliquam urna elit euismod urna." + render_with_template( + locals: { + sizes: current_component::SIZES.keys, + variants: { + "empty" => { + value: nil, disabled: false, hint: nil, errors: {} + }, + "filled" => { + value: dummy_text, disabled: false, hint: nil, errors: {} + }, + "with_hint" => { + value: dummy_text, disabled: false, hint: "Max. 400 characters", errors: {} + }, + "empty_with_error" => { + value: nil, disabled: false, hint: nil, errors: { "empty_with_error" => ["can't be blank"] } + }, + "filled_with_error" => { + value: dummy_text, disabled: false, hint: nil, errors: { "filled_with_error" => ["is invalid"] } + }, + "with_hint_and_error" => { + value: dummy_text, disabled: false, hint: "Max. 400 characters", errors: { "with_hint_and_error" => ["is invalid"] } + }, + "empty_disabled" => { + value: nil, disabled: true, hint: nil, errors: {} + }, + "filled_disabled" => { + value: dummy_text, disabled: true, hint: nil, errors: {} + }, + "with_hint_disabled" => { + value: dummy_text, disabled: true, hint: "Max. 400 characters", errors: {} + } + } + } + ) + end + + # @param size select { choices: [s, m, l] } + # @param label text + # @param value text + # @param hint text + # @param errors text "Separate multiple errors with a comma" + # @param placeholder text + # @param disabled toggle + def playground(size: :m, label: "Description", value: nil, hint: nil, errors: "", placeholder: "Placeholder", disabled: false) + render_with_template( + locals: { + size: size.to_sym, + field: label, + value: value, + hint: hint, + errors: { label.dasherize => (errors.blank? ? [] : errors.split(",").map(&:strip)) }, + placeholder: placeholder, + disabled: disabled + } + ) + end +end 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 new file mode 100644 index 00000000000..56bd273fe2a --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/overview.html.erb @@ -0,0 +1,34 @@ +<%= form_with(url: "#", scope: :overview, method: :get, class: "w-full") do |form| %> + + + + <% sizes.each do |size| %> + + <% end %> + + + + <% + variants.each_pair do |name, definition| %> + + <% sizes.each do |size| %> + + <% end %> + + <% end %> + +
<%= size.to_s.humanize %>
+ <%= + render current_component.new( + form: form, + field: name, + size: size, + errors: definition[:errors], + hint: definition[:hint], + disabled: definition[:disabled], + placeholder: "Placeholder", + value: definition[:value] + ) + %> +
+<% end %> 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 new file mode 100644 index 00000000000..f2e792fdea0 --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_area/component_preview/playground.html.erb @@ -0,0 +1,14 @@ +<%= form_with(url: "#", scope: :playground, method: :get, class: "w-60") do |form| %> + <%= + render current_component.new( + form: form, + size: size, + field: field, + value: value, + hint: hint, + errors: errors, + placeholder: placeholder, + disabled: disabled + ) + %> +<% 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 new file mode 100644 index 00000000000..daf45f92d51 --- /dev/null +++ b/admin/spec/components/solidus_admin/ui/forms/guidance/component_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::UI::Forms::Guidance::Component, type: :component do + describe "#initialize" do + 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"] }) + + expect(component.errors?).to be(true) + end + + 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) + + expect(component.errors?).to be(true) + end + + 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"] }) + + expect(component.errors?).to be(true) + end + + 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) + end + end +end diff --git a/admin/spec/components/solidus_admin/ui/forms/text_area/component_spec.rb b/admin/spec/components/solidus_admin/ui/forms/text_area/component_spec.rb new file mode 100644 index 00000000000..af5e6f1bf09 --- /dev/null +++ b/admin/spec/components/solidus_admin/ui/forms/text_area/component_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::UI::Forms::TextArea::Component, type: :component do + it "renders the overview preview" do + render_preview(:overview) + end + + it "renders the playground preview" do + render_preview(:playground) + end +end diff --git a/admin/spec/components/solidus_admin/ui/forms/text_field/component_spec.rb b/admin/spec/components/solidus_admin/ui/forms/text_field/component_spec.rb index 67cb3d25d6d..2d89dabfe54 100644 --- a/admin/spec/components/solidus_admin/ui/forms/text_field/component_spec.rb +++ b/admin/spec/components/solidus_admin/ui/forms/text_field/component_spec.rb @@ -10,36 +10,4 @@ it "renders the playground preview" do render_preview(:playground) end - - describe "#initialize" do - 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, errors: { name: ["can't be blank"] }) - - expect(component.errors?).to be(true) - end - - 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) - - expect(component.errors?).to be(true) - end - - 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, errors: { name: ["can't be blank"] }) - - expect(component.errors?).to be(true) - end - - 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) }.to raise_error(ArgumentError) - end - end end