DEV Community

Cover image for Capstone 3 - Contact Book
Andrew Meredith
Andrew Meredith

Posted on • Originally published at learn-clojurescript.com

Capstone 3 - Contact Book

This article is Lesson 20 of Andrew's book, Learn ClojureScript


Over the past few lessons, we have learned the core tools for working with data in ClojureScript. First, we learned about the basic collection types - lists, vectors, maps, and sets - as well as the most common functions for working with these collections. Then we took a closer look at the important sequence abstraction that allows us to operate on all sorts of sequential data using a uniform interface. Next, we discovered the reduce function and the many cases in which it can be used to summarize a sequence of data. Last, we walked through the process of modeling a real- world analytics domain. With this knowledge in our possession, we are ready to build another capstone project. This time, we will take the example of a contact book that we mentioned back in Lesson 16, and we will build a complete implementation, ClojureScript Contacts.


In This Lesson:

  • Create a complete ClojureScript application without any frameworks
  • Build HTML out of plain ClojureScript data structures

ClojureScript Contacts Screenshot

Screenshot of ClojureScript Contacts

Data Modeling

In this lesson, we will use the techniques and patterns from the previous chapter to model the data for our contact book. The goal will be to practice what we have learned rather than to introduce much new material. We will primarily model our data using maps and vectors, and we will implement the constructor function pattern for creating new contacts. We will also implement the operations that the UI will need to update the contact list using simple functions to transform our data. With that, let's dig in to the data model!

Constructing Entities

Since a contact book represents an ordered list of contacts, we will need a data structure to represent that ordered list, and as we have already seen, a vector fits the bill nicely. We can define an empty contact list as an empty vector - no constructor function necessary:

(def contact-list [])
Enter fullscreen mode Exit fullscreen mode

Since an empty vector is not terribly interesting, let's turn our attention to the contacts that it will hold. Each contact will need a first name, last name, email address, and a physical address, including city, state, and country. This can easily be accommodated with a nested map, such as the following:

{:first-name "Phillip"
 :last-name "Jordan"
 :email "phil.j@hotmail.com"
 :address {:street "523 Sunny Hills Cir."
           :city "Springfield"
           :state "MI"
           :postal "11111"
           :country "USA"}}
Enter fullscreen mode Exit fullscreen mode

In order to construct a new contact, we will use a variation on the constructor pattern introduced in the last lesson. Instead of passing in each field individually, we will pass in a map that we expect to contain zero or more of the fields that make up a contact. You will recall from the last lesson that the select-keys function takes a map and a collection of keys that should be selected, and it returns a new map with only the selected keys copied. We can use this function to sanitize the input, ensuring that our contact contains only valid keys.

(defn make-contact [contact]
  (select-keys contact [:first-name :last-name :email :postal :address]))
Enter fullscreen mode Exit fullscreen mode

Since the address itself is a map, let's factor out creation of an address to another function. We can then update the make-contact function to use this address constructor:

(defn make-address [address]
  (select-keys address [:street :city :state :country]))

(defn make-contact [contact]
  (let [clean-contact (select-keys contact [:first-name :last-name :email])]
    (if-let [address (:address contact)]
      (assoc clean-contact :address (make-address address))
      clean-contact)))
Enter fullscreen mode Exit fullscreen mode

This new version of make-contact introduces one expression that we have not seen before: if-let. This macro works just like if except that it binds a name to the value being tested (just like let does). Unlike let, only a single binding may be provides. At compile time, this code will expand to something like the following1:

(if (:address contact)
  (let [address (:address contact)]
    (assoc clean-contact :address (make-address address)))
  clean-contact)
Enter fullscreen mode Exit fullscreen mode

if-let Transformation

However, we can make the make-contact function a bit more concise and easier to read using one of ClojureScript's threading macros, -> (pronounced "thread first"). This macro allows us to take what would otherwise be a deeply nested expression and write it more sequentially. It takes a value and any number of function calls and injects the value as the first argument to each of these function calls. Seeing this transformation in action should make its functionality more intuitive:

(-> val                                                    ;; <1>
    (fn-1 :foo)                                            ;; <2>
    (fn-2 :bar :baz)                                       ;; <3>
    (fn-3))

(fn-3                                                      ;; <4>
  (fn-2
    (fn-1 val :foo)
    :bar :baz))
Enter fullscreen mode Exit fullscreen mode

Thread-First Transformation

  1. Start with val as the value to thread through the following expressions
  2. fn-1 will be evaluated with the arguments, val and :foo
  3. The result of the evaluation of fn-1 will be threaded as the first argument to fn-2
  4. The macro will rewrite into a nested expression that evaluates fn-1 then fn-2, then fn-3

