-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
df03854
commit d580785
Showing
5 changed files
with
348 additions
and
0 deletions.
There are no files selected for viewing
155 changes: 155 additions & 0 deletions
155
admin/app/components/solidus_admin/ui/forms/text_field/component.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
# frozen_string_literal: true | ||
|
||
class SolidusAdmin::UI::Forms::TextField::Component < SolidusAdmin::BaseComponent | ||
SIZES = { | ||
s: 'leading-4 body-small', | ||
m: 'leading-6 body-small', | ||
l: '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 do | ||
label_tag + field_tag + 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: " | ||
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} | ||
#{@attributes[:class]} | ||
", | ||
**field_aria_describedby_attribute, | ||
**field_error_attributes, | ||
**@attributes.except(:class) | ||
) | ||
end | ||
|
||
def field_helper | ||
TYPES.fetch(@type) | ||
end | ||
|
||
def field_size_classes | ||
SIZES.fetch(@size) | ||
end | ||
|
||
def field_error_classes | ||
return "" unless errors? | ||
|
||
"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 unless @hint | ||
|
||
tag.p(id: hint_id, class: "mt-2 body-tiny text-gray-500 peer-disabled:text-gray-300 #{hint_error_classes}") do | ||
@hint | ||
end | ||
end | ||
|
||
def hint_error_classes | ||
return {} unless errors? | ||
|
||
"text-red-400" | ||
end | ||
|
||
def hint_id | ||
"#{id_prefix}_hint" | ||
end | ||
|
||
def error_tag | ||
return unless errors? | ||
|
||
tag.p(id: error_id, class: "mt-2 body-tiny text-red-400") do | ||
@errors[@field].map do |error| | ||
tag.span(class: "block") { error } | ||
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 |
101 changes: 101 additions & 0 deletions
101
admin/spec/components/previews/solidus_admin/ui/forms/text_field/component_preview.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: "Your name", 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: "Your name", 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: "Your name", 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 |
34 changes: 34 additions & 0 deletions
34
...components/previews/solidus_admin/ui/forms/text_field/component_preview/overview.html.erb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
<%= form_with(url: "#", 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 %> |
16 changes: 16 additions & 0 deletions
16
...mponents/previews/solidus_admin/ui/forms/text_field/component_preview/playground.html.erb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
<%= form_with(url: "#", method: :get) do |form| %> | ||
<%= | ||
render current_component.new( | ||
form: form, | ||
size: size, | ||
type: type, | ||
field: field, | ||
value: value, | ||
hint: hint, | ||
errors: errors, | ||
placeholder: placeholder, | ||
disabled: disabled, | ||
class: "w-56" | ||
) | ||
%> | ||
<% end %> |
42 changes: 42 additions & 0 deletions
42
admin/spec/components/solidus_admin/ui/forms/text_field/component_spec.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |