May 9, 2021

Building a library III - Study case ez-wire

In part III we look at the thought process that went into ez-wire. ez-wire is based off of ez-form, which in turn is based off of Django forms.

Design goals

We want something that is reusable, extendable and readable. Langage of of choice is Clojurescript, using a combination of reagent and re-frame. The stakeholders are the library author, the library maintainer, developers using the library, third party components (React and reagent) and finally users of web apps/pages using the library.

Of these only reagent is strictly a requirement, while re-frame is detable and could have been implemented in an extendable manner.

Picking reagent, which uses hiccup syntax for representing HTML, allows us to use clojure.zip for some fancy data manipulation. Let’s dive in shall we?

defform

defform is the macro which creates a form in ez-wire. It is used as follows:

(defform name-of-form options field)
(defform my-form-name
  {}
  [{:name :username}
   {:name :lastname}])

It takes three arguments, the name of the form, an options map and a vector of fields. Each field is represented as a map.

The name of the form is used by the macro to become a function, which will call on the internal machinery of ez-wire. This in turn allows us to define forms in their own namespaces, to be imported into other namespaces where they are actually used. A clean separation of implementation, usage and re-use.

We use maps deliberately for both options and fields, as they allow for easy extension. A new piece of functionality is easy to add, without breaking backwards compability with older releases, as well as allowing for extension by library users.

A form in ez-wire looks something like this.

(defrecord Form [fields field-ks options id errors data meta])
(defn form [fields form-options override-options data]
  ;; do the conform here as conform can change the structure of the data
  ;; that comes out in order to show how it came to that conclusion (spec/or for example)
  (let [options (merge {:id (util/gen-id)
                        :form/automatic-cleanup? true}
                       form-options
                       override-options)
        map-fields (->> fields
                        (map (fn [{:keys [name id error-element active?] :as field}]
                               [name (assoc field
                                            :active? (atom (if (some? active?)
                                                             active?
                                                             true))
                                            :field-fn (get-field-fn field)
                                            :value (get-default-value data name field)
                                            :error-element (or error-element
                                                               ez-wire.form.elements/error-element)
                                            ;; always generate id in the form so we
                                            ;; can reference it later
                                            :id (or id (util/gen-id)))]))
                        (into (array-map)))
        -data (reduce (fn [out [name field]]
                        (assoc out name (get-default-value data name field)))
                      {} map-fields)
        errors (reduce (fn [out [name _]]
                         (assoc out name (atom [])))
                       {} map-fields)
        field-ks (mapv :name fields)
        form (map->Form {:fields       map-fields
                         ;; field-ks control which fields are to be rendered for
                         ;; everything form supports with the exception of wiring
                         :field-ks     field-ks
                         :options      options
                         :default-data -data
                         :id           (:id options)
                         :extra        (atom {})
                         :form-key     (atom (random-uuid))
                         :branching    (atom {})
                         :wizard       (atom {})
                         :errors       errors
                         :data         (atom {})})]

    (doseq [k field-ks]
      (handle-branching form k (get -data k)))

    (add-watcher form)
    ;; run validation once before we send back our form
    (reset! (:data form) -data)
    form))

There is a lot going on here. In order:

  1. We first create a merged options map. We wish to allow for three types of overrides for options. When a form is defined, when it is initialized and when it is rendered.

  2. All the fields are initalized.

  3. We create a map holding all our data.

  4. We create a map for errors.

  5. We create a vector of all the field names. This is used for the order in which to render fields.

  6. We finally create our form map. This map holds all the important data for the form, including a bunch of advanced stuff that we’ll explore later.

  7. We handle the branching for the initial run.

  8. We add a watcher for the data RAtom.

  9. We reset the data RAtom with the initial data, which triggers the watcher function the first time.

watcher

For a form to be effective, it needs to react to input. In ez-wire this is implemented via a watch function. In clojurescript (and clojure), a watch function is a function that can be attached to any mutable state such as atoms, agents, vars and refs. The watch function takes four inputs: a key, the rerence, the old state and the new state. We are interested only in the old state and the new state.

We will get back to this and take a deep dive into it, but for now we can say it handles validation and error updates.

What happens is that whenever a field is updated, Clojurescript runs the watch function for us, and we can then run our validations, update errors, etc, which in turn triggers visual updates that the user can see.

fields

Each field in the form is represented as a map. For a field to be useful it requires a minimum of two keys: a :name key and an :element key. :name is the internal name of the field for the form, and the :element is the visual representation of the field.

When initiating a form for the first time, the form goes through an initiation phase, and in that each field also goes through an initiation phase. In ez-wire that is implemented via a multimethod called get-field-fn.

get-field-fn
(defmulti ^:private get-field-fn (fn [field]
                                   (let [{:keys [element adapter]} field]
                                     (cond adapter                                :adapt-fn
                                           (fn? element)                          :fn
                                           (util/reagent-native-wrapper? element) :fn
                                           :else                                  nil))))
(defmethod get-field-fn :fn [field]
  (:element field))
(defmethod get-field-fn :adapt-fn [{:keys [adapter] :as field}]
  (adapter field))
(defmethod get-field-fn :default [field]
  (:element field))

Since we do not know what kind of element will be used for the field, we grab the function required for rendering the field using an extendable multimethod. The default is to get the element back as is, we can also use an adapter to adapt the element into something ez-wire undestands.

integer-adapter
(defn integer-adapter [{:keys [element] :as field}]
  (let [f (r/adapt-react-class element)]
    (fn [{:keys [model value] :as data}]
      [f (merge {:value (str (or @model value))
                 :on-change #(let [value (-> % .-target .-value)
                                   int-value (js/parseInt value)]
                               (if-not (js/isNaN int-value)
                                 (reset! model int-value)
                                 (reset! model value)))}
                (select-keys data [:id :placeholder]))])))

The outer function of the integer-adapter takes the field as an argument, as is, with nothing extra added to it. The inner function takes the field as an argument, but with additional extras, added dynamically by ez-wire. Of the additional extras, the :model key is the most important one, as it is a cursor into the RAtom that holds the data for the form.

The inner function thus gets all data defined in defform, as well as the :model key, as well as any data additional data and removes anything that is regarded as internal data.

The whole point of this exercise, is that ez-wire does not need to know how fields are rendered, and that we can use anything that is usable by reagent.

And so we arrive at a field in defform that looks something like this (so far).

Integer adapater
(defform my-form-name
  {}
  [{:name :percent
    :element Input ;; from some popular React component library
    :adapter integer-adapter}])

The loop

This is one is important, so I’ll spell it out here.

When a model is updated, it triggers the watch function. This is the heart of
ez-wire. The user writes something, the watch function runs, updates occur to
the form, the user sees the updates, the user stops and ez-wire rests, waiting
for further input.

Next up…​

We will continue by looking at validation, error messages and i18n.

Tags: programming