This macro is extremely common in ClojureScript code because of how it enhances the readability of our code. We can write code that looks sequential but is evaluated "inside-out". There are several additional threading macros in ClojureScript that we will not go into now, but we will explain their usage as we run into them.

Thread First Macro

Thread First Macro

With this macro, we can make our make-contact function even clearer:

(defn maybe-set-address [contact]                          ;; <1>
  (if (:address contact)
    (update contact :address make-address)
    contact))

(defn make-contact [contact]
  (-> contact                                              ;; <2>
      (select-keys [:first-name :last-name :email])
      (maybe-set-address)))
Enter fullscreen mode Exit fullscreen mode
  1. Refactor the code that conditionally constructs an address
  2. Rewrite make-contact using the -> macro

Quick Review

  • Does if-let allow multiple bindings? For example, what would this code do?
(if-let [contact (find-by-id 123)
         address (:address contact)]
  (println "Address:" (format-address address)))
Enter fullscreen mode Exit fullscreen mode
  • How would the -> macro rewrite the following expression?
(let [input {:password "s3cr3t"}]
  (-> input
      (assoc :password-digest (-> input :password digest))
      (dissoc :password)))
Enter fullscreen mode Exit fullscreen mode

Defining State Transitions

In order for our UI to do anything other than display a static list of contacts that we define in code, we need to enable some interactions in the UI. Again, we are building our low-level domain logic before any UI code so that we can take advantage of the bottom-up programming style that ClojureScript encourages - composing small, granular functions into larger and more useful structures.

First, we want the user to be able to add a new contact to the contact list. We can assume that we will receive some sort of form data as input, which we can pass to our make-contact constructor, adding the resulting contact to our list. We will need to pass in the contact list and input data as arguments and produce a new contact list.

(defn add-contact [contact-list input]
  (conj contact-list
        (make-contact input)))
Enter fullscreen mode Exit fullscreen mode

We can paste these function definitions into the REPL and then test them to make sure that they function as expected:

cljs.user=> (-> contact-list                               ;; <1>
                (add-contact {:email "me@example.com"
                              :first-name "Me"
                              :address {:state "RI"}})
                (add-contact {:email "you@example.com"
                              :first-name "You"}))
[{:first-name "Me", :email "me@example.com"}
 {:first-name "You", :email "you@example.com"}]
Enter fullscreen mode Exit fullscreen mode

Testing with the REPL

  1. Once again, the -> macro makes our code easier to read and write

Next, we will need a way to remove a contact from the list. Since we are using a vector to hold our contacts, we can simply remove an element at a specific index:

(defn remove-contact [contact-list idx]
  (vec                                                     ;; <1>
    (concat                                                ;; <2>
      (subvec contact-list 0 idx)                          ;; <3>
      (subvec contact-list (inc idx)))))
Enter fullscreen mode Exit fullscreen mode

Removing a Contact

  1. vec converts a sequence into a vector
  2. concat returns a seq that contains all elements in the sequences passed to it in order
  3. subvec returns a portion of the vector that it is given

Since there are a couple of new functions here that we have not seen yet, let's quickly look at what they do. Starting from the "inside" of this function, we have subvec. This function provides an efficient way to obtain a slice of some vector. It comes in a 2-arity and a 3-arity form: (subvec v start) and (subvec v start end). This function work similarly to JavaScript's Array.prototype.slice() function. It returns a new vector that starts at the start index of the original vector and contains all elements up to but not including the end index. If no end is provided, it will contain everything from start to the end of the original vector.

Next, we have concat. This function takes a number of sequences and creates a new lazy2 seq that is the concatenation of all of the elements of its arguments. Because the result is a seq, we use the vec function to coerce the result back into a vector. Since much of ClojureScript's standard library operates on the sequence abstraction, we will find that we often need to convert the result back into a more specific type of collection.

Quick Review

  • We mentioned that vec converts a sequence into a vector. Given what we learned about the sequence abstraction, what will happen if you pass vec a map? What about a set?

Creating the UI

Now that we have defined all of the functions that we need to work with our data model,let's turn our attention to creating the application UI. In Section 5, we will learn how tocreate high-performance UIs using the Reagent framework, but for now, we will take naive approach ofre-rendering the entire application whenever anything changes. Our application will have twomain sections - a list of contacts that displays summary details about each contact and a largerpane for viewing/editing contact details.

