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" (defproject todomvc "0.1.0-SNAPSHOT"
:dependencies [[org.clojure/clojure "1.6.0"] :dependencies [[org.clojure/clojure "1.8.0"]
[org.clojure/clojurescript "0.0-2371"] [org.clojure/clojurescript "1.8.51"]
[compojure "1.2.1"] [ring "1.4.0"]
[ring "1.3.1"] [ring/ring-defaults "0.2.0" :exclusions [javax.servlet/servlet-api]]
[ring/ring-defaults "0.1.3" :exclusions [javax.servlet/servlet-api]] [compojure "1.4.0"]
[net.thegeez/clj-browserchannel-jetty-adapter "0.0.6"] [org.immutant/web "2.1.4"]
[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"]]
: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 [environ "1.0.3"]]
{: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}}}}
:profiles {:dev {:env {:dev true}} :plugins [[lein-cljsbuild "1.1.3"]
[lein-environ "1.0.3"]]
:uberjar {:env {:dev false} :main todomvc.server
: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}}}}}}
:aliases {"uberjar" ["do" "clean" ["cljsbuild clean"] "uberjar"] :clean-targets ^{:protect false} [:target-path
"cljsdev" ["do" ["cljsbuild" "clean"] ["cljsbuild" "once"] ["cljsbuild" "auto"]]}) [: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> <title>todomvc with reagent</title>
<link rel="stylesheet" href="todos.css"> <link rel="stylesheet" href="todos.css">
<link rel="stylesheet" href="todosanim.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> </head>
<body> <body>
<h1>This will become todomvc when the ClojureScript is compiled</h1> <div id="app">
{% if dev %}<script type="text/javascript" src="cljs/client/goog/base.js"></script>{% endif %} <h1>This will become todomvc when the ClojureScript is compiled</h1>
{% if dev %}<script src="http://fb.me/react-0.12.1.js"></script>{% endif %} </div>
<script type="text/javascript" src="cljs/client.js"></script> {% 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 %} {% if dev %}<script type="text/javascript">goog.require('todomvc.client');</script>{% endif %}
<script type="text/javascript"> <script type="text/javascript">
todomvc.client.run(); todomvc.client.run();

View file

@ -1,10 +1,26 @@
(ns todomvc.client (ns todomvc.client
(:require (:require
[reagent.core :as reagent :refer [atom]] [reagent.core :as r]
[clj-browserchannel-messaging.client :as browserchannel] [ajax.core :refer [POST default-interceptors to-interceptor]]
[reagent-data-views.client.core :as rviews] [dommy.core :refer-macros [sel1]]
[net.thegeez.browserchannel.client :as browserchannel]
[reagent-data-views.client.component :refer [view-cursor] :refer-macros [defvc]] [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 add-todo [text] (POST "/todos/add" {:format :url :params {:title text}}))
(defn toggle [id] (POST "/todos/toggle" {:format :url :params {:id id}})) (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 complete-all [v] (POST "/todos/mark-all" {:format :url :params {:done? v}}))
(defn clear-done [] (POST "/todos/delete-all-done")) (defn clear-done [] (POST "/todos/delete-all-done"))
;; UI Components
(defn todo-input [{:keys [title on-save on-stop]}] (defn todo-input [{:keys [title on-save on-stop]}]
(let [val (atom title) (let [val (r/atom title)
stop #(do (reset! val "") stop #(do (reset! val "")
(if on-stop (on-stop))) (if on-stop (on-stop)))
save #(let [v (-> @val str clojure.string/trim)] save #(let [v (-> @val str clojure.string/trim)]
@ -31,7 +51,7 @@
nil)})]))) nil)})])))
(def todo-edit (with-meta todo-input (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]}] (defn todo-stats [{:keys [filt active done]}]
(let [props-for (fn [name] (let [props-for (fn [name]
@ -49,7 +69,7 @@
"Clear completed " done])])) "Clear completed " done])]))
(defn todo-item [] (defn todo-item []
(let [editing (atom false)] (let [editing (r/atom false)]
(fn [{:keys [id done title]}] (fn [{:keys [id done title]}]
[:li {:class (str (if done "completed ") [:li {:class (str (if done "completed ")
(if @editing "editing"))} (if @editing "editing"))}
@ -63,23 +83,48 @@
:on-save #(save id %) :on-save #(save id %)
:on-stop #(reset! editing false)}])]))) :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] (defvc todo-app [props]
(let [filt (atom :all)] (let [filt (r/atom :all)]
(fn [] (fn []
(let [items (view-cursor [:todos]) (let [items (view-cursor :todos)
done (->> @items (filter :done) count) done (->> @items (filter :done) count)
active (- (count @items) done)] active (- (count @items) done)]
[:div [:div
[:section#todoapp [:section#todoapp
[:header#header [:header#header
[:h1 "todos"] [:h1 "todos"]
[todo-input {:id "new-todo" [todo-input {:id "new-todo"
:placeholder "What needs to be done?" :placeholder "What needs to be done?"
:on-save add-todo}]] :on-save add-todo}]]
(when (-> @items count pos?) (when (-> @items count pos?)
[:div [:div
[:section#main [:section#main
[:input#toggle-all {:type "checkbox" :checked (zero? active) [:input#toggle-all {:type "checkbox" :checked (zero? active)
:on-change #(complete-all (pos? active))}] :on-change #(complete-all (pos? active))}]
[:label {:for "toggle-all"} "Mark all as complete"] [:label {:for "toggle-all"} "Mark all as complete"]
[:ul#todo-list [:ul#todo-list
@ -96,9 +141,36 @@
[:footer#info [:footer#info
[:p "Double-click to edit a todo"]]])))) [: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 [] (defn ^:export run []
(browserchannel/init! ; Configure reagent-data-views and then BrowserChannel.
:on-connect (rdv-browserchannel/configure!)
(fn []
(rviews/init!) ; NOTE: We are passing in an empty map for the BrowserChannel event handlers only
(reagent/render-component [todo-app] (.-body js/document))))) ; 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 (:require
[compojure.core :refer [routes GET POST]] [compojure.core :refer [routes GET POST]]
[compojure.route :as route] [compojure.route :as route]
[net.thegeez.jetty-async-adapter :refer [run-jetty-async]]
[ring.middleware.defaults :refer [wrap-defaults site-defaults]] [ring.middleware.defaults :refer [wrap-defaults site-defaults]]
[ring.middleware.anti-forgery :refer [*anti-forgery-token*]]
[ring.util.response :refer [response]] [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-pebble.core :as pebble]
[clj-browserchannel-messaging.server :as browserchannel]
[environ.core :refer [env]] [environ.core :refer [env]]
[honeysql.helpers :refer :all] [clojure.java.jdbc :as jdbc]
[clojure.java.jdbc :as sql] [views.sql.core :refer [vexec! with-view-transaction]]
[reagent-data-views.server.core :as rviews :refer [views-config]] [views.sql.view :refer [view]]
[views.db.core :refer [vexec! with-view-transaction]])) [reagent-data-views.browserchannel.server :as rdv-browserchannel]))
(def db {:classname "org.postgresql.Driver" (def db {:classname "org.postgresql.Driver"
:subprotocol "postgresql" :subprotocol "postgresql"
@ -21,74 +24,82 @@
:password "s3cr3t"}) :password "s3cr3t"})
;; View templates (functions which return HoneySQL SELECT query maps)
(defn ^:refresh-only todos-view [] ;; View functions.
(-> (select :id :title :done) ;;
(from :todos))) ;; 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 (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] (defn add-todo! [title]
(vexec! (vexec! db ["INSERT INTO todos (title) VALUES (?)" title])
@views-config (response "ok"))
(-> (insert-into :todos)
(values [{:title title}])))
(response "added todo"))
(defn delete-todo! [id] (defn delete-todo! [id]
(vexec! (vexec! db ["DELETE FROM todos WHERE id = ?" id])
@views-config (response "ok"))
(-> (delete-from :todos)
(where [:= :id id])))
(response "deleted todo"))
(defn update-todo! [id title] (defn update-todo! [id title]
(vexec! (vexec! db ["UPDATE todos SET title = ? WHERE id = ?" title id])
@views-config (response "ok"))
(-> (update :todos)
(sset {:title title})
(where [:= :id id])))
(response "updated todo"))
(defn toggle-todo! [id] (defn toggle-todo! [id]
; note that we could have written this operation using a single UPDATE query, ; note that a transaction is obviously not necessary here as we could have used
; but writing it this way also serves to demonstrate: ; just a single UPDATE query. however, it is being done this way to demonstrate
; - using transactions with vexec! ; using transactions with vexec!.
; - that the db connection is available under :db in the views config map and can (with-view-transaction
; be used to run any ordinary query directly with jdbc (of course) [dt db]
(with-view-transaction [vt @views-config] (let [done? (:done (first (jdbc/query dt ["SELECT done FROM todos WHERE id = ?" id])))]
(let [done? (:done (first (sql/query (:db vt) ["SELECT done FROM todos WHERE id = ?" id])))] (vexec! dt ["UPDATE todos SET done = ? WHERE id = ?" (not done?) id]))
(vexec! (response "ok")))
vt
(-> (update :todos)
(sset {:done (not done?)})
(where [:= :id id]))))
(response "toggled todo")))
(defn mark-all! [done?] (defn mark-all! [done?]
(vexec! (vexec! db ["UPDATE todos SET done = ?" done?])
@views-config (response "ok"))
(-> (update :todos)
(sset {:done done?})))
(response "completed all todos"))
(defn delete-all-done! [] (defn delete-all-done! []
(vexec! (vexec! db ["DELETE FROM todos WHERE done = true"])
@views-config (response "ok"))
(-> (delete-from :todos)
(where [:= :done true])))
(response "deleted all done todos"))
;; Compojure routes / Jetty server & main
;; Compojure routes and Ring handler
(def app-routes (def app-routes
(routes (routes
@ -100,23 +111,46 @@
(POST "/todos/mark-all" [done?] (mark-all! (Boolean/parseBoolean done?))) (POST "/todos/mark-all" [done?] (mark-all! (Boolean/parseBoolean done?)))
(POST "/todos/delete-all-done" [] (delete-all-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/resources "/")
(route/not-found "not found"))) (route/not-found "not found")))
(defn run-server [] (def handler
(browserchannel/init!
:middleware [rviews/views-middleware])
(rviews/init! db views)
(-> app-routes (-> app-routes
(browserchannel/wrap-browserchannel) (wrap-defaults site-defaults)
(wrap-defaults (assoc-in site-defaults [:security :anti-forgery] false)) ; NOTE: We are passing in an empty map for the BrowserChannel event handlers only
(run-jetty-async ; because this todo app is not using BrowserChannel for any purpose other
{:port 8080 ; then to provide client/server messaging for reagent-data-views. If we
:auto-reload? true ; wanted to use it for client/server messaging in our application as well,
:join? false})) ; we could pass in any event handlers we want here and it would not intefere
(println "Web app is running at http://localhost:8080/")) ; 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] (defn -main [& args]
(run-server)) (run-server))