update example todomvc app

This commit is contained in:
Gered 2016-05-23 13:53:36 -04:00
parent 6542fab155
commit 2e429bb5c0
5 changed files with 293 additions and 133 deletions

View file

@ -0,0 +1,41 @@
# Reagent Data Views Example - Todo MVC
This is a modification of the Todo MVC app for Reagent [demonstrated here][1].
This version of the app has been modified to use a PostgreSQL database
to store the Todos and to provide realtime synchronization of changes
to that data to any number of users currently viewing the app.
[1]: http://reagent-project.github.io/
## Running
Since Reagent Data Views and the Views library it depends on are all
currently in somewhat of an experimental / pre-beta state right now,
you will need to first clone the following repositories and manually
install the libraries via `lein install`:
* [views](https://github.com/gered/views)
* [views-sql](https://github.com/gered/views-sql)
* [reagent-data-views](https://github.com/gered/reagent-data-views)
As well, you can install [views-honeysql](https://github.com/gered/views-honeysql)
if you want to try out using HoneySQL instead of SQL with views. But
this example app does not use it so it's not required.
Once these libraries are install, you can simply build the ClojureScript:
$ lein cljsbuild once
And then start up a REPL and run:
(-main)
And a new browser window should open to the app.
Alternatively, to build and run in one go:
$ lein rundemo
Open up a second browser and make changes by adding or deleting a Todo,
or marking them as completed, etc. and see that the changes are
instantly propagated to all clients.

View file

@ -1,50 +1,56 @@
(defproject todomvc "0.1.0-SNAPSHOT"
:dependencies [[org.clojure/clojure "1.6.0"]
[org.clojure/clojurescript "0.0-2371"]
[compojure "1.2.1"]
[ring "1.3.1"]
[ring/ring-defaults "0.1.3" :exclusions [javax.servlet/servlet-api]]
[net.thegeez/clj-browserchannel-jetty-adapter "0.0.6"]
[clj-browserchannel-messaging "0.0.4"]
[clj-pebble "0.2.0"]
[cljs-ajax "0.3.3"]
[reagent "0.5.0-alpha"]
[reagent-data-views "0.1.0-SNAPSHOT"]
[views "0.5.0"]
[org.clojure/java.jdbc "0.3.5"]
[org.postgresql/postgresql "9.2-1003-jdbc4"]
[honeysql "0.4.3"]
[environ "1.0.0"]]
:dependencies [[org.clojure/clojure "1.8.0"]
[org.clojure/clojurescript "1.8.51"]
[ring "1.4.0"]
[ring/ring-defaults "0.2.0" :exclusions [javax.servlet/servlet-api]]
[compojure "1.4.0"]
[org.immutant/web "2.1.4"]
:plugins [[lein-cljsbuild "1.0.3"]]
[org.clojure/java.jdbc "0.6.1"]
[org.postgresql/postgresql "9.4.1208.jre7"]
[gered/clj-browserchannel "0.3.1"]
[gered/clj-browserchannel-immutant-adapter "0.0.3"]
[gered/views "1.5-SNAPSHOT"]
[gered/views-sql "0.1.0-SNAPSHOT"]
[reagent-data-views "0.2.0-SNAPSHOT"]
[reagent-data-views-browserchannel "0.1.0-SNAPSHOT"]
:main todomvc.server
[clj-pebble "0.2.0"]
[reagent "0.6.0-alpha2"]
[cljs-ajax "0.5.4"]
; only being used to get a <meta> tag value with the CSRF token in it
[prismatic/dommy "1.1.0"]
:cljsbuild {:builds
{:main
{:source-paths ["src"]
:compiler
{:preamble ["reagent/react.js"]
:output-to "resources/public/cljs/client.js"
:source-map "resources/public/cljs/client.js.map"
:output-dir "resources/public/cljs/client"
:optimizations :none
:pretty-print true}}}}
[environ "1.0.3"]]
:profiles {:dev {:env {:dev true}}
:plugins [[lein-cljsbuild "1.1.3"]
[lein-environ "1.0.3"]]
:uberjar {:env {:dev false}
:hooks [leiningen.cljsbuild]
:cljsbuild
{:jar true
:builds
{:main
{:compiler
^:replace
{:output-to "resources/public/cljs/client.js"
:preamble ["reagent/react.min.js"]
:optimizations :advanced
:pretty-print false}}}}}}
:main todomvc.server
:aliases {"uberjar" ["do" "clean" ["cljsbuild clean"] "uberjar"]
"cljsdev" ["do" ["cljsbuild" "clean"] ["cljsbuild" "once"] ["cljsbuild" "auto"]]})
:clean-targets ^{:protect false} [:target-path
[:cljsbuild :builds :main :compiler :output-dir]
[:cljsbuild :builds :main :compiler :output-to]]
:cljsbuild {:builds {:main
{:source-paths ["src"]
:compiler {:output-to "resources/public/cljs/app.js"
:output-dir "resources/public/cljs/target"
:source-map true
:optimizations :none
:pretty-print true}}}}
:profiles {:dev {:env {:dev "true"}}
:uberjar {:env {}
:aot :all
:hooks [leiningen.cljsbuild]
:cljsbuild {:jar true
:builds {:main
{:compiler ^:replace {:output-to "resources/public/cljs/app.js"
:optimizations :advanced
:pretty-print false}}}}}}
:aliases {"rundemo" ["do" ["clean"] ["cljsbuild" "once"] ["run"]]
"uberjar" ["do" ["clean"] ["uberjar"]]}
)

View file

@ -5,12 +5,19 @@
<title>todomvc with reagent</title>
<link rel="stylesheet" href="todos.css">
<link rel="stylesheet" href="todosanim.css">
<!-- include CSRF token that ring's anti-forgery middleware is expecting.
clj-browserchannel's client-side init will pick this meta tag up
automatically and include the token in all of browserchannel's
requests to the server. -->
<meta name="anti-forgery-token" content="{{ csrfToken }}">
</head>
<body>
<h1>This will become todomvc when the ClojureScript is compiled</h1>
{% if dev %}<script type="text/javascript" src="cljs/client/goog/base.js"></script>{% endif %}
{% if dev %}<script src="http://fb.me/react-0.12.1.js"></script>{% endif %}
<script type="text/javascript" src="cljs/client.js"></script>
<div id="app">
<h1>This will become todomvc when the ClojureScript is compiled</h1>
</div>
{% if dev %}<script type="text/javascript" src="cljs/target/goog/base.js"></script>{% endif %}
<script type="text/javascript" src="cljs/app.js"></script>
{% if dev %}<script type="text/javascript">goog.require('todomvc.client');</script>{% endif %}
<script type="text/javascript">
todomvc.client.run();

View file

@ -1,10 +1,26 @@
(ns todomvc.client
(:require
[reagent.core :as reagent :refer [atom]]
[clj-browserchannel-messaging.client :as browserchannel]
[reagent-data-views.client.core :as rviews]
[reagent.core :as r]
[ajax.core :refer [POST default-interceptors to-interceptor]]
[dommy.core :refer-macros [sel1]]
[net.thegeez.browserchannel.client :as browserchannel]
[reagent-data-views.client.component :refer [view-cursor] :refer-macros [defvc]]
[ajax.core :refer [POST]]))
[reagent-data-views.browserchannel.client :as rdv-browserchannel]))
;; Todo MVC - Reagent Implementation
;;
;; This is taken from the example code shown on http://reagent-project.github.io/
;; It has been modified so that instead of using todo data stored client-side in
;; an atom, the data is retrieved from the server.
;;
;; AJAX requests are used to add/edit/delete the todos. The list is refreshed
;; whenever a change is made (by any client currently viewing the app) by a
;; view subscription. See the 'todo-app' component near the bottom-middle of this
;; file for more details about this.
;; AJAX operations
(defn add-todo [text] (POST "/todos/add" {:format :url :params {:title text}}))
(defn toggle [id] (POST "/todos/toggle" {:format :url :params {:id id}}))
@ -14,8 +30,12 @@
(defn complete-all [v] (POST "/todos/mark-all" {:format :url :params {:done? v}}))
(defn clear-done [] (POST "/todos/delete-all-done"))
;; UI Components
(defn todo-input [{:keys [title on-save on-stop]}]
(let [val (atom title)
(let [val (r/atom title)
stop #(do (reset! val "")
(if on-stop (on-stop)))
save #(let [v (-> @val str clojure.string/trim)]
@ -31,7 +51,7 @@
nil)})])))
(def todo-edit (with-meta todo-input
{:component-did-mount #(.focus (reagent/dom-node %))}))
{:component-did-mount #(.focus (r/dom-node %))}))
(defn todo-stats [{:keys [filt active done]}]
(let [props-for (fn [name]
@ -49,7 +69,7 @@
"Clear completed " done])]))
(defn todo-item []
(let [editing (atom false)]
(let [editing (r/atom false)]
(fn [{:keys [id done title]}]
[:li {:class (str (if done "completed ")
(if @editing "editing"))}
@ -63,23 +83,48 @@
:on-save #(save id %)
:on-stop #(reset! editing false)}])])))
;; Main TODO app component
;;
;; Note that this component is defined using 'defvc' instead of 'defn'. This is a
;; macro provided by reagent-data-views which is required to be used by any Reagent
;; component that will directly subscribe/unsubscribe to views. It handles all the
;; housekeeping operations that working with views on the client entails.
;;
;; The call to 'view-cursor' is where the rest of the magic happens. This function
;; will:
;;
;; - Send a subscription request to the server for the specified view and parameters
;; if a subscription for the view (and the exact provided parameters) does not
;; already exist.
;; - Returns the most recent data for this view in a Reagent cursor. When the data
;; is changed and the server sends a view refresh, components dereferencing this
;; cursor will be rerendered, just like any other Reagent atom/cursor.
;; - If the values of the (optional) parameters passed to view-cursor change, a
;; view resubscription (with the new parameters) will be triggered automatically
;; and the server will send us new view data.
;;
;; NOTE:
;; view-cursor cannot be used in a Reagent component that was created using defn.
(defvc todo-app [props]
(let [filt (atom :all)]
(let [filt (r/atom :all)]
(fn []
(let [items (view-cursor [:todos])
done (->> @items (filter :done) count)
(let [items (view-cursor :todos)
done (->> @items (filter :done) count)
active (- (count @items) done)]
[:div
[:section#todoapp
[:header#header
[:h1 "todos"]
[todo-input {:id "new-todo"
[todo-input {:id "new-todo"
:placeholder "What needs to be done?"
:on-save add-todo}]]
:on-save add-todo}]]
(when (-> @items count pos?)
[:div
[:section#main
[:input#toggle-all {:type "checkbox" :checked (zero? active)
[:input#toggle-all {:type "checkbox" :checked (zero? active)
:on-change #(complete-all (pos? active))}]
[:label {:for "toggle-all"} "Mark all as complete"]
[:ul#todo-list
@ -96,9 +141,36 @@
[:footer#info
[:p "Double-click to edit a todo"]]]))))
;; Some unfortunately necessary set up to ensure we send the CSRF token back with
;; AJAX requests (clj-browserchannel handles this automatically for it's own HTTP
;; requests, so the set up we do is only for our own application code).
(defn get-anti-forgery-token []
(if-let [tag (sel1 "meta[name='anti-forgery-token']")]
(.-content tag)))
(def csrf-interceptor
(to-interceptor {:name "CSRF Interceptor"
:request #(assoc-in % [:headers "X-CSRF-Token"] (get-anti-forgery-token))}))
(swap! default-interceptors (partial cons csrf-interceptor))
;; Page load
(defn ^:export run []
(browserchannel/init!
:on-connect
(fn []
(rviews/init!)
(reagent/render-component [todo-app] (.-body js/document)))))
; Configure reagent-data-views and then BrowserChannel.
(rdv-browserchannel/configure!)
; NOTE: We are passing in an empty map for the BrowserChannel event handlers only
; because this todo app is not using BrowserChannel for any purpose other
; then to provide client/server messaging for reagent-data-views. If we
; wanted to use it for client/server messaging in our application as well,
; we could pass in any event handlers we want here and it would not intefere
; with reagent-data-views.
(browserchannel/connect! {} {:middleware [rdv-browserchannel/middleware]})
(r/render-component [todo-app] (.getElementById js/document "app")))

View file

@ -3,16 +3,19 @@
(:require
[compojure.core :refer [routes GET POST]]
[compojure.route :as route]
[net.thegeez.jetty-async-adapter :refer [run-jetty-async]]
[ring.middleware.defaults :refer [wrap-defaults site-defaults]]
[ring.middleware.anti-forgery :refer [*anti-forgery-token*]]
[ring.util.response :refer [response]]
[net.thegeez.browserchannel.server :refer [wrap-browserchannel]]
[net.thegeez.browserchannel.immutant-async-adapter :refer [wrap-immutant-async-adapter]]
[immutant.web :as immutant]
[clj-pebble.core :as pebble]
[clj-browserchannel-messaging.server :as browserchannel]
[environ.core :refer [env]]
[honeysql.helpers :refer :all]
[clojure.java.jdbc :as sql]
[reagent-data-views.server.core :as rviews :refer [views-config]]
[views.db.core :refer [vexec! with-view-transaction]]))
[clojure.java.jdbc :as jdbc]
[views.sql.core :refer [vexec! with-view-transaction]]
[views.sql.view :refer [view]]
[reagent-data-views.browserchannel.server :as rdv-browserchannel]))
(def db {:classname "org.postgresql.Driver"
:subprotocol "postgresql"
@ -21,74 +24,82 @@
:password "s3cr3t"})
;; View templates (functions which return HoneySQL SELECT query maps)
(defn ^:refresh-only todos-view []
(-> (select :id :title :done)
(from :todos)))
;; View functions.
;;
;; These are functions which accept any number of parameters provided when the view
;; is subscribed to and run whenever a subscriber needs refreshed data for it.
;;
;; A view function's return value requirement depends on what views IView
;; implementation is being used.
;;
;; This example app is using views-sql, so view templates should return a SQL SELECT
;; query in a clojure.java.jdbc "sqlvec" which is a vector where the first string is
;; the actual SQL query and is followed by any number of parameters to be used in
;; the query.
(defn get-todos []
["SELECT id, title, done FROM todos ORDER BY title"])
;; Views list.
;;
;; A definition/declaration of the views in the system. Each view is given an id and
;; points to a function that returns the query that will be used to retrieve the view's
;; data. Also other properties can be provided to each view, such as the database connection.
;;
;; The view id and parameters to the view function get used later on to form a
;; "view signature" or "view-sig" when the client subscribes to a view.
; the keys in this map are the names of the views, which we can refer to in our cljs code
; (the key + args to the view fn combined make up a "view signature" or "view-sig"
; e.g. [:items] in this case as it has zero arguments.
(def views
{:todos {:fn #'todos-view}})
[(view :todos db #'get-todos)])
;; SQL operations (affecting the views defined above, so we use vexec! instead of jdbc calls)
;; SQL operations triggered by AJAX requests.
;;
;; These functions are just your ordinary AJAX request handlers that do the various
;; CRUD operations on the example app's data. The only difference is that instead
;; of using clojure.java.jdbc/execute!, we instead use vexec!.
;;
;; vexec! performs the exact same operation as execute!, except that it also
;; analyzes the SQL query being run and dispatches "hints" to the view system which
;; trigger view refrehses for all subscribers of the views that the hints match.
(defn add-todo! [title]
(vexec!
@views-config
(-> (insert-into :todos)
(values [{:title title}])))
(response "added todo"))
(vexec! db ["INSERT INTO todos (title) VALUES (?)" title])
(response "ok"))
(defn delete-todo! [id]
(vexec!
@views-config
(-> (delete-from :todos)
(where [:= :id id])))
(response "deleted todo"))
(vexec! db ["DELETE FROM todos WHERE id = ?" id])
(response "ok"))
(defn update-todo! [id title]
(vexec!
@views-config
(-> (update :todos)
(sset {:title title})
(where [:= :id id])))
(response "updated todo"))
(vexec! db ["UPDATE todos SET title = ? WHERE id = ?" title id])
(response "ok"))
(defn toggle-todo! [id]
; note that we could have written this operation using a single UPDATE query,
; but writing it this way also serves to demonstrate:
; - using transactions with vexec!
; - that the db connection is available under :db in the views config map and can
; be used to run any ordinary query directly with jdbc (of course)
(with-view-transaction [vt @views-config]
(let [done? (:done (first (sql/query (:db vt) ["SELECT done FROM todos WHERE id = ?" id])))]
(vexec!
vt
(-> (update :todos)
(sset {:done (not done?)})
(where [:= :id id]))))
(response "toggled todo")))
; note that a transaction is obviously not necessary here as we could have used
; just a single UPDATE query. however, it is being done this way to demonstrate
; using transactions with vexec!.
(with-view-transaction
[dt db]
(let [done? (:done (first (jdbc/query dt ["SELECT done FROM todos WHERE id = ?" id])))]
(vexec! dt ["UPDATE todos SET done = ? WHERE id = ?" (not done?) id]))
(response "ok")))
(defn mark-all! [done?]
(vexec!
@views-config
(-> (update :todos)
(sset {:done done?})))
(response "completed all todos"))
(vexec! db ["UPDATE todos SET done = ?" done?])
(response "ok"))
(defn delete-all-done! []
(vexec!
@views-config
(-> (delete-from :todos)
(where [:= :done true])))
(response "deleted all done todos"))
(vexec! db ["DELETE FROM todos WHERE done = true"])
(response "ok"))
;; Compojure routes / Jetty server & main
;; Compojure routes and Ring handler
(def app-routes
(routes
@ -100,23 +111,46 @@
(POST "/todos/mark-all" [done?] (mark-all! (Boolean/parseBoolean done?)))
(POST "/todos/delete-all-done" [] (delete-all-done!))
(GET "/" [] (pebble/render-resource "html/app.html" {:dev (env :dev)}))
; main page
(GET "/" [] (pebble/render-resource
"html/app.html"
{:dev (boolean (env :dev))
:csrfToken *anti-forgery-token*}))
(route/resources "/")
(route/not-found "not found")))
(defn run-server []
(browserchannel/init!
:middleware [rviews/views-middleware])
(rviews/init! db views)
(def handler
(-> app-routes
(browserchannel/wrap-browserchannel)
(wrap-defaults (assoc-in site-defaults [:security :anti-forgery] false))
(run-jetty-async
{:port 8080
:auto-reload? true
:join? false}))
(println "Web app is running at http://localhost:8080/"))
(wrap-defaults site-defaults)
; NOTE: We are passing in an empty map for the BrowserChannel event handlers only
; because this todo app is not using BrowserChannel for any purpose other
; then to provide client/server messaging for reagent-data-views. If we
; wanted to use it for client/server messaging in our application as well,
; we could pass in any event handlers we want here and it would not intefere
; with reagent-data-views.
(wrap-browserchannel {} {:middleware [rdv-browserchannel/middleware]})
(wrap-immutant-async-adapter)))
;; Web server startup & main
(defn run-server []
(pebble/set-options! :cache (env :dev))
; init-views takes care of initialization views and reagent-data-views at the same
; time. As a result, we do not need to also call views.core/init! anywhere. The
; same options you are able to pass to views.core/init! can also be passed in here
; and they will be forwarded along.
;
; if you need to shutdown the views system (e.g. if you're using something like
; Component or Mount), you can just call views.core/shutdown!.
(rdv-browserchannel/init-views! views)
(if (env :dev)
(immutant/run-dmc #'handler {:port 8080})
(immutant/run #'handler {:port 8080})))
(defn -main [& args]
(run-server))