Add form_builder replacement / smart input components
sfcgeorge opened this issue · 3 comments
A one size fits all form builder is hard. The Rails form_builder
isn't flexible enough to work with frameworks like Bootstrap, so then there was simple_form
but that's nowhere near simple. But we might be able to at least provide some smart form field components that are easily customisable.
Customisation could be via passing in custom INPUT and LABEL equivalent components, or by inheriting from the field component and changing the render method to suit your UI framework's HTML structure.
This is an example implementation simplified from work app. You simply pass in a model and specify the attribute. The input then can automatically:
- Assign useful unique ID to input
name
and labelfrom
(see ruby-hyperloop/hyper-mesh#62 for more) - Display current value of the attribute.
- Update internal value state of the input on keypress.
- Assign to record's attribute on blur (prevents special types like date from erring).
- Update internal value state if the record attribute changes (see
Watch
below). - Display's errors.
- With validation metadata it could also display requirements (see ruby-hyperloop/hyper-mesh#65)
The HTML structure isn't customisable though.
module Form
class Input < Hyperloop::Component
param :type, default: :text, type: Symbol
param :placeholder, default: "", type: String
param :className, default: "", type: String
param :id, default: nil, type: String, allow_nil: true
param :name, default: nil, type: String, allow_nil: true
param :label, default: nil, type: String, allow_nil: true
param :label_class, default: "", type: String
param :onChange, default: -> {}
param :style, default: {}, type: Hash
param :record
param :attribute
state value: ""
before_mount do
@key = 1
end
after_mount do
record.load(attribute).then do |value|
mutate.value value
end
end
render(DIV) do
Watch(attribute: record[attribute]).on(:trigger) do
mutate.value record[attribute]
@key += 1
end
LABEL(
class: "#{params.label_class} #{'error' if error?}",
for: attr_name
) do
label_with_error_message
end
INPUT(
type: type,
class: "#{params.className} #{'error' if error?}"
id: attr_id,
name: attr_name,
placeholder: params.placeholder,
defaultValue: state.value,
key: @key,
**(type == :checkbox ? { checked: !!state.value } : {})
).on(:change) do |e|
if type == :checkbox
mutate.value !state.value
else
mutate.value e.target.value
end
end.on(:blur) do |e|
begin
record[attribute] = state.value
rescue ArgumentError => e
# happens if you type invalid data for the field format AKA date.
record.errors.messages[attribute] ||= []
record.errors.messages[attribute].unshift e.message
force_update! # Needed
end
end
end
private
def record
params.record
end
def attribute
params.attribute.to_s
end
def relation
attribute.sub(/_id$/, "")
end
def attr_id
params.id ||
"#{record.class.name}#{record.backing_record.object_id}-#{attribute}"
end
def attr_name
params.name || params_id
end
def label_with_error_message
label = params.label || relation.capitalize.tr("_", " ")
SPAN do
SPAN { "#{label}" }
EM(class: "error") { error_message } if error?
end
end
def error?
record.errors[relation].try(:any?)
end
def error_message
error? ? record.errors[relation].first.to_s : ""
end
end
end
You also need this "Watch" fake component. It lets you trigger a callback when a record attribute changes. In the Input component it is used to update the internal value state if the record attribute changes externally, e.g. by server push. So:
- The input receives and assigns the initial value of the record attribute param to internal state.
- As you type it's the internal state being updated.
- On blur it actually assigns the internal state back to the record attribute (prevents types like Date coercing while you type and/or raising errors).
- If the record attribute changes externally it updates the value.
AKA, the input always shows the current "real" value of the record attribute. While you're typing it uses internal state until you're done (blur) then it assigns it back to the attribute.
class Watch < Hyperloop::Component
param :attribute
param :on_trigger, default: -> {}, type: Proc
param :className, default: "", type: String
before_receive_props do |new|
next if new[:attribute] == @attribute
@attribute = new[:attribute]
params.on_trigger
end
render { children.first.render if children.any? }
end
I also had to use @catmando's technique from to allow typing in the middle of then input. #248
This is beautifully done.
I think your need for Watch is very closely related to ruby-hyperloop/hyper-mesh#47
Watch here is solving the problem of letting you manipulate a copy of the content of an AR attribute (or really any other global state variable) while keeping the copy in sync with master, but only writing back to the master at specific point (i.e. on-blur)
Thanks.
It's a related kind of issue, but I'm not doing this to solve render performance, I didn't have any problem theme. The 2 separate issues I'm solving here:
First is to workaround special attribute types, namely Date. If you link a text input directly to a record attribute of type Date then it is impossible to type anything in it—because the field is strictly typed so the date is invalid until you've finished typing the whole thing, but you can't because every character you type causes an error and the input reverts to being empty. The only way to type a date is thus to copy paste in a full valid date. So that's the reason I use an intermediate state value; to let you finish typing the whole thing before assigning the attribute on blur, and Watch allows external attribute changes to update the input too. It works great.
The second issue is only being able to type at then end. I thought my intermediate state also fixed that, but it seems not. Could've sworn it did though, so maybe a recent lap has changed state handling and broken this, I suspect so. Anyway, your funky workaround fixes that, so I have updated my example to use said technique too, within the Watch callback.
Understand that while they are "related" they are not for the same underlying reason. Just thinking we might be able to kill two birds with one stone in solving ruby-hyperloop/hyper-mesh#47