Monday, October 21, 2013

Ganelon tutorial, part 2: widgets and actions.

This part of Ganelon tutorial shows how to connect Clojure project to MongoDB and then create dynamic widgets and actions that manage application data. You can see the complete application for this step in a GitHub repository: https://github.com/tlipski/ganelon-tutorial/tree/PART2_WIDGETS.

If you want to see the final result of an entire tutorial, you can view the tutorial app running live at http://ganelon-tutorial.tomeklipski.com or you can check out/star/fork the source code at http://github.com/tlipski/ganelon-tutorial.

For more information regarding Ganelon, you can visit the project website: http://ganelon.tomeklipski.com.

Connecting to MongoDB

Using mongodb is Clojure project is very easy and there's nothing Ganelon-specific about it.

First we will add dependency for congomongo library ([congomongo "0.4.1"]) to project.clj:

  :dependencies [[ganelon "0.9.0"]
                 [congomongo "0.4.1"]
                 [crypto-random "1.1.0"]]

There's also another new library referenced - crypto-random. It is a useful tool when it comes to generating secure random identifiers - something that will come in very handy when creating new meetups or invitations.

Having the MongoDB library in place, we can add connection setup to src/ganelon/tutorial.clj:

(ns ganelon.tutorial
  (:gen-class)
  (:require [ganelon.tutorial.pages.routes]
            [ring.middleware.stacktrace]
            [ring.middleware.reload]
            [ganelon.web.middleware :as middleware]
            [ganelon.web.app :as webapp]
            [noir.session :as sess]
            [somnium.congomongo :as db]))

(defn get-mongo-url []
  (or
    (get (System/getenv) "MONGOHQ_URL")
    (System/getProperty "MONGOHQ_URL")
    "mongodb://localhost/meetups"))

(defn initialize[]
  (db/set-connection! (db/make-connection (get-mongo-url))))

The get-mongo-url function returns mongo db url either from environment or from default development setup. In real-life scenario, the connection options will be much more complex, including username&password, failover options, etc.

It is also noteworthy, that we don't have to manage MongoDB connections ourselves - the underlying libraries are taking care of that for us.

The initialize function is referenced in lein-ring plugin configuration and will be invoked upon ring application initialization.

Accessing the data with MongoDB

To separate our application from underlying persistence layer, we will create seperate service namespaces for basic data integration. Fortunately, most of the work is done by congomongo library and we can use native Clojure data structures in all layers.

Data operations are defined in three namespaces:

All of functions in these namespace are quite simple and mostly cover congomongo (somnium.congomongo is imported as db) references, for example:

(defn retrieve-list [meetup-id]
  (db/fetch :meetup-times :where {:meetup-id meetup-id} 
    :sort {:date 1 :time 1}))

(defn add-time! [meetup-id date time]
  (when (empty? (db/fetch :meetup-times :where 
                  {:meetup-id meetup-id :date date :time time}))
   (db/insert! :meetup-times {:meetup-id meetup-id :date date 
                              :time time :create-time (java.util.Date.) 
                              :accepted []})))

Defining widgets

Finally, having all the other components in place, we are able to create some Ganelon-specific widgets and actions!

New meetup widget

The new meetup widget consists of two parts, listed below. Entire definition is contained in ganelon.tutorial.widgets.meetup-add namespace.

First of all, we have widget definition. As you can see below, it is a standard Clojure function, and it is invoked explicitly from Clojure code - ganelon.tutorial.pages.routes/meetup-layout to be exact.

(defn new-meetup-widget []
  (widgets/with-div
    [:h1 "New meetup"]
    [:p "Please provide meetup details:"]
    (widgets/action-form "meetup-create" {} {:class "form well"}
      [:div.control-group [:label.control-label {:for "inputTitle"} "Title"]
       [:div.controls
        [:input#inputTitle {:placeholder "Title for a meetup" :type "text"
                            :name "title"
                            :required "1"}]]]
      [:div.control-group [:label.control-label {:for "inputPlace"} "Place"]
       [:div.controls
        [:input#inputPlace {:placeholder "Place for a meetup" :type "text"
                            :name "place"
                            :required "1"}]]]
      [:div.control-group
       [:div.controls
        [:button.btn.btn-primary.btn-large {:type "submit"} "Create"]]])))