We will use the hiccups library to transform plain ClojureScript data structures into an HTML string. This allows us to represent the interface of our application as a ClojureScript data structure and have a very simple interface to the actual DOM of the page. In order to use this library, we need to add it to our dependencies in project.clj:

:dependencies [;; ...Other dependencies
               [hiccups "0.3.0"]]
Enter fullscreen mode Exit fullscreen mode

project.clj

Then, we need to import this library into our core namespace. Note that since we are using a macro from this library, the syntax is a little different:

(ns contacts.core
  (:require-macros [hiccups.core :as hiccups])
  (:require [hiccups.runtime]))
Enter fullscreen mode Exit fullscreen mode

core.cljs

The translation between ClojureScript data structures and HTML is very simple:

  1. HTML tags are represented by vectors whose first element is the tag name as a keyword
  2. Attributes are represented by maps and should come immediately after the tag name. Omitting attributes is fine.
  3. Any remaining elements in the vector are children of the outer element

For example, the following code renders a div containing a single anchor tag:

(hiccups/html                                              ;; <1>
  [:div                                                    ;; <2>
    [:a {:href "https://www.google.com"                    ;; <3>
         :class "external-link"}
        "Google"]])                                        ;; <4>
;; <div><a class="external-link" href="https://www.google.com">Google</a></div>
Enter fullscreen mode Exit fullscreen mode

Rendering With Hiccups

  1. The html macro renders hiccups data to an HTML string
  2. Create a div. We do not need to specify any attributes
  3. Create an a element with an attributes map as a child of the outer div
  4. The a only contains text

With this knowledge, we can start defining components for our various UI elements that produce a hiccups-compatible data structure. We can compose these functions together to create a data structure that represents the entire UI, and we'll pass this to another function that renders the whole structure to the page.

UI State

Let's take a quick step back and think about the additional state that we need for the UI. First, we need to have a flag indicating whether we are in editing mode. In editing mode, a form with contact details will be displayed in the right-hand pane. Also, we need to keep track of which contact has been selected for editing - in the case of a new contact that has not been saved yet, this property will be nil or omitted. Naturally, we also need the contacts on the application state. This leaves us with a pretty simple state model to support everything that we want in our UI:

(def initial-state
  {:contacts contact-list
   :selected nil
   :editing? false})
Enter fullscreen mode Exit fullscreen mode

We will also define a refresh! function that is responsible for rendering the entire application and attaching event handlers every time our state changes:

(defn attach-event-handlers! [state])                      ;; <1>

(defn set-app-html! [html-str]
  (set! (.-innerHTML app-container) html-str))

(defn render-app! [state]
  (set-app-html!
    (hiccups/html
      [:div])))                                            ;; <2>

(defn refresh! [state]                                     ;; <3>
  (render-app! state)
  (attach-event-handlers! state))

(refresh! initial-state)                                   ;; <4>
Enter fullscreen mode Exit fullscreen mode
  1. All event handlers that we need to attach will be attached in this function
  2. We will replace the empty div with our actual application HTML
  3. refresh! will be called every time that we update the application state
  4. We kick off the application with an initial refresh! to render the page from initial-state

Rendering Contacts

Let's start with the component that displays the contact summary in the list. We will be using the Bulma CSS framework to help with styling, so most of the markup that we are generating is for the purpose of styling the page.

(defn format-name [contact]                                ;; <1>
  (->> contact                                             ;; <2>
       ((juxt :first-name :last-name))                     ;; <3>
       (str/join " ")))

(defn delete-icon [idx]
  [:span {:class "delete-icon"
          :data-idx idx}
    [:span {:class "mu mu-delete"}]])

(defn render-contact-list-item [idx contact selected?]
  [:div {:class (str "card contact-summary" (when selected? " selected"))
         :data-idx idx}                                    ;; <4>
    [:div {:class "card-content"}
      [:div {:class "level"}
        [:div {:class "level-left"}
          [:div {:class "level-item"}
            (delete-icon idx)
            (format-name contact)]]
        [:div {:class "level-right"}
          [:span {:class "mu mu-right"}]]]]])
Enter fullscreen mode Exit fullscreen mode

Rendering a Contact Summary

  1. Extract the logic for a contact's display name into another function
  2. Use the ->> (thread last) macro to pass a value through as the last argument to each subsequent function
  3. The juxt function extracts the first and last name from the contact. It is described below
  4. idx is needed so that we will be able to get the correct contact in our event handlers

Both of the things that we should note about this code occur in the format-name function. First, we use the ->> macro to thread a value through several function calls. This works almost exactly like the -> macro that we used earlier in this lesson with the exception that it feeds threads the value as the last argument to each subsequent function.

