TodoMVC implemented in Keechma


(this space intentionally left almost blank)
(ns keechma-todomvc.components.todo-list
  (:require (keechma.ui-component :as ui)))

Renders the list of todos. This component gets the list of todos from the :todos subscription and the current editing id from the :editing-id subscription.

Each todo item is rendered by the :todo-item component which receives the todo entity and is-editing? (based on the todo entity id and the current editing id)

(defn render
  (fn []
    (let [todos-sub (ui/subscription ctx :todos)
          todo-item-component (ui/component ctx :todo-item)
          editing-id-sub (ui/subscription ctx :editing-id)
          editing-id @editing-id-sub]
       (for [todo @todos-sub]
         ^{:key (:id todo)}
         [(ui/component ctx :todo-item) todo (= (:id todo) editing-id)])])))
(def component (ui/constructor {:subscription-deps [:todos :editing-id]
                                :component-deps [:todo-item]
                                :renderer render}))
  (:require [keechma.ui-component :as ui]))

Main app component. Renders all the other components.

Depends on the :todos-by-status subscription which returns the list of todos for a status. This is used to check if there are any todos in the EntityDB.

This component depends on :new-todo, :todo-list, :footer and :toggle-todos components. Each of these components has it's own context passed in.

(defn render
  (fn []
    (let [todos-sub (ui/subscription ctx :todos-by-status [:all])
          has-todos? (pos? (count @todos-sub))]
        [:h1 "todos"]
        [(ui/component ctx :new-todo)]]
       (when has-todos? 
          [(ui/component ctx :toggle-todos)]
          [(ui/component ctx :todo-list)]])
       (when has-todos? [(ui/component ctx :footer)])])))
(def component
   {:renderer render
    :component-deps [:new-todo :todo-list :footer :toggle-todos]
    :subscription-deps [:todos-by-status]}))
  (:require [keechma.ui-component :as ui]
            [reagent.core :refer [atom]]
            [keechma-todomvc.util :refer [is-enter?]]))
(defn handle-key-down [ctx e new-todo]
  (when (is-enter? (.-keyCode e))
      (ui/send-command ctx :create-todo @new-todo)
      (reset! new-todo ))))

Renders the input field for the new todo. Stores the todo value inside the local atom, and when the user presses enter sends the command to creat todo.

Todo is created by the todos controller.

(defn render
  (let [new-todo (atom "")]
    (fn [] 
       {:placeholder "What needs to be done?"
        :value @new-todo
        :autofocus true
        :on-key-down #(handle-key-down ctx % new-todo)
        :on-change #(reset! new-todo (.. % -target -value))}])))
(def component (ui/constructor {:renderer render}))
(ns keechma-todomvc.components.footer
  (:require [keechma.ui-component :as ui]))
(defn items-label [count]
  (if (= count 1) "item" "items"))

Footer component. Renders the current item count, filter buttons and "Clear completed" button.

Depends on the :todos-by-status subscription which returns the list of todos for a status. It gets the list of :completed and :active todos from the :todos-by-status subscription.

Footer component reads the current status from the current route data to determine which link should have the selected class added.

(defn render
  (fn []
    (let [current-status (get-in @(ui/current-route ctx) [:data :status])
          completed-sub (ui/subscription ctx :todos-by-status [:completed])
          active-sub (ui/subscription ctx :todos-by-status [:active])
          active @active-sub
          active-class #(when (= % current-status) "selected")
          active-count (count active)]
        [:strong active-count] (str " " (items-label active-count) " left")]
        [:li>a {:href (ui/url ctx {:status "all"})
                :class (active-class "all")} "All"]
        [:li>a {:href (ui/url ctx {:status "active"})
                :class (active-class "active")} "Active"]
        [:li>a {:href (ui/url ctx {:status "completed"})
                :class (active-class "completed")} "Completed"]]
       (when (pos? (count @completed-sub))
          {:on-click #(ui/send-command ctx :destroy-completed)}
          "Clear completed"])])))
(def component (ui/constructor {:renderer render
                                :subscription-deps [:todos-by-status]}))
(ns keechma-todomvc.components.toggle-todos
  (:require [keechma.ui-component :as ui]))

Renders the checkbox component which toggles the status of all components.

(defn render
  (fn []
    (let [active-sub (ui/subscription ctx :todos-by-status [:active])]
       {:type "checkbox"
        :on-change #(ui/send-command ctx :toggle-all (.. % -target -checked))
        :checked (= 0 (count @active-sub))}])))
(def component (ui/constructor {:renderer render
                                :subscription-deps [:todos-by-status]}))
(ns keechma-todomvc.components.todo-input
  (:require [keechma.ui-component :as ui]
            [reagent.core :as reagent :refer [atom]]
            [keechma-todomvc.util :refer [is-enter? is-esc?]]))

Focuses the input element.

(defn focus-input
  (let [node (reagent/dom-node x)
        length (count (.-value node))]
    (.focus node)
    (.setSelectionRange node length length)))

Called on each key down.

  • on enter key it will update the todo
  • on esc key it will remove the edit input field
(defn update-or-cancel
  [update cancel e]
  (let [key-code (.-keyCode e)]
    (when (is-enter? key-code) (update))
    (when (is-esc? key-code) (cancel))))

Renders the input element for todo editing. Input field has the following event bindings:

  • on blur - update the todo
  • on change - store the current value in the todo-title atom
  • on key down - call update-or-cancel function which will update the todo or remove the input element
(defn render
  [ctx todo-sub todo-title]
  (let [todo @todo-sub
        update #(ui/send-command ctx :update-todo (assoc todo :title @todo-title))
        cancel #(ui/send-command ctx :cancel-edit-todo)
        handle-key-down (partial update-or-cancel update cancel)]
    [:input.edit {:value @todo-title 
                  :on-blur update
                  :on-change #(reset! todo-title (.. % -target -value))
                  :on-key-down handle-key-down}]))

Create the component using the Form-3 way.

We have to do it in this way to be able to add a :component-did-mount lifecycle function which will focus the input field when the component is mounted.

(defn make-renderer
  (let [todo-sub (ui/subscription ctx :editing-todo)
        todo-title (atom (:title @todo-sub))]
     {:reagent-render (partial render ctx todo-sub todo-title) 
      :component-did-mount focus-input})))
(def component {:renderer make-renderer
                :subscription-deps [:editing-todo]})
(ns keechma-todomvc.components.todo-item
  (:require [keechma.ui-component :as ui]))

Helper function that returns the li element clasess based on is-editing? and completed? arguments.

(defn classes
  [is-editing? completed?]
  (clojure.string/join " " (remove nil? [(when is-editing? "editing")
                                         (when completed? "completed")])))

Renders on todo item. If this item is currently being edited, renders the edit input element.