We use Hiccup to generate HTML form, styled by Bootstrap. One significant difference is that we don't use [:form] HTML tag, but rather ganelon.web.widgets/action-form function. This function allows us to reference a Ganelon action to be invoked upon form submission - client side.

Action meetup-create, creating new meetup is defined with a help of a ganelon.web.actions/defjsonaction macro.

(actions/defjsonaction "meetup-create" [title place]
  (let [id (meetup/create! title place)]
    [(ui-operations/open-page (str "/meetup/edit/" id))]))

The action itself is pretty straightforward:

  • First we create new meetup in MongoDB using form params - the destructuring syntax underneath is Compojure's, since we are using its handler infrastructure.
  • Then, we return one operation to be performed - to redirect the browser to meetup's edit page, containing its random and unique id.

In the next part of the tutorial, this action will change and will not reload entire page - and we will use custom JavaScript actions to achieve that.

Meetup details widget (and sub-widgets)

Meetup details widget is more complicated than the previous one, since it has to support not only edition of meetup place and title, but also:

Please observe, that the main meetup details widget is referencing sub-widgets as functions: meetup-edit-form-widget, meetup-times/meetup-times-widget and meetup-invitations/meetup-invitations-widget. This way, we can decompose our web application into a set of widgets with clearly defined dependencies.

(defn meetup-details-widget [meetup-id]
  (widgets/with-div
    (let [meetup (meetup/retrieve meetup-id)]
      [:div
        [:div {:style "padding-top: 20px;"}
          (meetup-edit-form-widget meetup)]
        (meetup-times/meetup-times-widget meetup-id)
        (meetup-invitations/meetup-invitations-widget meetup-id)])))

As we have more possibilities here than just meetup creation, we can make the form more dynamic. For example, we will capture :onchange JavaScript events for meetup title and place to update the fields in MongoDB without save button. Even more, we will display a confirmation label next to a saved field.

In meetup edition widget definition, we will add standard HTML attributes, referencing our Ganelon action (meetup-title-update) - but client-side! As Ganelon has information about defined actions, there is an interface available as a standard Compojure route (by default under /ganelon/actions.js), publishing all these functions to be referenced in JavaScript client-side:

(defn meetup-edit-form-widget [meetup]
  (widgets/with-div
    [:h1 "Meetup details"]
    (let [url (str (web-helpers/current-request-host-part)  "/meetup/edit/" 
                (:_id meetup))]
      [:p "Meetup admin url: " [:a {:href url } url]])
    [:form.form-horizontal.well
     [:div.control-group [:label.control-label {:for "inputTitle"} "Title"]
      [:div.controls [:input#inputTitle.input-xlarge 
                      {:placeholder "Title for a meetup" :type "text"
                       :value (:title meetup)
                       :onkeypress "$('#update-title-loader > *').fadeOut();"
                       :onchange (str "GanelonAction.meetup_title_update('" 
                                   (:_id meetup) 
                                   "', this.value);")
                       :name "title"
                       :required "1"}]
       [:span#update-title-loader]]]
     [:div.control-group [:label.control-label {:for "inputPlace"} "Place"]
      [:div.controls [:input#inputPlace.input-xlarge 
                      {:placeholder "Place for a meetup" :type "text"
                       :value (:place meetup)
                       :onkeypress "$('#update-place-loader > *').fadeOut();"
                       :onchange (str "GanelonAction.meetup_place_update('" 
                                   (:_id meetup) 
                                   "', this.value);")
                       :name "place"
                       :required "1"}]
       [:span#update-place-loader]]]]))

Once more, we are using standard Bootstrap 2 classes here to render a form.

Actions update meetup details in MongoDB, but also update form elements with jQuery's fade effect, provided by ganelon.web.ui-operations/fade function - for example:

(actions/defjsonaction "meetup-title-update" [id title]
  (meetup/update! id :title title)
  [(ui-operations/fade (str ".meetup-title-" id) 
     (hiccup.util/escape-html title))
   (ui-operations/fade "#update-title-loader"
    (hiccup.core/html [:span {:onmouseover "$(this).fadeOut();"}
                       " " [:i.icon-check] " Saved"]))])

You are welcome to browse the operations available in ganelon.web.ui-operations namespace - but please note, that it is also very easy to define your own, more fitting for you apps specific needs!

Meetup times widget allows us to add new invitations and to list and manage existing ones - including invitation confirmation status for each time and invitation. The whole implementation is available in ganelon.tutorial.widgets.meetup-times namespace.

(defn toggle-meetup-times-button [t inv editable-invitation-ids]
  (let [accepted? (some #{(:_id inv)} (:accepted t))]
    (widgets/with-div
      [:div {:style (str "padding: 8px; " (if accepted?
                                            "background-color: #5bb75b"
                                            "background-color: #9da0a4"))}
       (if (or (not editable-invitation-ids) (some #{(:_id inv)}
                                               editable-invitation-ids))
         (widgets/action-loader-link "meetup-times-toggle-invitation"
           {:invitation-id (:_id inv)
            :id (:_id t)
            :value (not accepted?)} {}
           (if accepted? [:i.icon-thumbs-up ] [:i.icon-thumbs-down ]))
         " ")])))

(defn meetup-times-list-widget [meetup-id editable-invitation-ids]
  (widgets/with-widget "meetup-times-list-widget"
    (let [times (meetup-time/retrieve-list meetup-id)]
      (if (not-empty times)
        (let [invitations (invitation/retrieve-list meetup-id)]
          [:table.table.table-striped.table-hover {:style "width: initial"}
           [:thead [:tr
                    (when-not editable-invitation-ids [:th ])
                    [:th "Date"] [:th "Time"]
                    (for [inv invitations]
                      [:th [:small
                            (hiccup.util/escape-html (:name inv))]])]]
           (for [t times]
             [:tr (when-not editable-invitation-ids
                    [:td (widgets/action-link "meetup-remove-time"
                           {:id (:_id t)} {} [:i.icon-remove ])])
              [:td (:date t)] [:td (:time t)]
              (for [inv invitations]
                [:td {:style "text-align: center; padding:0px;
                               border-left: 1px solid #ccc"}
                 (toggle-meetup-times-button t
                   inv editable-invitation-ids)])])])
      [:div.alert [:i "No meetup times defined yet!"]]))))

Please observe, that we are using widgets in loop here - as ganelon.web.widgets/with-div macro binds widget each time to a unique and random value, we can tons of instances of the same widget side by side.

We can also define a function which will return Ganelon UI operation refreshing times list - to be invoked externally (e.g. from invitations list widget):

(defn refresh-meetup-times-list-widget-operations [meetup-id]
  (ui-operations/fade "#meetup-times-list-widget"
    (meetup-times-list-widget meetup-id nil)))

This operation references widget by id attribute, allowing us to invoke it without widget-id context set.

The meetup-times-toggle-invitation action takes advantage of Compojure's route params support, allowing us to access them by name directly:

(actions/defwidgetaction "meetup-times-toggle-invitation" 
  [id invitation-id value]
  (if (boolean (Boolean. value))
    (meetup-time/accept-time! id invitation-id)
    (meetup-time/reject-time! id invitation-id))
  (toggle-meetup-times-button (meetup-time/retrieve id) 
     (invitation/retrieve invitation-id) [invitation-id]))

meetup-add-time action itself provides simple validation mechanism:

(actions/defjsonaction "meetup-add-time" [id date time]
  (if-let [new-mu (meetup-time/add-time! id date time)]
    ;success
     [(ui-operations/make-empty "#meetup-add-time-message")
      (ui-operations/fade "#meetup-times-list-widget"
        (meetup-times-list-widget id nil))]
    ;error - such time already exists
     (ui-operations/fade "#meetup-add-time-message"
        (hiccup.core/html
          [:div.alert
           [:button.close {:type "button" :data-dismiss "alert"} "×"]
           [:p "Such date & time combination already exists!" ]]))))

ganelon.web.ui-operations/make-empty operation simply removes all contents from designated dom element.

Meetup invitations list widget works on similar principle as meetup times widget listed above:

(defn meetup-invitations-widget [meetup-id]
  (widgets/with-widget "meetup-invitations-widget"
    [:h2 "Meeting invitations"]
    (widgets/action-form "invitation-create"
      {:meetup-id meetup-id}
      {:class "form-horizontal well"}
      [:span "Recipient's name:"] " "
      [:input {:type "text" :name "name" :required "1"}] " "
      [:button.btn.btn-primary "Create new invitation"]
      )
    (let [invitations (invitation/retrieve-list meetup-id)]
      (if (empty? invitations)
        [:div.alert 
         "No invitations created yet. Please use the form above to add some!"]
        (for [inv invitations]
          [:p
           [:b (hiccup.util/escape-html (:name inv))] [:br]
           "Link: " [:a {:href 
                         (str (web-helpers/current-request-host-part) 
                           "/i/" (:_id inv))}
                        (str (web-helpers/current-request-host-part) 
                          "/i/" (:_id inv))]
           (widgets/action-link "invitation-cancel"
             {:meetup-id meetup-id :id (:_id inv)}
             {:class "pull-right"}
             [:i.icon-remove] " Cancel")])))))

(actions/defwidgetaction "invitation-create" [name meetup-id]
  (invitation/create! meetup-id name)
  (actions/put-operation!
    (meetup-times/refresh-meetup-times-list-widget-operations 
      meetup-id))
  (meetup-invitations-widget meetup-id))

(actions/defwidgetaction "invitation-cancel" [meetup-id id]
  (invitation/delete! id)
  (actions/put-operation!
    (meetup-times/refresh-meetup-times-list-widget-operations 
      meetup-id))
  (meetup-invitations-widget meetup-id))

One thing worth noticing is that we put operation refreshing meetup times widget upon invitation creation or deletion. Instead of accessing widget functions for meetup times directly, we call a wrapper function which encapsulates all the necessary logic - e.g. DIV identification and returns ready to use Ganelon UI operation.

Invitation widget

Invitation widget is used by meetup attendees and allows them only to confirm or reject their presence at the meetup at given time. Whole implementation is provided withing ganelon.tutorial.widgets.invitation-details namespace.

(defn invitation-details-widget [id]
  (widgets/with-div
    (if-let [inv (invitation/retrieve id)]
      (let [meetup (meetup/retrieve (:meetup-id inv))]
        [:div
         [:div [:h2 (hiccup.util/escape-html (:title meetup))]
         [:p "Located at: " [:b (hiccup.util/escape-html (:place meetup))]]
         [:p "Sent to: " [:b (hiccup.util/escape-html (:name inv))]]]
         [:h2 "Confirm your presence"]
         [:p "Please mark times & dates that are best for you:"]
         (meetup-times/meetup-times-list-widget (:meetup-id inv) [id])])
      [:div.alert.alert-error [:h2 "Invitation not found"]
       [:p "Invitation with a supplied id has not been found.
            Please make sure that the link is correct."]])))

Please note, that we are re-using meetup-times/meetup-times-list-widget to provide meetup times and to allow the attendee to confirm or negate his/her presence at the meetup. We do so by providing additional parameter to function invocation, limiting displayed invitations only to the currently used.

Summary

In this blog post we have learned, that it is very easy to build dynamic, AJAX-oriented web application entirely in Clojure, server side. Even better, such approach allows us to re-use existing components in different scenarios. We don't have to stick with our application being single-page and we are totally 100% SEO friendly in such approach, as widgets are used both in standard HTML page rendering as in updates to the page itself.

In the next post I will show how to add middleware for security handling and how to define custom Ganelon UI operations in JavaScript.