A simple Clojurescript app
Built with React, Om and Kioo
In May, I left Canada to do some long-term travelling with my wife. I don’t mention it here because this isn’t tumblr, but it does mean that I have some problems many people don’t, such as figuring out when I can return in the Schengen Area. This is a problem that has been solved, but I’ve yet to see it solved well, so I decided to build my own calculator as an exercise using Kioo with Om.
You can see the calculator in action here, and find the code on github
It’s a single-page app; one index.html
, one stylesheet, and one main cljs
file
with all the functionality (Plus an extra clj
file for macros). It uses Om
with Kioo to render the UI, which is pretty exciting. In this post, I’ll go through
the process of writing this simple app, and show you how it all works and fits together.
Environment
I’ve been pretty public about the difficulty I have actually getting a ClojureScript environment set up, and this isn’t really an exception. I’ve more-or-less given up on getting a repl or live reloading working easily, so the development cycle is more of a change-reload loop than I usually prefer to use while working with clojure. Other people seem to have no problem, so perhaps it’s just a matter of practice and/or getting it just right.
For that reason, I won’t be covering environment setup; other people have done this better than I can.
Build configuration
The application lives in the resources/
directory of the leiningen project.
It contains index.html
, style.css
, and a main.js
to which all the
code gets compiled. It also uses CDNs to serve react.js
and moment.js
. The
cljsbuild
configuration for this is pretty boring:
:cljsbuild {
:builds [{
:source-paths ["src/cljs"]
:compiler {
:output-to "resources/main.js"
:optimizations :whitespace
:pretty-print true}}]}
The code
It’s more interesting to read the code from the bottom up. The last thing in the file is
the call to om/root
, which initializes the applicaton. The :target
parameter tells
om that we want to replace the entire page with our main component. We also pass
in our main rendering function (which must return a component) and an atom
containing the global application state.
The app
function is the main entry point used by Om to render (and re-render) your
components. As long as you use om’s built-in transact!
and update!
functions to
update your application state, om will take care of re-rendering those components
that changed. om/component
is a shortcut macro for a component that doesn’t do
anything but render.
Above that, app-state
is a normal atom
which contains the global
application state.
(def app-state
(atom (fetch "schengencalc" {:travel-dates []})))
(defn app [data owner]
(store "schengencalc" data) ; Persist data
(om/component (main data)))
(om/root app app-state {:target (.-body js/document)})
You’ll can also see two not-yet-introduced functions called store
and fetch
.
These functions write and read, respectively, the global application state to localStorage
.
By calling fetch
on initialization in app-state
, and store
in app
, we ensure
that our application’s state persists through page loads; it is updated every re-render.
The other unfamiliar function here is main
; this is our main kioo template, and it looks like this:
(kioo/deftemplate main "index.html"
[{travel-dates :travel-dates}]
{[:tbody.travel-dates] (kioo/content (map #(date-row % travel-dates) travel-dates))
[:.add-stay :a] (kioo/listen :on-click (fn [e]
(.preventDefault e)
(om/transact! travel-dates #(conj % {})))
)
[:ul.results] (kioo/content (map result-item (re-entry-dates travel-dates)))
[:.disclaimer] (if (every? #(>= (:days-left %) 0) (re-entry-dates travel-dates))
(kioo/do->
(kioo/set-class "disclaimer ok")
(kioo/content "This schedule is ok!"))
(kioo/do->
(kioo/set-class "disclaimer warn")
(kioo/content "This schedule violates Schengen Visa rules!")))
})
Here, we introduce a library called kioo. Kioo is like enlive, in that it takes a full HTML file, parses it into a data structure, and applies an assortment of transformations to the document. Kioo is special because instead of rendering HTML itself, it just passes the structure along to om (it also works with reagent) as a React dom structure. It does this as a macro, so this is already ready to go in your compiled javascript file.
This is completely awesome. I’ve gushed about Enlive before, but I think this is even better. One thing that spooks me about React is the presence of HTML in your js files (or jsx if you use that); using kioo completely resolves this issue. You can make an HTML mockup of your app, use it as kioo template, and serve it up; kioo will render right over it.
So anyway, the call to deftemplate
is given a name, a path to the template .html file,
an argument list (used when rendering the template), and a map of transforms. Let’s take a look at those:
[:tbody.travel-dates] (kioo/content (map #(date-row % travel-dates) travel-dates))
This replaces the content of tbody.travel-dates
with a list of nodes, created
by calling date-row
on each of the travel-dates
passed to the function. We’ll
talk about date-row
later.
[:.add-stay :a] (kioo/listen :on-click (fn [e]
(.preventDefault e)
(om/transact! travel-dates #(conj % {}))))
This adds a click handler to the link in .add-stay
. The handler updates travel-dates
by adding a new blank entry, using om’s transact!
function. As I mentioned before,
you must use om’s updating functions to manipulate the application state; doing it this
way ensures that everything is re-rendered properly.
You may have noticed that we used parameter unpacking to grab just the travel-dates
part of the global state map, and then called transact!
against that. This is possible
because om gives your functions special objects called “cursors”. Cursors are a
view into the global application state. They act like regular clojure data structures,
but can be used with om’s transaction functions, and will yield cursors when get
is
called on them (as it is during destructuring). There’s a big gotcha here: a cursor
is only returned if the returned data is a collection. Primitives will just be
plain old values that can’t be transacted against.
[:ul.results] (kioo/content (map result-item (re-entry-dates travel-dates)))
This just sets the contents of the ul.results
to a list of nodes, generated by
mapping result-item
over the calculated re-entry-dates. We’ll discuss all that
later.
[:.disclaimer] (if (every? #(>= (:days-left %) 0) (re-entry-dates travel-dates))
(kioo/do->
(kioo/set-class "disclaimer ok")
(kioo/content "This schedule is ok!"))
(kioo/do->
(kioo/set-class "disclaimer warn")
(kioo/content "This schedule violates Schengen Visa rules!")))
This checks to see if all of the re-entry dates are valid (i.e. allowed days left are > 0).
In either case, it sets a class on the .disclaimer
element and updates the message in it.
Moving along, we find another Kioo thing, called a “snippet”:
(kioo/defsnippet date-row "index.html" [:tr.date-row]
[{:keys [entry exit] :as rowdata} travel-dates]
{[:.duration] (kioo/content (str (duration rowdata) " days"))
[:input.entry] (kioo/do->
(kioo/set-attr :value (fmt-date-iso entry))
(bind-input kioo/listen om/transact! rowdata :entry))
[:input.exit] (kioo/do->
(kioo/set-attr :value (fmt-date-iso exit))
(bind-input kioo/listen om/transact! rowdata :exit))
[:a] (kioo/listen :on-click
(fn [e]
(.preventDefault e)
(om/transact! travel-dates
#(vec (filter (fn [x]
(and (not (= (:entry x) entry))
(not (= (:exit x) exit))))
%)))))})
(kioo/defsnippet result-item "index.html" [:li.stay-tpl]
[{:keys [days-left return-date from-date]}]
{[:.return-date] (kioo/content (fmt-date return-date))
[:.days-left] (kioo/content days-left)
[:.deadline] (kioo/content (fmt-date from-date))
})
Snippets are like templates, but accept an extra selector argument. This selector
limits the scope of the snippet. Notice that the first snippet uses a tr
,
and the other uses an li
. Snippets are especially good for situations where
you must repeat an element with different values, as we do. The snippet date-row
is responsible for rendering each of the input rows, and result-item
renders each
of the result items.
The date-row
snippet makes the following transformations to its input:
- Update the displayed duration of this stay
- Updates the entry and exit files with their current values, and uses the
bind-input
macro to observe them for changes, which are updated in the global state. - Adds a listener to the link, which removes the current row from the list of travel dates.
The result-item
snippet just updates some display values.
You’ll notice that the snippets were called from the main template as if they were regular functions. That is because they are regular functions, after the macro is done with them.
Next comes the minutae of actually calculating the dates:
(defn duration [{exit :exit entry :entry :as in}]
(-> (js/moment exit)
(.diff (js/moment entry))
(js/moment.duration)
(.asDays)
(inc)
))
(defn days-used [deadline upcoming-stays]
(reduce + 0 (for [{:keys [entry exit] :as upcoming} upcoming-stays]
(cond
(< deadline entry) 0
(< deadline exit) (duration {:entry entry :exit deadline})
:otherwise (duration upcoming)))))
(defn days-left [deadline upcoming-stays]
(- 90 (days-used deadline upcoming-stays)))
(defn calc-days-left-and-deadline [upcoming-stays date]
(let [deadline (.add (js/moment date) "days" 180)]
{:from-date date
:days-left (days-left deadline upcoming-stays)
:return-date deadline}))
(defn update-re-dates [upcoming-stays {exit :exit :as stay}]
(conj upcoming-stays
(merge stay (calc-days-left-and-deadline upcoming-stays exit))))
(defn re-entry-dates [travel-dates]
(let [initial (calc-days-left-and-deadline
travel-dates
(:entry (first travel-dates)))
other-dates (filter #(not (= (:days-left %) 90))
(reduce update-re-dates [] (reverse travel-dates)))]
(cond
(empty? travel-dates) []
(empty? other-dates) [initial]
:otherwise (apply conj [initial] (reverse other-dates)))))
I’ll leave deciphering this as an exercise for the reader, and please have a go at refactoring it if you like.
And, finally, for the sake of completeness, some helper functions that you may have seen in the previous code:
(defn store [k obj]
(.setItem js/localStorage k (js/JSON.stringify (clj->js obj))))
(defn keywordify [m]
(cond
(map? m) (into {} (for [[k v] m] [(keyword k) (keywordify v)]))
(coll? m) (vec (map keywordify m))
:else m))
(defn fetch [k default]
(let [item (.getItem js/localStorage k)]
(if item
(-> (.getItem js/localStorage k)
(or (js-obj))
(js/JSON.parse)
(js->clj)
(keywordify))
default)))
(defn fmt-date [d]
(.format (js/moment d) "MMMM Do, YYYY"))
(defn fmt-date-iso [d]
(.format (js/moment d) "YYYY-MM-DD"))
Also, the bind-input
macro:
(defmacro bind-input [listen-fn transact-fn cursor k]
`(~listen-fn :on-change
(fn [e#]
(~transact-fn ~cursor #(assoc % ~k (.-value (.-target e#)))))))
This just creates an on-change listener that updates the global state every time the value changes.
That’s it! I hope all this has been helpful. Again, you can find the code at the github repository.