Skip to content

Commit

Permalink
Add text_field component to solidus_admin
Browse files Browse the repository at this point in the history
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. solidusio#5329
  • Loading branch information
waiting-for-dev committed Aug 18, 2023
1 parent df03854 commit 7ef640d
Show file tree
Hide file tree
Showing 5 changed files with 348 additions and 0 deletions.
156 changes: 156 additions & 0 deletions admin/app/components/solidus_admin/ui/forms/text_field/component.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<%= form_with(url: "#", scope: :overview, method: :get) do |form| %>
<table>
<thead>
<tr>
<% sizes.each do |size| %>
<td class="px-3 py-1 text-gray-500 text-center body-text"><%= size.to_s.humanize %></td>
<% end %>
</tr>
</thead>
<tbody>
<%
variants.each_pair do |name, definition| %>
<tr>
<% sizes.each do |size| %>
<td class="px-3 py-1">
<%=
render current_component.new(
form: form,
field: name,
size: size,
errors: definition[:errors],
hint: definition[:hint],
disabled: definition[:disabled],
placeholder: "Placeholder",
value: definition[:value]
)
%>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<% end %>
Original file line number Diff line number Diff line change
@@ -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 %>
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 7ef640d

Please sign in to comment.