Building a library IV - Validation, errors & i18n
In part III of building a library, we ended with a field called :percent
had
an integer-adapter
added to it. Let’s add some validation to it.
A big problem with validation, is that there are oh so many different ways you might want to validate a value. In Clojurescript there are easily over a dozen alternatives, all with pros and cons. For ez-form I picked vlad, which was great for my purposes, but it also meant that anyone wanting to use ez-form had to buy into my choices. And so the end result was a more brittle and stiff implementation, where some things were set in stone.
Protocols
We can avoid all of this if we use protocols. The
protocol is defined below, and is fairly simple. We want to check if something
is valid?
, we want to get any error message
and for branching we want to
get affected fields (ignore this one for now).
(defprotocol IValidate
:extend-via-metadata true
(valid? [validation value form] "Evaluate if this validation is true given the value")
(get-error-message [validation value form] "Get any error messages for this validation")
(get-affected-fields [validation] "Get any fields that might be affected by this validation. This can include the same field as the validation is attached to."))
An additional consideration, is that you might want to do more than one validation per value. That also means you might get more than one error message back. You also have another problem where all the values are actually correct, but they are still wrong. In those instances, you need to be able to add external error messages. Examples of this are passwords, which are valid according to your validation, but does not match the password saved in the database.
Errors
We also need to store the errors somewhere. Since ez-wire is a frontend library only, which only ever runs in a local environment, errors have been implemented in the easiest manner possible with a local atom. This is something that could be expanded on in the future by adding a protocol, should the need arise.
(defonce errors (atom {}))
Notice that this is only about the storage, and does not care about rendering.
There is a second reason we want to store errors separately. While errors have to be defined together with the validation, we wish to keep the implementation and the specification of them separate. This allows us switch out the validation, but keep the error message.
PS
The error message functionality cheat a bit at the moment, with regards to implementation. It should be expanded to use protocols as well.
Default implementations
Some default implementations for validation are supplied for ez-wire, of which we can focus on keywords and vectors.
(extend-protocol protocols/IValidate
cljs.core/Keyword
(valid? [validation value form]
(spec/valid? validation value))
(get-error-message [validation value form]
(get @errors validation nil))
(get-affected-fields [validation]
#{})
cljs.core/PersistentVector
(valid? [validation value form]
(every? #(protocols/valid? % value form) validation))
(get-error-message [validation value form]
(map #(get @errors % nil) validation))
(get-affected-fields [validation]
(apply set/union (map #(protocols/get-affected-fields %) validation))))
Keywords
by default are implemented using
clojure.spec. This allows for a reasonable
default, that is always available, and works pretty well for validation.[O
Vectors
by default also implement the IValidate
protocol, but in this case
we use a clever litte trick where we run the corresponding function of the
protocol against each value in the vector. This is how we support multiple
validations, and here we have a really good demonstration of the power of
protocols. The vector does not want to care about the implementation details of
each element in the vector, and with the usage of protocols it does not need to.
For convenience, a macro exists for registering the validations together with the error message.
(defmacro defvalidation
([spec-k t-fn-or-keyword]
`(swap! errors assoc ~spec-k ~t-fn-or-keyword))
([spec-k spec-v t-fn-or-keyword]
`(do (spec/def ~spec-k ~spec-v)
(swap! errors assoc ~spec-k ~t-fn-or-keyword))))
Tying validation together with fields
For our previous field :percent
we now want to specifiy that it cannot go
below 0% or above 100%. We specify this with our macro.
(defvalidation :percent-above-0
#(>= % 0)
"The percent cannot go lower than 0%")
(defvalidation :percent-below-100
#(<= % 100)
"The percent cannot go higher than 100%")
We have our validations. We now need to tie it together with our :percent
field, and we do this by adding support for validation in ez-wire to fields
by adding a key called :validation
.
Integer adapter with validation
(defform my-form-name
{}
[{:name :percent
:element Input ;; from some popular React component library
:adapter integer-adapter
:validation [:percent-above-0 :percent-below-100]}])
Notice that by adding support for this, we don’t have to change anything in the syntax. I.e., we don’t break anything by adding this.
i18n
Internationalization face the same problem as validation, in that there are a ton of libraries available to use. Not wishing to tie ez-wire down to a specific implementation we use protocols for i18n as well.
And just like validation we supply default implementations.
(defprotocol Ii18n
:extend-via-metadata true
(t [k] [k args]))
;; default implementations
(extend-protocol Ii18n
string
(t
([k] k)
([k args] k))
nil
(t
([k] k)
([k args] k))
cljs.core/Keyword
(t
([k] (name k))
([k args] (name k))))
So we give back string
as is, nil
as is and keyword
as the name
.
Keyword
giving back only the name could be argued vs giving back the full
keyword in cases of qualified keywords. However, a string is needed to be given
back unless you want any React component to choke on the keyword implementation
from clojurescript.
i18n with tongue
tongue is a neat little clojure(script) library for i18n that works quite well. Below follows an example implementation extending the i18n protocol to use tongue for i18n.
(def dictionary
{:en {:percent-0 "The percent cannot go lower than 0%"
:percent-100 "The percent cannot go higher than 100%"}
:sv {:percent-0 "Procenten kan inte gå lägre än 0%"
:percent-100 "Procenten kan inte gå högre än 100%"}
:tongue/fallback :nb-NO})
(def translate (tongue/build-translate data))
(def locale (atom :en))
(extend-protocol Ii18n
cljs.core/Keyword
(t
([k] (translate @locale k))
([k args] (apply translate @locale k args))))
And we can now specify our validations like this. If we switch the locale atom, any new renderings of the form will switch to the new language.
(defvalidation :percent-above-0
#(>= % 0)
:percent-0)
(defvalidation :percent-below-100
#(<= % 100)
:percent-100)
Next up…
We will continue by looking at the implementation details for the watcher.