Second, the juxt function is a very interesting function that deserves a bit of an explanation. juxt takes a variable number of functions and returns a new function. This resulting function will call each of the original functions passed to juxt in turn using the arguments provided to it. The results are finally placed into a vector in the same order as the functions that were passed to juxt. For example, this code snippet will create a function that will get 2-element vector containing the minimum and maximum values from a collection:

(def minmax
  (juxt #(reduce Math/min %)
        #(reduce Math/max %)))

(minmax [48 393 12 14 -2 207])
;; [-2 393]
Enter fullscreen mode Exit fullscreen mode

The reason for the extra set of parentheses in the call to juxt - ((juxt :first-name :last-name)) - is that we need to call the function returned by juxt rather than threading the contact into the call to juxt itself. Since keywords are functions that can look themselves up in maps, this expression will effectively create a vector with the first name and last name of the contact respectively.

((juxt :first-name :last-name) {:first-name "Bob" :last-name "Jones"})
;; ["Bob" "Jones"]
Enter fullscreen mode Exit fullscreen mode

Adding Interactions

Let's now think about the interactions that we want to enable on the contact summary. When the row is clicked, we want to open up this contact's details for view/editing. We will attach an event handler to each list item that will set an :editing? flag on the app state and will set :selected to the index of the contact that was clicked (this is why we needed to pass idx into the rendering function and set the data-idx attribute on the row).

(defn on-open-contact [e state]
  (refresh!
    (let [idx (int (aget e "currentTarget" "dataset" "idx"))]
      (assoc state :selected idx
                   :editing? true))))

(defn attach-event-handlers! [state]
  ;; ...
  (doseq [el (array-seq (gdom/getElementsByClass "contact-summary"))]
    (gevents/listen el "click"
      (fn [e] (on-open-contact e state)))))
Enter fullscreen mode Exit fullscreen mode

Now that we can render a single contact list item, let's create the list that will display each list item:

(defn render-contact-list [state]
  (let [contacts (:contacts state)
        selected (:selected state)]
    [:div {:class "contact-list column is-4 hero is-fullheight"}
      (map-indexed (fn [idx contact]
                     (render-contact-list-item idx contact (= idx selected)))
                   contacts)]))
Enter fullscreen mode Exit fullscreen mode

This function is pretty straightforward: it renders a wrapper div with each contact list item, then it delegates to the render-contact-list-item function to render the summary for each contact in turn. There is a new function that we have not yet met, however: map-indexed. This function works just like map except that it calls the mapping function with the index of the element in the sequence as well as the element itself.

This pattern of building a UI by composing components is common in the JavaScript world aswell, but in most ClojureScript applications, the method of composing UIs is functioncomposition with pure functions that produce plain data structures.

Finally, we will briefly cover rendering the contact details form and adding/updating contacts without too much additional explanation. First, let's look at the function that renders the entire app HTML:

(defn render-app! [state]
  (set-app-html!
    (hiccups/html
      [:div {:class "app-main"}
        [:div {:class "navbar has-shadow"}
          [:div {:class "container"}
            [:div {:class "navbar-brand"}
              [:span {:class "navbar-item"}
                "ClojureScript Contacts"]]]]
        [:div {:class "columns"}
          (render-contact-list state)
          [:div {:class "contact-details column is-8"}
            (page-header (:editing? state))
            [:div {:class "hero is-fullheight"}
              (if (:editing? state)
                (render-contact-details (get-in state [:contacts (:selected state)] {}))
                [:p {:class "notice"} "No contact selected"])]]]])))
Enter fullscreen mode Exit fullscreen mode

We have already seen the render-contact-list function, but we still need to define page-header, which will display the buttons for adding or saving a contact, and render-contact-details, which will render the edit form. Let's start with render-contact-details:

(defn form-field                                           ;; <1>
  ([id value label] (form-field id value label "text"))
  ([id value label type]
   [:div {:class "field"}
     [:label {:class "label"} label]
     [:div {:class "control"}
       [:input {:id id
                :value value
                :type type
                :class "input"}]]]))

(defn render-contact-details [contact]
  (let [address (get contact :address {})]                 ;; <2>
    [:div {:id "contact-form" :class "contact-form"}
      (form-field "input-first-name" (:first-name contact) "First Name")
      (form-field "input-last-name" (:last-name contact) "Last Name")
      (form-field "input-email" (:email contact) "Email" "email")
      [:fieldset
        [:legend "Address"]
        (form-field "input-street" (:street address) "Street")
        (form-field "input-city" (:city address) "City")
        (form-field "input-state" (:state address) "State")
        (form-field "input-postal" (:postal address) "Postal Code")
        (form-field "input-country" (:country address) "Country")]]))
Enter fullscreen mode Exit fullscreen mode

Rendering Contact Details

  1. Since we will be rendering multiple form fields, we will extract the code for a field into its own function
  2. The address may or may not be present, so we provide an empty map as a default

Now let's look at the code for processing the form and either adding or updating a contact:

(defn get-field-value [id]
  (let [value (.-value (gdom/getElement id))]
    (when (not (empty? value)) value)))

(defn get-contact-form-data []
  {:first-name (get-field-value "input-first-name")
   :last-name (get-field-value "input-last-name")
   :email (get-field-value "input-email")
   :address {:street (get-field-value "input-street")
             :city (get-field-value "input-city")
             :state (get-field-value "input-state")
             :postal (get-field-value "input-postal")
             :country (get-field-value "input-country")}})

(defn on-save-contact [state]
  (refresh!
    (let [contact (get-contact-form-data)
          idx (:selected state)
          state (dissoc state :selected :editing?)]        ;; <1>
      (if idx
        (update state :contacts                            ;; <2>
                replace-contact idx contact)
        (update state :contacts
                add-contact contact)))))
Enter fullscreen mode Exit fullscreen mode
  1. state within the let will refer to this updated state
  2. Use our domain functions to update the contact list within our app state

Before moving on, let's take a look at the use of update to transform our application state. update takes an indexed collection (a map or vector), a key to update, and a transformation function. This function is variadic, and any additional arguments after the transformation function that will be passed to the transformation function following the value to transform. For instance, the call, (update state :contacts replace-contact idx contact), will call replace-contact with the contacts list followed by idx and contact.

Now, we will finally implement the page header with its actions to create and save contacts:

(defn action-button [id text icon-class]
  [:button {:id id
            :class "button is-primary is-light"}
    [:span {:class (str "mu " icon-class)}]
    (str " " text)])

(def save-button (action-button "save-contact" "Save" "mu-file"))
(def cancel-button (action-button "cancel-edit" "Cancel" "mu-cancel"))
(def add-button (action-button "add-contact" "Add" "mu-plus"))

(defn page-header [editing?]
  [:div {:class "page-header"}
    [:div {:class "level"}
      [:div {:class "level-left"}
        [:div {:class "level-item"}
          [:h1 {:class "subtitle"}
            [:span {:class "mu mu-user"}]
            "Edit Contact"]]]
      [:div {:class "level-right"}
        (if editing?
          [:div {:class "buttons"} cancel-button save-button]
          add-button)]]])

(defn on-add-contact [state]
  (refresh! (-> state
                (assoc :editing? true)
                (dissoc :selected))))

(defn on-save-contact [state]
  (refresh!
    (let [contact (get-contact-form-data)
          idx (:selected state)
          state (dissoc state :selected :editing?)]
      (if idx
        (update state :contacts replace-contact idx contact)
        (update state :contacts add-contact contact)))))

(defn on-cancel-edit [state]
  (refresh! (dissoc state :selected :editing?)))

(defn attach-event-handlers! [state]
  ;; ...
  (when-let [add-button (gdom/getElement "add-contact")]
    (gevents/listen add-button "click"
      (fn [_] (on-add-contact state))))

  (when-let [save-button (gdom/getElement "save-contact")]
    (gevents/listen save-button "click"
      (fn [_] (on-save-contact state))))

  (when-let [cancel-button (gdom/getElement "cancel-edit")]
    (gevents/listen cancel-button "click"
      (fn [_] (on-cancel-edit state))))))
Enter fullscreen mode Exit fullscreen mode

By now, this sort of code should be no problem to read and understand. In the interest of space, we will not reprint the entire application code, but it can be found at the book's GitHub project.

You Try It

  • Allow contacts to have phone numbers. Each phone number will need a :type and :number. This will require updates to the models, components, and event handlers.

Summary

While this application is not a shining example of modern web development (don't worry - we will get to that in Section 5), it showed us how to build a data-driven application using the techniques that we have been learning in this section. While this is not a large application, is is a non-trivial project that should have helped us get more familiar with transforming data using ClojureScript. Still, much of our ClojureScript code still looks a lot like vanilla JavaScript with a funky syntax. In the next section, we will start to dig in to writing more natural, idiomatic ClojureScript.


  1. The actual implementation of the if-let macro is slightly more complex, but the effect is the same. 

  2. Lazy evaluation was covered in Lesson 11. 

Top comments (0)