ruby-hyperloop / hyper-react

The project has moved to Hyperstack!!
https://hyperstack.org/
MIT License
285 stars 14 forks source link

Add form_builder replacement / smart input components #245

Open sfcgeorge opened 6 years ago

sfcgeorge commented 6 years ago

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:

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:

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. https://github.com/ruby-hyperloop/hyper-react/issues/248

catmando commented 6 years ago

This is beautifully done.

I think your need for Watch is very closely related to https://github.com/ruby-hyperloop/hyper-mesh/issues/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 commented 6 years ago

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 commented 6 years ago

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