August 13, 2014

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.