From 7ef640ddff97cd2cf65bad2526cfbfd52cc15af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Busqu=C3=A9?= Date: Thu, 17 Aug 2023 11:23:29 +0200 Subject: [PATCH] Add text_field component to solidus_admin This component is used to render a text field in a form. It leverages the `type` attribute to also render different input fields, although we might want to specialize in the future. It comes in three sizes: small, medium and large. It also supports rendering a label and a hint, as well as field error messages. It needs to be rendered in the context of a block yielded by one of Rails' form helpers, such as `form_for` or `form_with`. This is to leverage the automatic inferrence of the `name`, `id` and `for` attributes, therefore avoiding boilerplate. When the given form builder is bound to a model instance, the error messages will be automatically extracted. Otherwise, an explicit errors hash needs to be passed. Ref. #5329 --- .../ui/forms/text_field/component.rb | 156 ++++++++++++++++++ .../ui/forms/text_field/component_preview.rb | 101 ++++++++++++ .../component_preview/overview.html.erb | 34 ++++ .../component_preview/playground.html.erb | 15 ++ .../ui/forms/text_field/component_spec.rb | 42 +++++ 5 files changed, 348 insertions(+) create mode 100644 admin/app/components/solidus_admin/ui/forms/text_field/component.rb create mode 100644 admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview.rb create mode 100644 admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/overview.html.erb create mode 100644 admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/playground.html.erb create mode 100644 admin/spec/components/solidus_admin/ui/forms/text_field/component_spec.rb 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 new file mode 100644 index 00000000000..7bde750fcdc --- /dev/null +++ b/admin/app/components/solidus_admin/ui/forms/text_field/component.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +class SolidusAdmin::UI::Forms::TextField::Component < SolidusAdmin::BaseComponent + SIZES = { + s: %w[leading-4 body-small], + m: %w[leading-6 body-small], + l: %w[leading-9 body-text] + }.freeze + + TYPES = { + color: :color_field, + date: :date_field, + datetime: :datetime_field, + email: :email_field, + month: :month_field, + number: :number_field, + password: :password_field, + phone: :phone_field, + range: :range_field, + search: :search_field, + text: :text_field, + time: :time_field, + url: :url_field, + week: :week_field + }.freeze + + # @param field [Symbol] the name of the field. Usually a model attribute. + # @param form [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 + # 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:, type: :text, size: :m, hint: nil, errors: nil, **attributes) + @field = field + @form = form + @type = type + @size = size + @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 + ) + end + + def call + tag.div(class: "mb-6") do + label_tag + field_tag + info_wrapper + end + end + + def info_wrapper + tag.div(class: "mt-2") do + hint_tag + error_tag + end + end + + def label_tag + @form.label(@field, class: "block mb-0.5 body-tiny-bold") + end + + def field_tag + @form.send( + field_helper, + @field, + class: field_classes, + **field_aria_describedby_attribute, + **field_error_attributes, + **@attributes.except(:class) + ) + end + + def field_classes + %w[ + peer + block px-3 py-1.5 w-full + text-black 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 + Array(@attributes[:class]).compact + end + + def field_helper + TYPES.fetch(@type) + end + + def field_size_classes + SIZES.fetch(@size) + end + + def field_error_classes + return [] unless errors? + + %w[border-red-400 text-red-400] + end + + def field_aria_describedby_attribute + return {} unless @hint || errors? + + { + "aria-describedby": "#{hint_id if @hint} #{error_id if errors?}" + } + end + + def field_error_attributes + return {} unless 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" + end + + def id_prefix + "#{@form.object_name}_#{@field}" + end +end 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 new file mode 100644 index 00000000000..ff8b6f3f30b --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# @component "ui/forms/text_field" +class SolidusAdmin::UI::Forms::TextField::ComponentPreview < ViewComponent::Preview + include SolidusAdmin::Preview + + # 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` + # ](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) + # helpers. + # + # When the form builder is not bound to a model instance, you must pass an + # errors Hash to the component. For example: + # + # ```erb + # <%= form_with(url: search_path, method: :get) do |form| %> + # <%= render components('ui/forms/text_field').new( + # form: form, + # field: :q, + # errors: params[:q].present? ? {} : { + # q: ["can't be blank"] + # } + # ) %> + # <%= form.submit "Search" %> + # <% end %> + # ``` + # + # When the form builder is bound to a model instance, the component will + # automatically fetch the errors from the model. + # + # ```erb + # <%= form_with(model: @user) do |form| %> + # <%= render components('ui/forms/text_field').new( + # form: form, + # field: :name + # ) %> + # <%= form.submit "Save" %> + # <% end %> + def overview + render_with_template( + locals: { + sizes: current_component::SIZES.keys, + variants: { + "empty" => { + value: nil, disabled: false, hint: nil, errors: {} + }, + "filled" => { + value: "Alice", disabled: false, hint: nil, errors: {} + }, + "with_hint" => { + value: "Alice", disabled: false, hint: "No special characters", errors: {} + }, + "empty_with_error" => { + value: nil, disabled: false, hint: nil, errors: { "empty_with_error" => ["can't be blank"] } + }, + "filled_with_error" => { + value: "Alice", disabled: false, hint: nil, errors: { "filled_with_error" => ["is invalid"] } + }, + "with_hint_and_error" => { + value: "Alice", disabled: false, hint: "No special characters", errors: { "with_hint_and_error" => ["is invalid"] } + }, + "empty_disabled" => { + value: nil, disabled: true, hint: nil, errors: {} + }, + "filled_disabled" => { + value: "Alice", disabled: true, hint: nil, errors: {} + }, + "with_hint_disabled" => { + value: "Alice", disabled: true, hint: "No special characters", errors: {} + } + } + } + ) + end + + # @param size select { choices: [s, m, l] } + # @param type select { choices: [color, date, datetime, email, month, number, password, phone, range, search, text, time, url, week] } + # @param label text + # @param value text + # @param hint text + # @param errors text (comma separated) + # @param placeholder text + # @param disabled toggle + def playground(size: :m, type: :text, label: "Name", value: nil, hint: nil, errors: "", placeholder: "Placeholder", disabled: false) + render_with_template( + locals: { + size: size.to_sym, + type: type.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_field/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/overview.html.erb new file mode 100644 index 00000000000..35f0ca4e694 --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/overview.html.erb @@ -0,0 +1,34 @@ +<%= form_with(url: "#", scope: :overview, method: :get) 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_field/component_preview/playground.html.erb b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/playground.html.erb new file mode 100644 index 00000000000..83f1ed221e3 --- /dev/null +++ b/admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview/playground.html.erb @@ -0,0 +1,15 @@ +<%= form_with(url: "#", scope: :playground, method: :get, class: "w-56") do |form| %> + <%= + render current_component.new( + form: form, + size: size, + type: type, + field: field, + value: value, + hint: hint, + errors: errors, + placeholder: placeholder, + disabled: disabled + ) + %> +<% 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 new file mode 100644 index 00000000000..d428407c839 --- /dev/null +++ b/admin/spec/components/solidus_admin/ui/forms/text_field/component_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::UI::Forms::TextField::Component, type: :component do + it "renders the overview preview" do + render_preview(:overview) + 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