(defn render
  [ctx todo is-editing?]
  [:li {:class (classes is-editing? (:completed todo))}
   [:div.view {:on-double-click #(ui/send-command ctx :edit-todo todo)}
    [:input.toggle {:type "checkbox"
                    :checked (:completed todo)
                    :on-change #(ui/send-command ctx :toggle-todo todo)}]
    [:label (:title todo)]
    [:button.destroy {:on-click #(ui/send-command ctx :destroy-todo todo)}]]
   (when is-editing?
     [(ui/component ctx :todo-input)])])
(def component (ui/constructor {:renderer render
                                :component-deps [:todo-input]}))
(ns keechma-todomvc.util)
(defn is-enter? [key-code]
  (= key-code 13))
(defn is-esc? [key-code]
  (= key-code 27))
(ns keechma-todomvc.edb
  (:require [keechma.edb :as edb]))
(def dbal (edb/make-dbal {:todos {:id :id}}))
(defn wrap-entity-db-get [dbal-fn]
  (fn [db & rest]
    (let [entity-db (:entity-db db)]
      (apply dbal-fn (concat [entity-db] rest)))))
(defn wrap-entity-db-mutate [dbal-fn]
  (fn [db & rest]
    (let [entity-db (:entity-db db)
          resulting-entity-db (apply dbal-fn (concat [entity-db] rest))]
      (assoc db :entity-db resulting-entity-db))))
(def insert-item (wrap-entity-db-mutate (:insert-item dbal)))
(def insert-named-item (wrap-entity-db-mutate (:insert-named-item dbal)))
(def insert-collection (wrap-entity-db-mutate (:insert-collection dbal)))
(def insert-meta (wrap-entity-db-mutate (:insert-meta dbal)))
(def append-collection (wrap-entity-db-mutate (:append-collection dbal)))
(def prepend-collection (wrap-entity-db-mutate (:prepend-collection dbal)))
(def remove-item (wrap-entity-db-mutate (:remove-item dbal)))
(def remove-named-item (wrap-entity-db-mutate (:remove-named-item dbal)))
(def remove-collection (wrap-entity-db-mutate (:remove-collection dbal)))
(def remove-meta (wrap-entity-db-mutate (:remove-meta dbal)))
(def get-item-by-id (wrap-entity-db-get (:get-item-by-id dbal)))
(def get-named-item (wrap-entity-db-get (:get-named-item dbal)))
(def get-collection (wrap-entity-db-get (:get-collection dbal)))
(def get-item-meta (wrap-entity-db-get (:get-item-meta dbal)))
(def get-named-item-meta (wrap-entity-db-get (:get-named-item-meta dbal)))
(def get-collection-meta (wrap-entity-db-get (:get-collection-meta dbal)))
(def vacuum (wrap-entity-db-mutate (:vacuum dbal)))
(defn update-item-by-id [db entity-kw id data]
  (let [item (get-item-by-id db entity-kw id)]
    (insert-item db entity-kw (merge item data))))
(defn collection-empty? [collection]
  (let [collection-meta (meta collection)]
    (and (= (:state collection-meta) :completed)
         (= (count collection) 0))))
(ns keechma-todomvc.core
  (:require [ :as app-state]
            [keechma-todomvc.controllers.todos :as todos]
            [keechma-todomvc.components :as components]
            [keechma-todomvc.subscriptions :as subscriptions]))

Defines the application.

(def app-definition
  {:routes [[":status" {:status "all"}]]
   :controllers {:todos (todos/->Controller)}
   :components components/system
   :subscriptions subscriptions/subscriptions
   :html-element (.getElementById js/document "app")})
(defonce running-app (clojure.core/atom))

Helper function that starts the application.

(defn start-app!
  (reset! running-app (app-state/start! app-definition)))

Helper function that restarts the application whenever the code is hot reloaded.

(defn restart-app!
  (let [current @running-app]
    (if current
      (app-state/stop! current start-app!)
(defn on-js-reload []
  ;; optionally touch your app-state to force rerendering depending on
  ;; your application
  ;; (swap! app-state update-in [:__figwheel_counter] inc))
(ns keechma-todomvc.subscriptions
  (:require [keechma-todomvc.edb :as edb]
            [keechma-todomvc.entities.todo :as todo])
  (:require-macros [reagent.ratom :refer [reaction]]))

Based on the current route, returns the list of todos.

(defn todos
   (let [db @app-db
         current-status (keyword (get-in db [:route :data :status]))]
     (todo/todos-by-status @app-db current-status))))

Returns the id of the todo that is being edited.

(defn editing-id
   (get-in @app-db [:kv :editing-id])))

Returns the todo that is being edited.

(defn editing-todo
   (let [db @app-db
         editing-id (get-in db [:kv :editing-id])]
     (edb/get-item-by-id @app-db :todos editing-id))))

Returns the list of todos for passed status.

(defn todos-by-status
  [app-db status]
   (todo/todos-by-status @app-db status)))
(def subscriptions {:todos todos
                    :editing-id editing-id
                    :editing-todo editing-todo
                    :todos-by-status todos-by-status})
(ns keechma-todomvc.controllers.todos
  (:require [keechma.controller :as controller :refer [dispatcher]]
            [cljs.core.async :refer [<!]]
            [keechma-todomvc.edb :as edb]
            [keechma-todomvc.entities.todo :as todo])
  (:require-macros [cljs.core.async.macros :refer [go]]))

Commits the change to the app-db to the app-db atom.

(defn updater!
  (fn [app-db-atom args]
    (swap! app-db-atom modifier-fn args)))

This controller receives the commands from the UI and dispatches them to the functions that modify the state.

  • params function returns true because this controller should always be running
  • start function adds an empty todo list to the EntityDB
  • handler function dispatches commands from the UI to the modifier functions
  Controller []
  (params [_ _] true)
  (start [_ params app-db]
    (edb/insert-collection app-db :todos :list []))
  (handler [_ app-db-atom in-chan _]
    (dispatcher app-db-atom in-chan
                {:toggle-todo (updater! todo/toggle-todo)
                 :create-todo (updater! todo/create-todo)
                 :update-todo (updater! todo/update-todo)
                 :destroy-todo (updater! todo/destroy-todo)
                 :edit-todo (updater! todo/edit-todo)
                 :cancel-edit-todo (updater! todo/cancel-edit-todo)
                 :destroy-completed (updater! todo/destroy-completed)
                 :toggle-all (updater! todo/toggle-all)})))
(ns keechma-todomvc.entities.todo
  (:require [keechma-todomvc.edb :as edb])
  (:import [goog.ui IdGenerator]))
(def id-generator (IdGenerator.))

Returns a new ID for the todo.

(defn id
  (.getNextUniqueId id-generator))

Is a todo in active state?

(defn is-active?
  (:completed todo))

Checks if the todo title is an empty string.

(defn has-title?
  (pos? (count (clojure.string/trim (:title todo)))))

Creates a new todo and adds it to a todos list if the todo has a non empty title.

(defn create-todo
  [app-db title]
  (let [todo {:id (str "todo" (id))
              :completed false
              :title title}]
    (if (has-title? todo)
      (edb/prepend-collection app-db :todos :list [todo])

Saves the id of the todo that is being edited.

(defn edit-todo
  [app-db todo]
  (assoc-in app-db [:kv :editing-id] (:id todo)))

Clears the id of the currently edited todo.

(defn cancel-edit-todo
  (assoc-in app-db [:kv :editing-id] nil))

Updates the todo with new data if the todo has a non empty title.

(defn update-todo
  [app-db todo]
  (if (has-title? todo)
    (-> app-db
        (edb/update-item-by-id :todos (:id todo) todo))

Removes the todo from the EntityDB.

(defn destroy-todo
  [app-db todo]
  (edb/remove-item app-db :todos (:id todo)))

Toggles the :completed status.

(defn toggle-todo
  [app-db todo]
  (update-todo app-db (assoc todo :completed (not (:completed todo)))))

Returns the todos for a status.

(defn todos-by-status
  [app-db status]
  (let [todos (edb/get-collection app-db :todos :list)]
    (case status
      :completed (filter is-active? todos)
      :active (filter (complement is-active?) todos)

Marks all todos as active or completed based on the status argument.

(defn toggle-all
  [app-db status]
  (let [todo-ids (map :id (todos-by-status app-db :all))]
    (reduce #(edb/update-item-by-id %1 :todos %2 {:completed status}) app-db todo-ids)))

Removes all completed todos from the EntityDB.

(defn destroy-completed
  (let [completed-todos (todos-by-status app-db :completed)
        completed-todos-ids (map :id completed-todos)]
    (reduce #(edb/remove-item %1 :todos %2) app-db completed-todos-ids)))
(ns keechma-todomvc.components
  (:require [ :as app]
            [keechma-todomvc.components.footer :as footer]
            [ :as new-todo]
            [keechma-todomvc.components.todo-item :as todo-item]
            [keechma-todomvc.components.todo-list :as todo-list]
            [keechma-todomvc.components.todo-input :as todo-input]
            [keechma-todomvc.components.toggle-todos :as toggle-todos]))

Defines the component system. All the components that have the :topic assoced to them send commands to the todos controller.

(def system
  {:main app/component
   :new-todo (assoc new-todo/component :topic :todos)
   :footer (assoc footer/component :topic :todos)
   :todo-item (assoc todo-item/component :topic :todos)
   :todo-list todo-list/component
   :todo-input (assoc todo-input/component :topic :todos)
   :toggle-todos (assoc toggle-todos/component :topic :todos)})