Tutorial 18 - Augmented TDD Session
March 8, 2017 · View on GitHub
In the previous tutorial we integrated the validators for the Shopping Calculator into the corresponding WUI in such a way that the user will be notified with the corresponding help messages when she enters invalid values in the form. By first injecting the validators into the server-side code, we have been religious about the progressive enhancement strategy. It's now time to fill the gap by injecting the portable validators into the client-side WUI as well.
Preamble
To start working from the end of the previous tutorial, assuming you've git installed, do as follows
git clone https://github.com/magomimmo/modern-cljs.git
cd modern-cljs
git checkout se-tutorial-17
Introduction
In this tutorial we're going to integrate the portable validators for the Shopping Calculator into the client-side WUI.
While there is nothing really new to be learned in this tutorial about CLJS, it represents a good opportunity to see at work the live coding TDD environment we setup in the Tutorial 16 - On pleasing TDD practitioners
As said by Fogus, the author of The Joy of Clojure book:
Most software development projects include a stage where you’re not sure what needs to happen next. Perhaps you need to use a library or part of a library you’ve never touched before. Or perhaps you know what your input to a particular function will be, and what the output should be, but you aren’t sure how to get from one to other. In some programming languages, this can be time-consuming and frustrating; but by leveraging the power of the Clojure REPL, the interactive command prompt, it can actually be fun.
This statement is particularly true when you deal with OSS (Open Source Software) libraries which are frequently updated without their corresponding documentation being updated as well.
Even if you are a TDD practitioner, which means that you start coding from a failing test, you still need to know and to understand your programming language and the libraries you're going to use to fix the failed tests and to refactor your code to obtain a cleaner and more maintainable code base. In that regard, Clojure(Script) REPLs are your best friends as they are for the ones, like myself, not starting to code from a test that has to fail.
Start TDD
Start TDD environment:
cd /path/to/modern-cljs
boot tdd
...
Elapsed time: 26.573 sec
Start CLJ REPL
Now launch the client REPL as usual
# from a new terminal
cd /path/to/modern-cljs
boot repl -c
...
boot.user=>
Turn on the JS engine in your browser and then visit the
Shopping Calculator URI to
activate the websocket connection used by tdd to reload pages when
you save some changes.
Start CLJS bREPL
Finally, launch the CLJS bREPL from the CLJ REPL
boot.user=> (start-repl)
<< started Weasel server on ws://127.0.0.1:49974 >>
<< waiting for client to connect ... Connection is ws://localhost:49974
Writing boot_cljs_repl.cljs...
connected! >>
To quit, type: :cljs/quit
nil
cljs.user=>
and you're ready to go.
Server side validators review
In the previous tutorial we injected the portable
validate-shopping-form validator in the shopping function defined
in the modern-cljs.templates.shopping namespace:
(defn shopping [q p t d]
(update-shopping-form q p t d (validate-shopping-form q p t d)))
The validate-shopping-form validator has been defined in the
portable src/cljc/modern_cljs/shopping/validators.cljc source file:
(defn validate-shopping-form [quantity price tax discount]
(validate {:quantity quantity :price price :tax tax :discount discount}
;; validate presence
[:quantity present? "Quantity can't be empty"]
[:price present? "Price can't be empty"]
[:tax present? "Tax can't be empty"]
[:discount present? "Discount can't be empty"]
;; validate type
[:quantity integer-string? "Quantity has to be an integer number"]
[:price decimal-string? "Price has to be a number"]
[:tax decimal-string? "Tax has to be a number"]
[:discount decimal-string? "Discount has to be a number"]
;; validate range
[:quantity (gt 0) "Quantity can't be negative"]
;; other specific platform validations (not at the moment)
))
validate-shopping-form validates the quantity, price, tax and
discount input fields all together. As you remember, when some
values do not pass the validation rules, the validator returns a map
of the corresponding error messages. Something like the following
;;; a sample call like the following
(validate {:email "zzzz" :password nil}
[:email present? "Email can't be empty"]
[:email email-address? "Invalid email format"]
[:password present? "Password can't be empty"]
[:password (matches *re-password*) "Invalid password format"])
;;; returns
{:email ["Invalid email format"]
:password ["Password can't be empty" "Invalid password format"]}
For that reason the assertions of the validate-shopping-form-test
test have been implemented by getting the first item of the vector
of messages returned by the validate-shopping-form when a value of
an input field does not pass the validation:
(deftest validate-shopping-form-test
(testing "Shopping Form Validation"
...
(testing "/ No presence"
(are [expected actual] (= expected actual)
"Quantity can't be empty"
(first (:quantity (validate-shopping-form "" "0" "0" "0")))
"Price can't be empty"
(first (:price (validate-shopping-form "1" "" "0" "0")))
"Tax can't be empty"
(first (:tax (validate-shopping-form "1" "0" "" "0")))
"Discount can't be empty"
(first (:discount (validate-shopping-form "1" "0" "0" "")))))
...))
TDD Workflow: start from a test that has to fail
On the client side WUI we want to individually validate any input value
as soon as we leave the corresponding field (i.e. when the
blur event is fired) as we already did in a
previous tutorial
with the email and password input of the Login Form:
;; individually validate email input field
(defn validate-email [email]
(destroy! (by-class "email"))
(if-let [errors (:email (user-credential-errors (value email) nil))]
(do
(prepend! (by-id "loginForm") (html [:div.help.email (first errors)]))
false)
(validate-email-domain (value email))))
;; individually validate password input field
(defn validate-password [password]
(destroy! (by-class "password"))
(if-let [errors (:password (user-credential-errors nil (value password)))]
(do
(append! (by-id "loginForm") (html [:div.help.password (first errors)]))
false)
true))
(defn ^:export init []
(if (and ...)
(let [email (by-id "email")
password (by-id "password")]
...
;; validate email input field on blur event
(listen! email :blur (fn [evt] (validate-email email)))
l
;; validate password input field on blur event
(listen! password :blur (fn [evt] (validate-password password))))))
Open the test/cljc/modern_cljs/shopping/validators_test.cljc file to
start adding new test assertions for the quantity input of the
Shopping Calculator form:
(deftest validate-shopping-quantity-test
(testing "Shopping Form Quantity Validation"
(testing "/ Happy Path"
(are [expected actual] (= expected actual)
nil (validate-shopping-quantity "1")))))
Here the intention is to test the happy path: the validator
validate-shopping-quantity, that still does not exist, should return
nil, meaning no errors, when called with an integer argument.
As soon as you save the file you'll receive an expected error from the running CLJS/CLJ auto-test processes:
Writing clj_test/suite.cljs...
Writing main.cljs.edn...
Compiling ClojureScript...
• main.js
WARNING: Use of undeclared Var modern-cljs.shopping.validators-test/validate-shopping-quantity at line 57 test/cljc/modern_cljs/shopping/validators_test.cljc
...
Ran 3 tests containing 39 assertions.
0 failures, 1 errors.
clojure.lang.Compiler$CompilerException: java.lang.RuntimeException: Unable to resolve symbol: validate-shopping-quantity in this context, compiling:(modern_cljs/shopping/validators_test.cljc:56:7)
java.lang.RuntimeException: Unable to resolve symbol: validate-shopping-quantity in this context
...
Elapsed time: 2.487 sec
Considering we intend to define the validate-shopping-quantity
validator in the portable modern-cljs.shopping.validators namespace,
we start fixing the error by adding the validate-shopping-quantity
symbol to the :refer section of the
modern-cljs.shopping.validators namespace requirement in the
modern-cljs.shopping.validators-test namespace declaration:
NOTE 1: we could define the individual client-side input validators in a
.cljssource file as well, but it does not hurt if we define them in the portable namespace.
(ns modern-cljs.shopping.validators-test
(:require [modern-cljs.shopping.validators :refer [validate-shopping-form
validate-shopping-quantity]]
#?(:clj [clojure.test :refer [deftest are testing]]
:cljs [cljs.test :refer-macros [deftest are testing]])))
As soon as you save the file, you'll again receive an expected error:
...
adzerk.boot_cljs.util.proxy$clojure.lang.ExceptionInfo$ff19274a: Referred var modern-cljs.shopping.validators/validate-shopping-quantity does not exist
...
Elapsed time: 0.275 sec
Note that the same error is notified in the browser as well.
Satisfy the test
To make the assertion to pass, we need now to define the
validate-shopping-quantity validator in the
modern-cljs.shopping.validators namespace.
(defn validate-shopping-quantity [quantity]
(first (:quantity (validate-shopping-form quantity "0" "0" "0"))))
Here we're reusing the previously defined validate-shopping-form
validator by passing to it acceptable values for price, tax and
discount because the newly defined validate-shopping-quantity
validator is for the quantity input field only.
Also note that validate-shopping-quantity returns the first error
message, if any, associated with the :quantity keyword.
As soon as you save the file you'll see that the newly defined
assertion for the validate-shopping-validate-test unit test passed.
Writing clj_test/suite.cljs...
Writing main.cljs.edn...
Compiling ClojureScript...
• main.js
Running cljs tests...
Testing modern-cljs.login.validators-test
Testing modern-cljs.shopping.validators-test
Ran 3 tests containing 39 assertions.
0 failures, 0 errors.
Testing modern-cljs.login.validators-test
Testing modern-cljs.shopping.validators-test
Ran 4 tests containing 40 assertions.
0 failures, 0 errors.
Elapsed time: 7.119 sec
Code Refactoring
The validate-shopping-quantity validator is very simple, but at the
same time it's suggesting to us that validate-shopping-price,
validate-shopping-tax and validate-shopping-discount validators
would be almost identical:
(defn validate-shopping-price [price]
(validate-shopping-form "1" price "0" "0"))
(defn validate-shopping-tax [tax]
(validate-shopping-form "1" "0" tax "0"))
(defn validate-shopping-discount [discount]
(validate-shopping-form "1" "0" "0" discount))
A clear case for code refactoring, because we could implement a more
general validate-shopping-field receiving a field (e.g.,
:quantity, :price, etc.) and the value to be validated as
arguments.
To implement validate-shopping-field we could use one of the
CLJ/CLJS conditional forms: cond, condp orcase. Perhaps we do
not remember how they work well enough to choose the one that best
fits our case. Don't worry, we have the REPL to experiment
with them:
First require the needed namespace:
cljs.user> (require '[modern-cljs.shopping.validators :as v])
nil
Then get the cond docstring:
cljs.user> (doc cond)
-------------------------
cljs.core/cond
([& clauses])
Macro
Takes a set of test/expr pairs. It evaluates each test one at a
time. If a test returns logical true, cond evaluates and returns
the value of the corresponding expr and doesn't evaluate any of the
other tests or exprs. (cond) returns nil.
nil
and experiment with the cond form in the context of our case, by
starting with the happy path
cljs.user> (let [field :quantity
val "1"]
(cond (= field :quantity) (v/validate-shopping-form val "0" "0" "0")
(= field :price) (v/validate-shopping-form "1" val "0" "0")))
nil
and going on with an invalid value:
cljs.user> (let [field :quantity
val "-1"]
(cond (= field :quantity) (v/validate-shopping-form val "0" "0" "0")
(= field :price) (v/validate-shopping-form "1" val "0" "0")))
{:quantity ["Quantity can't be negative"]}
It works, but it's a little bit verbose. Let's see if the condp form
has something better to offer by first getting its docstring
cljs.user> (doc condp)
-------------------------
cljs.core/condp
([pred expr & clauses])
Macro
Takes a binary predicate, an expression, and a set of clauses.
Each clause can take the form of either:
test-expr result-expr
test-expr :>> result-fn
Note :>> is an ordinary keyword.
For each clause, (pred test-expr expr) is evaluated. If it returns
logical true, the clause is a match. If a binary clause matches, the
result-expr is returned, if a ternary clause matches, its result-fn,
which must be a unary function, is called with the result of the
predicate as its argument, the result of that call being the return
value of condp. A single default expression can follow the clauses,
and its value will be returned if no clause matches. If no default
expression is provided and no clause matches, an
IllegalArgumentException is thrown.
nil
and then experimenting with it, by starting again from an happy path
cljs.user> (let [field :quantity
val "1"]
(condp = field
:quantity (v/validate-shopping-form val "0" "0" "0")
:price (v/validate-shopping-form "1" val "0" "0")))
nil
and continuing with an invalid input value
cljs.user> (let [field :quantity
val "-1"]
(condp = field
:quantity (v/validate-shopping-form val "0" "0" "0")
:price (v/validate-shopping-form "-1" val "0" "0")))
{:quantity ["Quantity can't be negative"]}
A little bit less verbose. Perhaps case form is even better than
condp. Get its docstring
cljs.user=> (doc case)
-------------------------
cljs.core/case
([e & clauses])
Macro
Takes an expression, and a set of clauses.
Each clause can take the form of either:
test-constant result-expr
(test-constant1 ... test-constantN) result-expr
The test-constants are not evaluated. They must be compile-time
literals, and need not be quoted. If the expression is equal to a
test-constant, the corresponding result-expr is returned. A single
default expression can follow the clauses, and its value will be
returned if no clause matches. If no default expression is provided
and no clause matches, an Error is thrown.
Unlike cond and condp, case does a constant-time dispatch, the
clauses are not considered sequentially. All manner of constant
expressions are acceptable in case, including numbers, strings,
symbols, keywords, and (ClojureScript) composites thereof. Note that since
lists are used to group multiple constants that map to the same
expression, a vector can be used to match a list if needed. The
test-constants need not be all of the same type.
nil
The case form seems to
better fit our need. Let's
try it at bREPL:
cljs.user> (let [field :quantity
val "1"]
(case field
:quantity (v/validate-shopping-form val "0" "0" "0")
:price (v/validate-shopping-form "-1" val "0" "0")))
nil
cljs.user> (let [field :quantity
val "-1"]
(case field
:quantity (v/validate-shopping-form val "0" "0" "0")
:price (v/validate-shopping-form "-1" val "0" "0")))
{:quantity ["Quantity can't be negative"]}
Now that we understand a little bit better our beloved CLJ/CLJS
programming language by experimenting with it at the REPL while
following a TDD approach, go back to the
modern-cljs.shopping.validators namespace and substitute the
previously defined validate-shopping-quantity validator with a more
general one as follows:
(defn validate-shopping-field [field value]
(case field
:quantity (first (:quantity (validate-shopping-form value "0" "0" "0")))
:price (first (:price (validate-shopping-form "1" value "0" "0")))
:tax (first (:tax (validate-shopping-form "1" "0" value "0")))
:discount (first (:discount (validate-shopping-form "1" "0" "0" value)))))
NOTE 2: OOP (Object Oriented Programming) practitioners hate conditionals forms. Some of them even launched an Anti-IF Campaign. I'm not religious in anyway about anything, because I'm a Philosopher, but sometime it happens that a smart use of
defprotocolordefmulticould improve the abstraction and the extendibility of your code (i.e., polymorphism).
As soon as you save the file you'll receive again an expected error
Writing clj_test/suite.cljs...
Writing main.cljs.edn...
Compiling ClojureScript...
• main.js
adzerk.boot_cljs.util.proxy$clojure.lang.ExceptionInfo$ff19274a: Referred var modern-cljs.shopping.validators/validate-shopping-quantity does not exist
...
Elapsed time: 1.477 sec
because validate-shopping-quantity does not exist anymore, but it is
still referenced in the test file. Let's fix it
(ns modern-cljs.shopping.validators-test
(:require [modern-cljs.shopping.validators :refer [validate-shopping-form
validate-shopping-field]]
#?(:clj [clojure.test :refer [deftest are testing]]
:cljs [cljs.test :refer-macros [deftest are testing]])))
;;; ...
(deftest validate-shopping-field-test
(testing "Shopping Form Fields Validation"
;; happy path
(testing "/ Happy Path"
(are [expected actual] (= expected actual)
nil (validate-shopping-field :quantity "1")
nil (validate-shopping-field :price "1.0")
nil (validate-shopping-field :tax "8.25")
nil (validate-shopping-field :discount "0.0")))
;; presence
(testing "/ Presence"
(are [expected actual] (= expected actual)
"Quantity can't be empty" (validate-shopping-field :quantity "")
"Quantity can't be empty" (validate-shopping-field :quantity nil)
"Price can't be empty" (validate-shopping-field :price "")
"Price can't be empty" (validate-shopping-field :price nil)
"Tax can't be empty" (validate-shopping-field :tax "")
"Tax can't be empty" (validate-shopping-field :tax nil)
"Discount can't be empty" (validate-shopping-field :discount "")
"Discount can't be empty" (validate-shopping-field :discount nil)))
;; type
(testing "/ Type"
(are [expected actual] (= expected actual)
"Quantity has to be an integer number" (validate-shopping-field :quantity "1.1")
"Price has to be a number" (validate-shopping-field :price "foo")
"Tax has to be a number" (validate-shopping-field :tax "foo")
"Discount has to be a number" (validate-shopping-field :discount "foo")))
;; range
(testing "/ Range"
(are [expected actual] (= expected actual)
"Quantity can't be negative" (validate-shopping-field :quantity "-1")))))
While we were updating the test file to fix the above error, it was very easy to add some other assertions as well.
As soon as you save the file, the tdd environment fires the
recompilation and the re-execution of all defined tests:
Writing clj_test/suite.cljs...
Writing main.cljs.edn...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.login.validators-test
Testing modern-cljs.shopping.validators-test
Ran 3 tests containing 61 assertions.
0 failures, 0 errors.
Testing modern-cljs.login.validators-test
Testing modern-cljs.shopping.validators-test
Ran 4 tests containing 62 assertions.
0 failures, 0 errors.
Writing target dir(s)...
Elapsed time: 9,245 sec
I leave to you the addition of any other assertion you could be interested in for strengthening your confidence of the correctness of the newly defined fields validators.
Fill the Gap
In the previous paragraph we experimented with an augmented TDD workflow by interleaving a few experiments with the language in the REPL while satisfying a few tests and consequently refactoring some code. But we still have to attach the newly defined individual field validators to the Shopping Calculator. Let's do that.
Inject the validators
To inject the individual validators for the input fields of the Shopping Calculator I'm not going to use a TDD workflow, but you could.
Let's start from the quantity input field. We'd like to mimic the
same effect we reached on the server-side WUI. For example, if the
user eventually typed a decimal number in the quantity input field,
when the field looses the focus we'd like to transform the following
HTML fragment
<div>
<label for="quantity">Quantity</label>
<input type="number"
name="quantity"
id="quantity"
value="1"
min="1" required>
</div>
into something like the following fragment
<div>
<label class="help" for="quantity">Quantity has to be an integer number</label>
<input type="number"
name="quantity"
id="quantity"
value="1.2"
min="1" required>
</div>
CSS selectors
First note that the label element does not have an id attribute to
be used with the by-id function we already used more times in
previous tutorials on domina library usage.
Likely domina offers both css and xpath selectors for such a
case. This is the first time we've dealt with css and xpath selectors
from the domina lib and before we start coding we want to
familiarize ourselves a little bit with at least one of them in the bREPL,
namely with the css selector.
Perhaps you remember that due to a bug in the boot-cljs-repl task,
before requiring a namespace of a dependency from the bREPL, you need
to require it in a source file.
Open the src/cljs/modern_cljs/shopping.cljs source file and add the
domina.css namespace to the namespace declaration as follows:
(ns modern-cljs.shopping
(:require [domina.core :refer [append!
by-class
by-id
destroy!
set-value!
value]]
[domina.events :refer [listen! prevent-default]]
[domina.css :refer [sel]] ;; domina css selector
[hiccups.runtime]
[shoreleave.remotes.http-rpc :refer [remote-callback]])
(:require-macros [hiccups.core :refer [html]]
[shoreleave.remotes.macros :as macros]))
Note that the only referred symbol in the domina.css requirement is
sel, for selector. We can now start playing with it at the bREPL. First we have to require the needed namespaces from domina library
cljs.user> (require '[domina.core :as dom]
'[domina.css :as css])
nil
and then let's see if we're able to select the label for the quantity
input field by using the
label[for=quantity]
CSS attribute selector:
cljs.user> (css/sel "label[for=quantity]")
#object[domina.css.t_domina$css8251]
So far, so good. Let's see if we are able to get the text associated with that label:
cljs.user> (dom/text (css/sel "label[for=quantity]"))
"Quantity"
It worked. Our goal is now to replace that text with a new one and to
add the help class to the label in such a way that the styles.ccs
included in the project will render that text in red color
cljs.user> (dom/set-text! (css/sel "label[for=quantity]") "Quantity has to be an integer number")
#object[domina.css.t_domina$css8251]
If you now take a look at the Shopping Calculator form in the browser,
you should see the new text instead of the original Quantity
label. Now let's try to add the help class to the label:
cljs.user> (dom/add-class! (css/sel "label[for=quantity]") "help")
#object[domina.css.t_domina$css8251]
But we'll need also to remove a class from an element
cljs.user> (dom/remove-class! (css/sel "label[for=quantity]") "help")
#object[domina.css.t_domina$css8251]
We got it. We learned enough at the bREPL about domina.core and
domina.css namespaces and we can start coding in the
src/cljs/modern_cljs/shopping.cljs source file.
First, we start by adding add-class, remove-class!, set-text!
and text symbols to the refer section of domina.core
requirement:
(ns modern-cljs.shopping
(:require [domina.core :refer [add-class! ;; to add a class
remove-class! ;; to remove a class
set-text! ;; to set text
text ;; to get text
...]]
...
(:require-macros ...))
Then we need to add the modern-cljs.shopping.validators to the
require section of the namespace declaration
(ns modern-cljs.shopping
(:require ...
[modern-cljs.shopping.validators :refer [validate-shopping-field
validate-shopping-form]])
(:require-macros ...))
Note that we're referring both the newly defined individual
validate-shopping-field validator and the aggregate
validate-shopping-form validator. This choice will become clearer
later when we'll update the calculate function.
Let's now attach a listener for the blur event to each field of the form:
(defn ^:export init []
(when (and js/document
(aget js/document "getElementById"))
;; get original labels' texts
(let [quantity-text (text (sel "label[for=quantity]"))
price-text (text (sel "label[for=price]"))
tax-text (text (sel "label[for=tax]"))
discount-text (text (sel "label[for=discount]"))]
;; quantity validation
(listen! (by-id "quantity")
:blur
(fn [_] (validate-shopping-field! :quantity quantity-text)))
;; price validation
(listen! (by-id "price")
:blur
(fn [_] (validate-shopping-field! :price price-text)))
;; tax validation
(listen! (by-id "tax")
:blur
(fn [_] (validate-shopping-field! :tax tax-text)))
;; discount validation
(listen! (by-id "discount")
:blur
(fn [_] (validate-shopping-field! :discount discount-text))))
;; calculate
(listen! (by-id "calc")
:click
(fn [evt] (calculate! evt)))
;; show help
(listen! (by-id "calc")
:mouseover
(fn []
(append! (by-id "shoppingForm")
(html [:div#help.help "Click to calculate"])))) ;; hiccups
;; remove help
(listen! (by-id "calc")
:mouseout
(fn []
(destroy! (by-id "help"))))))
There are few important things to be noted here:
- we wrapped the field listeners for
blurevents inside aletform to keep memory of their original text labels. This is because we need to set them again when the input values are valid; - each
blurlistener uses the samevalidate-shopping-field!function and passes to it the input field it is listening to and the original text label captured by theletform; note that the name of the listener is almost the same as the name of the validator (i.e.,validate-shopping-field). We only added the bang!to underline that it has side-effect on the DOM; - we also modified the button listeners to
mouseoverandmouseoutevents. This is because we want to protect the removal of thehelpclass from the invalid field when the user move the mouse out of the button area; - we changed the name of the button click listener from
calculatetocalculate!, because this function is going to have side-effect on the DOM as well.
Let's now define the validate-shopping-field! listener above init in shopping.cljs:
(defn validate-shopping-field! [field text]
(let [attr (name field)
label (sel (str "label[for=" attr "]"))]
(remove-class! label "help")
(if-let [error (validate-shopping-field field (value (by-id attr)))]
(do
(add-class! label "help")
(set-text! label error))
(set-text! label text))))
As you see the definition of the validate-shopping-field! listener
is quite easy:
- first we get the label for the input field in the
letform; - then we remove the
helpclass, even when it's not present because it does not hurt; - next, if there is any error resulting from the individual input
validator, we add the
helpclass to the label and set its text to the returned error; - if the input value is valid, we just set the text of the label to its original value.
We are not finished yet. What happens when some input values are invalid and the user clicks the Calculate button? At a minimum we should prevent the remote calculate function from being called as follows:
(defn calculate! [evt]
(let [quantity (value (by-id "quantity"))
price (value (by-id "price"))
tax (value (by-id "tax"))
discount (value (by-id "discount"))
errors (validate-shopping-form quantity price tax discount)]
(if-not errors
(remote-callback :calculate
[quantity price tax discount]
#(set-value! (by-id "total") (.toFixed % 2))))
(prevent-default evt)))
As soon as you save the file, the tdd environment recompiles the
source files and re-executes both the CLJS and the CLJ tests.
Writing clj_test/suite.cljs...
Writing main.cljs.edn...
Compiling ClojureScript...
WARNING: Replacing ClojureScript compiler option :main with automatically set value.
• main.js
Running cljs tests...
Testing modern-cljs.login.validators-test
Testing modern-cljs.shopping.validators-test
Ran 3 tests containing 61 assertions.
0 failures, 0 errors.
Testing modern-cljs.login.validators-test
Testing modern-cljs.shopping.validators-test
Ran 4 tests containing 62 assertions.
0 failures, 0 errors.
Writing target dir(s)...
Elapsed time: 9,245 sec
As you remember, anytime you modify the exported init function that
is attached to the onload event of the corresponding HTML page, you
need to manually reload the page itself to appreciate the effects of
your changes: reload the page and see the individual fields validators at
work by playing with the Shopping Form.
On Improving UX (User eXperience)
One more thing: while playing with the Shopping Calculator, you need to click
the Calculate button to calculate the total anytime you change an input value.
We could improve the user experience of the Shopping Calculator and it's
very easy too. I leave to you to understand the following final code
for improving the Shopping Calculator UX.
(ns modern-cljs.shopping
(:require [domina.core :refer [add-class!
append!
by-class
by-id
destroy!
remove-class!
set-value!
set-text!
text
value]]
[domina.events :refer [listen! prevent-default]]
[domina.css :refer [sel]]
[hiccups.runtime]
[modern-cljs.shopping.validators :refer [validate-shopping-field
validate-shopping-form]]
[shoreleave.remotes.http-rpc :refer [remote-callback]])
(:require-macros [hiccups.core :refer [html]]
[shoreleave.remotes.macros :as macros]))
(defn calculate! []
(let [quantity (value (by-id "quantity"))
price (value (by-id "price"))
tax (value (by-id "tax"))
discount (value (by-id "discount"))
errors (validate-shopping-form quantity price tax discount)]
(if-not errors
(remote-callback :calculate
[quantity price tax discount]
#(set-value! (by-id "total") (.toFixed % 2))))))
;;; validate-shopping-filed now takes an event argument too
(defn validate-shopping-field! [evt field text]
(let [attr (name field)
label (sel (str "label[for=" attr "]"))]
(remove-class! label "help")
(if-let [error (validate-shopping-field field (value (by-id attr)))]
(do
(add-class! label "help")
(set-text! label error))
(do
(set-text! label text)
(calculate!) ;; trigger the calculation
(prevent-default evt))))) ;; and prevent default submission
(defn ^:export init []
(when (and js/document
(aget js/document "getElementById"))
;; get original labels' texts
(let [quantity-text (text (sel "label[for=quantity]"))
price-text (text (sel "label[for=price]"))
tax-text (text (sel "label[for=tax]"))
discount-text (text (sel "label[for=discount]"))]
;; quantity validation
(listen! (by-id "quantity")
:input
(fn [evt] (validate-shopping-field! evt :quantity quantity-text)))
;; price validation
(listen! (by-id "price")
:input
(fn [evt] (validate-shopping-field! evt :price price-text)))
;; tax validation
(listen! (by-id "tax")
:input
(fn [evt] (validate-shopping-field! evt :tax tax-text)))
;; discount validation
(listen! (by-id "discount")
:input
(fn [evt] (validate-shopping-field! evt :discount discount-text))))
(listen! (by-id "calc")
:click
(fn [evt]
(calculate!)
(prevent-default evt)))
(listen! (by-id "calc")
:mouseover
(fn [_]
(append! (by-id "shoppingForm")
(html [:div#help.help "Click to calculate"])))) ;; hiccups
(listen! (by-id "calc")
:mouseout
(fn [_]
(destroy! (by-id "help"))))
(calculate!)))
As usual, when you save the above changes, the TDD environment
triggers the recompilation and re-executes the tests. By having
changed the init function, you need to manually reload the
Shopping Calculator page. Now, as soon
as you type a value in any input field, the Shopping Calculator will
show you in red the calculated Total.
I'm really bad with HTML/CSS and I'm pretty sure that most of you can make the UX even better than this.
You can now stop the CLJ REPL and the boot process, and then reset the branch as usual:
git reset --hard
Next Step - Tutorial 19 - Living' on the edge
In the next tutorial we're going to explain how to make a library compliant with the new Reader Conditionals extension on CLJ/CLJS compilers.
License
Copyright © Mimmo Cosenza, 2012-15. Released under the Eclipse Public License, the same as Clojure.