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.