Skip to content
This repository has been archived by the owner on Oct 19, 2018. It is now read-only.

Add form_builder replacement / smart input components #245

Open
sfcgeorge opened this issue Feb 9, 2018 · 3 comments
Open

Add form_builder replacement / smart input components #245

sfcgeorge opened this issue Feb 9, 2018 · 3 comments

Comments

@sfcgeorge
Copy link

sfcgeorge commented Feb 9, 2018

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 label from (see add #html_attr_name method 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 Add validations or awareness of validations 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

@catmando
Copy link
Contributor

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)

@sfcgeorge
Copy link
Author

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.

@catmando
Copy link
Contributor

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

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants