May 13, 2021

Building a library V - Implementation details of the watcher

In part III we talked about the inititation of a form and skimmed over the part of the watcher, which is what forms the loop of ez-wire between input and output.

(defn- add-watcher
  "Add validation checks for the RAtom as it changes"
  [form]
  (let [{{on-valid :on-valid} :options} form]
    (add-watch (:data form) (:id form)
               (fn [_ _ old-state new-state]
                 ;; get all errors for all fields
                 (let [field-errors (->> (reduce (get-validation-errors form new-state old-state) [] new-state)
                                         (get-external-errors form))]
                   ;; update the RAtoms for the error map
                   (doseq [[k update? errors] field-errors]
                     (when update?
                       (rf/dispatch [::error (:id form) k errors])
                       (reset! (get-in form [:errors k]) errors)))

                   ;; if there are no errors then the form is
                   ;; valid and we can fire off the function
                   (let [active-fields (reduce (fn [out k]
                                                 (if @(:active? (get-in form [:fields k]))
                                                   (conj out k)
                                                   out))
                                               #{} (:field-ks form))
                         valid? (->> field-errors
                                     (filter #(active-fields (first %)))
                                     (map last)
                                     (every? empty?))
                         to-send (if valid? (select-keys @(:data form) active-fields) ::invalid)]
                     (when (fn? on-valid)
                       (on-valid to-send))
                     (rf/dispatch [::on-valid (:id form) to-send])))))))

Here we tie the validation and errors which we setup in part IV into the rest of the form.

The loop we want is to have a user do something with the form, run validations against the new data, add any errors for invalid input, remove any errors from previous invalid input, visually update (both input and errors) and give updates that the form is either invalid, or valid.

In the code this means get-validation-errors grabs validation errors (and removes them). get-external-errors grabs any errors that has been added externally by the programmer using the library.

The next step is to loop over all of the errors and update the error RAtom for that field, if it has been updated. We need to have the updated? check, since otherwise we would trigger a re-rendering from reagent of the error, every time we did an update to the field, regardless if it had changed or not.

We then run through the active fields (this has to do with the wizard and branching), make sure that they all are valid, and then do any updates needed for the consumer of the library to know that the form is valid or invalid.

get-external-errors

get-external-errors is self-contained. It uses the :extra RAtom in the form, which is meant to be a catch all data container that doesn’t warrant a bigger implementation.

(defn- get-external-errors [form field-errors]
  (reduce (fn [out [k update? errors]]
            (if-let [external-errors (get-in @(:extra form) [k :field-errors])]
              (let [trimmed-external-errors (remove #(valid? % nil nil) external-errors)]
                ;; run a check if we need to update the external errors
                ;; because some of them are being removed
                (if-not (= (count external-errors)
                           (count trimmed-external-errors))
                  (swap! (:extra form) assoc-in [k :field-errors] trimmed-external-errors))
                (conj out [k update? (->> trimmed-external-errors
                                              (map #(get-error-message % nil nil))
                                              (into errors))]))
              (conj out [k update? errors])))
          [] field-errors))

get-validation-errors

get-validation-errors is a little bit more involved with 3 helper functions, and is itself a function that returns a function.

From the bottom up get-validation-errors is run once, returns the reducer function and is then reduced over. This is where the IValidate protocol comes into play, as validations are being run on the values.

We first check if there is more than one field being affected by the change in the current field using get-affected-fields. This part of the implementation starts to become tricky, because we want slightly different behaviour depending on where we are in the lifecycle of the form. This is one of those times where leaving comments are a good thing™. It will be one of those things that is only discovered when you manually test how your code performs yourself, and leaving a comment about why you did a certain thing will be extremely helpful to your future self, and will avoid any axe murdering maniacs tracking you down to educate you about why comments are important.

From here it becomes a little bit more straight forward as we run the valid? function from the IValidate protocol against our value (and we do not need to know the internal implementation details).

Finally we get any error messages if there is indeed something wrong. This is the part where we cheated a bit, and didn’t implement the error messages as a protocol. But…​ and this is important, what we return will be able to run through the i18n protocol. So how we get the error messages are a bit more set in stone than the other parts of the implementation, but the return result can always be extended to whatever we want.

Note
Remember the function get-affected-fields from the IValidate protocol, which we didn’t talk about? It’s used for when you might run into a sitatuion where a validation has to take into account multiple fields. There is a MultiValidation implementation that does this exact thing, where you can have it check a password field and a repeat password field in a signup form.
(defn- finalize-error-message [context error-message]
  (if (fn? error-message)
    (with-meta (error-message context) {::by-fn? true})
    error-message))

(defn- get-error-messages [{:keys [validation] :as field} value form]
  (let [v (if (sequential? validation) validation [validation])
        context {:field field
                 :value value
                 :form form}]
    (->> v
         (remove #(valid? % value form))
         (map (comp #(finalize-error-message context %)
                    #(get-error-message % value form)))
         (remove nil?))))

(defn- field-updated?
  [old-state k v]
  ;; we skip pairs of nils assuming that this is a state of initialization
  (let [old-v (get old-state k)]
    (and (not (and (nil? old-v)
                   (nil? v)))
         (not= v old-v))))

(defn- get-validation-errors [form new-state old-state]
  (fn [out [k v]]
    (let [{:keys [validation] :as field} (get-in form [:fields k])]
      ;; update? is used to determine if we should do updates to errors
      ;; we still need all the errors for on-valid to properly fire
      (let [affected-fields (get-affected-fields validation)
            update? (if (empty? affected-fields)
                      (field-updated? old-state k v)
                      ;; we need to run two checks here. first that the current field
                      ;; has been updated at least once. if this does not pass
                      ;; we do nothing, because the user has not begun to interact with the
                      ;; field
                      ;; if it passes that we run the check against affected fields,
                      ;; including the current field
                      (and (some? v)
                           (some #(field-updated? old-state % (get new-state %))
                                 (set/union affected-fields #{k}))))]
        (if (valid? validation v form)
          ;; if the validation is valid we change it to hold zero errors
          (conj out [k update? []])
          ;; if the validation is invalid we give an explanation to
          ;; what's wrong
          (conj out [k update? (get-error-messages field v form)]))))))

Repeat

We have our form, we initiate the data (part III), whenever a user updates the fields the watcher is run. Each time the watcher is run, validations are run and error messages updated (part IV). For each new error message, reagent picks it up and renders it for us.

Next up…​

We will continue by looking at rendering.

Tags: programming