Merge branch 'differential_sync'
This commit is contained in:
commit
48e7ef1b50
22 changed files with 831 additions and 478 deletions
23
project.clj
23
project.clj
|
@ -17,24 +17,33 @@
|
|||
[clj-webdriver "0.6.1" :exclusions [org.clojure/core.cache]]
|
||||
[om "0.7.0"]
|
||||
[sablono "0.2.17"]
|
||||
[cljs-uuid "0.0.4"]]
|
||||
:profiles {:uberjar {:aot :all}}
|
||||
[cljs-uuid "0.0.4"]
|
||||
[net.polyc0l0r/hasch "0.2.3"]]
|
||||
:profiles {:uberjar {:aot :all}
|
||||
:dev {:dependencies [[midje "1.6.3"]]}}
|
||||
:min-lein-version "2.1.2"
|
||||
:plugins [[lein-cljsbuild "1.0.3"]
|
||||
[lein-ring "0.8.6"]]
|
||||
:cljsbuild {:builds {:dev {:source-paths ["src/cljs"]
|
||||
[lein-ring "0.8.6"]
|
||||
[com.keminglabs/cljx "0.4.0"]]
|
||||
:cljsbuild {:builds {:dev {:source-paths ["src/cljs" "target/generated/cljs"]
|
||||
:compiler {:output-dir "public/js/out"
|
||||
:output-to "public/js/grub.js"
|
||||
:optimizations :none
|
||||
:source-map true}}
|
||||
:prod {:source-paths ["src/cljs"]
|
||||
:prod {:source-paths ["src/cljs" "target/generated/cljs"]
|
||||
:compiler {:output-to "public/js/grub.min.js"
|
||||
:optimizations :advanced
|
||||
:pretty-print false
|
||||
:preamble ["react/react.min.js"]
|
||||
:externs ["react/externs/react.js"]}}}}
|
||||
:source-paths ["src/clj" "src/test"]
|
||||
:test-paths ["spec/clj"]
|
||||
:cljx {:builds [{:source-paths ["src/cljx"]
|
||||
:output-path "target/classes"
|
||||
:rules :clj}
|
||||
{:source-paths ["src/cljx"]
|
||||
:output-path "target/generated/cljs"
|
||||
:rules :cljs}]}
|
||||
:source-paths ["src/clj" "src/test" "target/classes"]
|
||||
:test-paths ["src/test"]
|
||||
:ring {:handler grub.core/app}
|
||||
:uberjar-name "grub-standalone.jar"
|
||||
:main grub.core)
|
||||
|
|
|
@ -23,9 +23,9 @@ h3 {
|
|||
}
|
||||
|
||||
.recipe-add-grubs-btn {
|
||||
float: right;
|
||||
clear: both;
|
||||
position: absolute;
|
||||
margin: 2px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
|
@ -133,16 +133,25 @@ tr:hover .grub-close {
|
|||
.recipe-header {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
padding-right: 72px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.recipe-header.edit {
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
.recipe-header.new {
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
.recipe-header-input {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
border-bottom: none;
|
||||
display: inline;
|
||||
width: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recipe-header-input[readonly] {
|
||||
|
|
|
@ -2,13 +2,14 @@
|
|||
(:require [grub.websocket :as ws]
|
||||
[grub.db :as db]
|
||||
[grub.test.integration.core :as integration-test]
|
||||
[ring.middleware.reload :as reload]
|
||||
[grub.state :as state]
|
||||
[ring.middleware.file :as file]
|
||||
[ring.util.response :as resp]
|
||||
[compojure.core :refer [defroutes GET POST]]
|
||||
[compojure.handler :as handler]
|
||||
[compojure.route :as route]
|
||||
[org.httpkit.server :as httpkit]
|
||||
[clojure.core.async :as a :refer [<! >! chan go]]
|
||||
[hiccup
|
||||
[page :refer [html5]]
|
||||
[page :refer [include-js include-css]]]
|
||||
|
@ -41,40 +42,45 @@
|
|||
|
||||
(def index-page (atom dev-index-page))
|
||||
|
||||
(defn websocket-handler [request]
|
||||
(when (:websocket? request)
|
||||
(httpkit/with-channel request ws-channel
|
||||
(let [to-client (chan)
|
||||
from-client (chan)]
|
||||
(ws/add-connected-client! ws-channel to-client from-client)
|
||||
(state/sync-new-client! to-client from-client)))))
|
||||
|
||||
(defroutes routes
|
||||
(GET "/" [] ws/websocket-handler)
|
||||
(GET "/" [] websocket-handler)
|
||||
(GET "/" [] @index-page)
|
||||
(GET "*/src/cljs/grub/:file" [file] (resp/file-response file {:root "src/cljs/grub"}))
|
||||
(GET "/js/public/js/:file" [file] (resp/redirect (str "/js/" file)))
|
||||
(route/files "/")
|
||||
(route/not-found "<p>Page not found.</p>"))
|
||||
|
||||
(def app
|
||||
(let [dev? true]
|
||||
(if dev?
|
||||
(reload/wrap-reload (handler/site #'routes) {:dirs ["src/clj"]})
|
||||
(handler/site routes))))
|
||||
|
||||
(def default-port 3000)
|
||||
|
||||
(defn start-server [port]
|
||||
(httpkit/run-server app {:port port}))
|
||||
(httpkit/run-server (handler/site routes) {:port port}))
|
||||
|
||||
(defn run-integration-test []
|
||||
(let [stop-server (start-server integration-test/server-port)]
|
||||
(println "Starting integration test server on localhost:" integration-test/server-port)
|
||||
(integration-test/run)
|
||||
(stop-server)))
|
||||
|
||||
(defn start-production-server [{:keys [port mongo-url]}]
|
||||
(reset! index-page prod-index-page)
|
||||
(let [db-chan (db/connect-production-database mongo-url)]
|
||||
(ws/pass-received-events-to-clients-and-db db-chan)
|
||||
(let [to-db (chan)]
|
||||
(db/connect-production-database to-db mongo-url)
|
||||
(state/init-server to-db (db/get-current-state))
|
||||
(println "Starting production server on localhost:" port)
|
||||
(start-server port)))
|
||||
|
||||
(defn start-development-server [{:keys [port]}]
|
||||
(let [db-chan (db/connect-development-database)]
|
||||
(ws/pass-received-events-to-clients-and-db db-chan)
|
||||
(let [to-db (chan)]
|
||||
(db/connect-development-database to-db)
|
||||
(state/init-server to-db (db/get-current-state))
|
||||
(println "Starting development server on localhost:" port)
|
||||
(start-server port)))
|
||||
|
||||
|
|
|
@ -1,98 +1,29 @@
|
|||
(ns grub.db
|
||||
(:require [monger.core :as m]
|
||||
(:require [grub.util :as util]
|
||||
[grub.sync :as sync]
|
||||
[monger.core :as m]
|
||||
[monger.collection :as mc]
|
||||
[monger.operators :as mo]
|
||||
[clojure.core.async :as a :refer [<! >! chan go]]))
|
||||
|
||||
(def conn (atom nil))
|
||||
(def db (atom nil))
|
||||
(def grub-collection "grubs")
|
||||
(def recipe-collection "recipes")
|
||||
|
||||
(defn clear-grubs []
|
||||
(mc/drop @db grub-collection))
|
||||
|
||||
(defn clear-recipes []
|
||||
(mc/drop @db recipe-collection))
|
||||
|
||||
(defn clear-all []
|
||||
(clear-grubs)
|
||||
(clear-recipes))
|
||||
|
||||
(defmulti handle-event :event :default :unknown-event)
|
||||
|
||||
(defn insert-grub [event]
|
||||
(let [grub (-> event
|
||||
(select-keys [:id :grub :completed])
|
||||
(clojure.set/rename-keys {:id :_id}))]
|
||||
(mc/insert @db grub-collection grub)))
|
||||
|
||||
(defmethod handle-event :add-grub [event]
|
||||
(insert-grub event))
|
||||
|
||||
(defmethod handle-event :add-grub-list [event]
|
||||
(doseq [grub-event (:grubs event)]
|
||||
(insert-grub grub-event)))
|
||||
|
||||
(defmethod handle-event :complete-grub [event]
|
||||
(mc/update @db grub-collection
|
||||
{:_id (:id event)}
|
||||
{mo/$set {:completed true}}))
|
||||
|
||||
(defmethod handle-event :uncomplete-grub [event]
|
||||
(mc/update @db grub-collection
|
||||
{:_id (:id event)}
|
||||
{mo/$set {:completed false}}))
|
||||
|
||||
(defmethod handle-event :update-grub [event]
|
||||
(let [orig (mc/find-one-as-map @db grub-collection {:_id (:id event)})
|
||||
new (dissoc event :event-type :id)]
|
||||
(mc/update-by-id @db grub-collection (:id event) (merge orig new))))
|
||||
|
||||
(defmethod handle-event :clear-all-grubs [event]
|
||||
(clear-grubs))
|
||||
|
||||
(defmethod handle-event :remove-grub [event]
|
||||
(mc/remove-by-id @db grub-collection (:id event)))
|
||||
|
||||
(defmethod handle-event :add-recipe [event]
|
||||
(let [recipe (-> event
|
||||
(select-keys [:id :name :grubs])
|
||||
(clojure.set/rename-keys {:id :_id}))]
|
||||
(mc/insert @db recipe-collection recipe)))
|
||||
|
||||
(defmethod handle-event :update-recipe [event]
|
||||
(mc/update @db recipe-collection
|
||||
{:_id (:id event)}
|
||||
{mo/$set {:name (:name event) :grubs (:grubs event)}}))
|
||||
|
||||
(defmethod handle-event :remove-recipe [event]
|
||||
(mc/remove-by-id @db recipe-collection (:id event)))
|
||||
|
||||
(defmethod handle-event :unknown-event [event]
|
||||
(println "Cannot handle unknown event:" event))
|
||||
|
||||
(defn get-current-grubs []
|
||||
(->> (mc/find-maps @db grub-collection)
|
||||
(sort-by :_id)
|
||||
(map #(select-keys % [:_id :grub :completed]))
|
||||
(map #(clojure.set/rename-keys % {:_id :id}))
|
||||
(vec)))
|
||||
|
||||
(defn get-current-recipes []
|
||||
(->> (mc/find-maps @db recipe-collection)
|
||||
(sort-by :_id)
|
||||
(map #(select-keys % [:_id :name :grubs]))
|
||||
(map #(clojure.set/rename-keys % {:_id :id}))
|
||||
(vec)))
|
||||
|
||||
(def collection "grub-lists")
|
||||
(def production-db "grub")
|
||||
(def development-db "grub-dev")
|
||||
|
||||
(defn handle-incoming-events [in]
|
||||
(a/go-loop [] (let [event (<! in)]
|
||||
(handle-event event)
|
||||
(recur))))
|
||||
(defn clear-all []
|
||||
(mc/drop @db collection))
|
||||
|
||||
(defn update-db! [state]
|
||||
(mc/drop @db collection)
|
||||
(mc/insert @db collection state))
|
||||
|
||||
(defn get-current-state []
|
||||
(let [state (first (mc/find-maps @db collection))]
|
||||
(if state
|
||||
(dissoc state :_id)
|
||||
sync/empty-state)))
|
||||
|
||||
(defn connect! [db-name mongo-url]
|
||||
(if mongo-url
|
||||
|
@ -101,16 +32,19 @@
|
|||
(do (println "Connected to mongo at localhost:" db-name)
|
||||
(m/connect))))
|
||||
|
||||
(defn connect-and-handle-events [db-name & [mongo-url]]
|
||||
(let [in (chan)]
|
||||
(handle-incoming-events in)
|
||||
(let [_conn (connect! db-name mongo-url)]
|
||||
(reset! conn _conn)
|
||||
(reset! db (m/get-db _conn db-name)))
|
||||
in))
|
||||
(defn connect-and-handle-events [to-db db-name & [mongo-url]]
|
||||
(a/go-loop []
|
||||
(if-let [state (<! to-db)]
|
||||
(do (println "DB got new state")
|
||||
(update-db! state)
|
||||
(recur))
|
||||
(println "Database disconnected")))
|
||||
(let [_conn (connect! db-name mongo-url)]
|
||||
(reset! conn _conn)
|
||||
(reset! db (m/get-db _conn db-name))))
|
||||
|
||||
(defn connect-production-database [mongo-url]
|
||||
(connect-and-handle-events production-db mongo-url))
|
||||
(defn connect-production-database [to-db mongo-url]
|
||||
(connect-and-handle-events to-db production-db mongo-url))
|
||||
|
||||
(defn connect-development-database []
|
||||
(connect-and-handle-events development-db))
|
||||
(defn connect-development-database [to-db]
|
||||
(connect-and-handle-events to-db development-db))
|
||||
|
|
|
@ -3,83 +3,16 @@
|
|||
[org.httpkit.server :as httpkit]
|
||||
[clojure.core.async :as a :refer [<! >! chan go]]))
|
||||
|
||||
(def incoming-events (chan))
|
||||
(defn disconnected [status ws-channel to from]
|
||||
(println "Client disconnected:" (.toString ws-channel) "with status" status)
|
||||
(a/close! to)
|
||||
(a/close! from))
|
||||
|
||||
(def connected-clients (atom {}))
|
||||
|
||||
(def ws-channel-id-count (atom 0))
|
||||
|
||||
(defn get-unique-ws-id []
|
||||
(swap! ws-channel-id-count inc))
|
||||
|
||||
(defn add-connected-client! [ws-channel]
|
||||
(let [ws-channel-id (get-unique-ws-id)
|
||||
client-chan (chan)]
|
||||
(swap! connected-clients #(assoc % ws-channel-id client-chan))
|
||||
[ws-channel-id client-chan]))
|
||||
|
||||
(defn remove-connected-client! [status ws-channel ws-channel-id client-chan]
|
||||
(println "Client disconnected:"
|
||||
(.toString ws-channel)
|
||||
(str "(" ws-channel-id ")")
|
||||
"with status" status)
|
||||
(swap! connected-clients #(dissoc % ws-channel-id))
|
||||
(println (count @connected-clients) "client(s) still connected")
|
||||
(a/close! client-chan))
|
||||
|
||||
(defn send-current-grubs-and-recipes-to-client [client-chan]
|
||||
(let [add-grubs-event {:event :add-grub-list
|
||||
:grubs (db/get-current-grubs)}
|
||||
add-recipes-event {:event :add-recipe-list
|
||||
:recipes (db/get-current-recipes)}]
|
||||
(go (>! client-chan add-grubs-event)
|
||||
(>! client-chan add-recipes-event))))
|
||||
|
||||
(defn on-receive [raw-event ws-channel-id client-chan]
|
||||
(let [parsed-event (read-string raw-event)
|
||||
event (assoc parsed-event :ws-channel ws-channel-id)]
|
||||
(println "Received event" event)
|
||||
(if (= (:event event) :send-all-items)
|
||||
(send-current-grubs-and-recipes-to-client client-chan)
|
||||
(go (>! incoming-events event)))))
|
||||
|
||||
(defn forward-other-events-to-client [c ws-channel]
|
||||
(a/go-loop []
|
||||
(when-let [event (<! c)]
|
||||
(println "Send to client '" (str event) "'")
|
||||
(httpkit/send! ws-channel (str event))
|
||||
(recur))))
|
||||
|
||||
(defn set-up-new-connection [ws-channel]
|
||||
(let [[ws-channel-id client-chan] (add-connected-client! ws-channel)]
|
||||
(println "Client connected:" (.toString ws-channel) (str "(" ws-channel-id ")"))
|
||||
(println (count @connected-clients) "client(s) connected")
|
||||
(httpkit/on-close ws-channel #(remove-connected-client! % ws-channel ws-channel-id client-chan))
|
||||
(httpkit/on-receive ws-channel #(on-receive % ws-channel-id client-chan))
|
||||
(forward-other-events-to-client client-chan ws-channel)))
|
||||
|
||||
(defn websocket-handler [request]
|
||||
(when (:websocket? request)
|
||||
(httpkit/with-channel request channel (set-up-new-connection channel))))
|
||||
|
||||
(defn get-other-client-channels [my-ws-channel-id]
|
||||
(-> @connected-clients
|
||||
(dissoc my-ws-channel-id)
|
||||
(vals)))
|
||||
|
||||
(defn push-event-to-others [orig-event]
|
||||
(let [my-ws-channel-id (:ws-channel orig-event)
|
||||
event (dissoc orig-event :ws-channel)]
|
||||
(go (doseq [c (get-other-client-channels my-ws-channel-id)]
|
||||
(>! c event)))))
|
||||
|
||||
(defn pass-received-events-to-clients-and-db [db-chan]
|
||||
(let [in' (a/mult incoming-events)
|
||||
to-others (chan)
|
||||
to-database (chan)]
|
||||
(a/tap in' to-others)
|
||||
(a/tap in' to-database)
|
||||
(a/go-loop [] (let [event (<! to-others)]
|
||||
(push-event-to-others event)
|
||||
(recur)))
|
||||
(a/pipe to-database (a/map> #(dissoc % :ws-channel) db-chan))))
|
||||
(defn add-connected-client! [ws-channel to from]
|
||||
(println "Client connected:" (.toString ws-channel))
|
||||
(a/go-loop [] (if-let [event (<! to)]
|
||||
(do (httpkit/send! ws-channel (pr-str event))
|
||||
(recur))
|
||||
(httpkit/close ws-channel)))
|
||||
(httpkit/on-receive ws-channel #(a/put! from (read-string %)))
|
||||
(httpkit/on-close ws-channel #(disconnected % ws-channel to from)))
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
(ns grub.core
|
||||
(:require [grub.state :as state]
|
||||
[grub.websocket :as ws]
|
||||
[grub.view.app :as view]
|
||||
[cljs.core.async :as a :refer [<! >! chan]])
|
||||
(:require-macros [grub.macros :refer [log logs]]
|
||||
[cljs.core.async.macros :refer [go]]))
|
||||
(:require-macros [grub.macros :refer [log logs]]))
|
||||
|
||||
(defn wire-channels-together []
|
||||
(let [to-remote (chan)
|
||||
to-state (chan)
|
||||
from-remote (ws/get-remote-chan to-remote)
|
||||
from-state (state/update-state-and-render to-state)]
|
||||
(a/pipe from-remote to-state)
|
||||
(a/pipe from-state to-remote)))
|
||||
(defn init-app []
|
||||
(let [current-state (atom state/empty-state)
|
||||
state-changes (view/render-app current-state)
|
||||
to-remote (chan)
|
||||
from-remote (chan)]
|
||||
(ws/connect-client! to-remote from-remote)
|
||||
(state/init-client from-remote to-remote state-changes current-state)))
|
||||
|
||||
(wire-channels-together)
|
||||
(init-app)
|
||||
|
|
|
@ -1,78 +0,0 @@
|
|||
(ns grub.state
|
||||
(:require [grub.view.app :as view]
|
||||
[cljs.core.async :as a :refer [<! >! chan]])
|
||||
(:require-macros [grub.macros :refer [log logs]]
|
||||
[cljs.core.async.macros :refer [go go-loop]]))
|
||||
|
||||
(def app-state (atom {:grubs {}
|
||||
:recipes {}}))
|
||||
|
||||
(defmulti handle-event (fn [event state] (:event event))
|
||||
:default :unknown-event)
|
||||
|
||||
(defmethod handle-event :unknown-event [event state]
|
||||
state)
|
||||
|
||||
(defn new-grub [id grub completed]
|
||||
{:id id :grub grub :completed completed})
|
||||
|
||||
(defmethod handle-event :add-grub [event state]
|
||||
(let [grub (new-grub (:id event) (:grub event) (:completed event))]
|
||||
(assoc-in state [:grubs (:id grub)] grub)))
|
||||
|
||||
(defn map-by-key [key coll]
|
||||
(->> coll
|
||||
(map (fn [a] [(get a key) a]))
|
||||
(into {})))
|
||||
|
||||
(defmethod handle-event :add-grub-list [event state]
|
||||
(->> event
|
||||
:grubs
|
||||
(map-by-key :id)
|
||||
(merge (:grubs state))
|
||||
(assoc state :grubs)))
|
||||
|
||||
(defmethod handle-event :update-grub [event state]
|
||||
(let [new-grub-info (dissoc event :event-type)
|
||||
orig-grub (get-in state [:grubs (:id event)])]
|
||||
(assoc-in state [:grubs (:id event)] (merge orig-grub new-grub-info))))
|
||||
|
||||
(defmethod handle-event :clear-all-grubs [event state]
|
||||
(assoc state :grubs {}))
|
||||
|
||||
(defmethod handle-event :remove-grub [event state]
|
||||
(assoc state :grubs (dissoc (:grubs state) (:id event))))
|
||||
|
||||
(defn new-recipe [id name grubs]
|
||||
{:id id :name name :grubs grubs})
|
||||
|
||||
(defmethod handle-event :add-recipe [event state]
|
||||
(let [recipe (new-recipe (:id event) (:name event) (:grubs event))]
|
||||
(assoc-in state [:recipes (:id recipe)] recipe)))
|
||||
|
||||
(defmethod handle-event :add-recipe-list [event state]
|
||||
(->> event
|
||||
:recipes
|
||||
(map-by-key :id)
|
||||
(merge (:recipes state))
|
||||
(assoc state :recipes)))
|
||||
|
||||
(defmethod handle-event :update-recipe [event state]
|
||||
(-> state
|
||||
(assoc-in [:recipes (:id event) :name] (:name event))
|
||||
(assoc-in [:recipes (:id event) :grubs] (:grubs event))))
|
||||
|
||||
(defmethod handle-event :remove-recipe [event state]
|
||||
(assoc state :recipes (dissoc (:recipes state) (:id event))))
|
||||
|
||||
(defn update-state-and-render [remote]
|
||||
(let [out (chan)
|
||||
view-events (view/render-app app-state)]
|
||||
(go-loop []
|
||||
(let [[event ch] (alts! [remote view-events])
|
||||
new-state (handle-event event @app-state)]
|
||||
(reset! app-state new-state)
|
||||
(when (= ch view-events)
|
||||
(>! out event))
|
||||
(recur)))
|
||||
out))
|
|
@ -1,10 +1,10 @@
|
|||
(ns grub.view.app
|
||||
(:require [om.core :as om :include-macros true]
|
||||
[sablono.core :refer-macros [html]]
|
||||
[cljs.core.async :as a :refer [<! put! chan]]
|
||||
[grub.view.dom :as dom]
|
||||
(:require [grub.view.dom :as dom]
|
||||
[grub.view.grub-list :as grub-list]
|
||||
[grub.view.recipe-list :as recipe-list])
|
||||
[grub.view.recipe-list :as recipe-list]
|
||||
[om.core :as om :include-macros true]
|
||||
[sablono.core :refer-macros [html]]
|
||||
[cljs.core.async :as a :refer [<! put! chan]])
|
||||
(:require-macros [grub.macros :refer [log logs]]
|
||||
[cljs.core.async.macros :refer [go go-loop]]))
|
||||
|
||||
|
@ -26,29 +26,16 @@
|
|||
(dom/on-window-scroll #(put! >events {:type :body-scroll :event %}))))))
|
||||
|
||||
(defn render-app [state]
|
||||
(let [grub-add (chan)
|
||||
grub-update (chan)
|
||||
grub-clear-all (chan)
|
||||
grub-remove (chan)
|
||||
recipe-add (chan)
|
||||
recipe-add-grubs (chan)
|
||||
recipe-update (chan)
|
||||
recipe-remove (chan)
|
||||
out (a/merge [grub-add grub-update grub-clear-all grub-remove
|
||||
recipe-add recipe-add-grubs recipe-update recipe-remove])
|
||||
>events (chan)
|
||||
<events (a/pub >events :type)]
|
||||
(let [>events (chan)
|
||||
<events (a/pub >events :type)
|
||||
add-grubs-ch (chan)
|
||||
state-changes (chan)]
|
||||
(om/root app-view
|
||||
state
|
||||
{:target (.getElementById js/document "container")
|
||||
:shared {:grub-add grub-add
|
||||
:grub-update grub-update
|
||||
:grub-clear-all grub-clear-all
|
||||
:grub-remove grub-remove
|
||||
:recipe-add recipe-add
|
||||
:recipe-add-grubs recipe-add-grubs
|
||||
:recipe-update recipe-update
|
||||
:recipe-remove recipe-remove
|
||||
:>events >events
|
||||
:<events <events}})
|
||||
out))
|
||||
:shared {:>events >events
|
||||
:<events <events
|
||||
:add-grubs-ch add-grubs-ch}
|
||||
:tx-listen (fn [{:keys [new-state]} _]
|
||||
(put! state-changes new-state))})
|
||||
state-changes))
|
||||
|
|
|
@ -1,38 +1,17 @@
|
|||
(ns grub.view.grub
|
||||
(:require [om.core :as om :include-macros true]
|
||||
(:require [grub.view.dom :as dom]
|
||||
[om.core :as om :include-macros true]
|
||||
[sablono.core :as html :refer-macros [html]]
|
||||
[cljs.core.async :as a :refer [<! put! chan]]
|
||||
[grub.view.dom :as dom]
|
||||
[cljs-uuid.core :as uuid])
|
||||
(:require-macros [grub.macros :refer [log logs]]
|
||||
[cljs.core.async.macros :refer [go go-loop]]))
|
||||
|
||||
(defn new-grub [grub]
|
||||
(defn new-grub [text]
|
||||
{:id (str "grub-" (uuid/make-random))
|
||||
:grub grub
|
||||
:text text
|
||||
:completed false})
|
||||
|
||||
(defn add-event [grub]
|
||||
(assoc (new-grub grub) :event :add-grub))
|
||||
|
||||
(defn add-list-event [grubs]
|
||||
{:event :add-grub-list
|
||||
:grubs grubs})
|
||||
|
||||
(defn edit-event [id grub]
|
||||
{:event :update-grub
|
||||
:id id
|
||||
:grub grub})
|
||||
|
||||
(defn complete-event [{:keys [id completed]}]
|
||||
{:event :update-grub
|
||||
:id id
|
||||
:completed (not completed)})
|
||||
|
||||
(defn remove-event [id]
|
||||
{:event :remove-grub
|
||||
:id id})
|
||||
|
||||
(def transitions
|
||||
{:waiting {:mouse-down :pressed
|
||||
:touch-start :pressed}
|
||||
|
@ -54,21 +33,22 @@
|
|||
timeout-id (js/setTimeout timeout-fn 500)]
|
||||
(om/set-state! owner :timeout-id timeout-id))
|
||||
[:pressed :waiting] (js/clearTimeout (om/get-state owner :timeout-id))
|
||||
[:editing :waiting] (let [update-ch (om/get-shared owner :grub-update)
|
||||
id (:id @(om/get-props owner))
|
||||
edit-event (edit-event id (om/get-state owner :grub))]
|
||||
(put! update-ch edit-event))
|
||||
[:editing :waiting] (let [grub (om/get-props owner)]
|
||||
(om/transact! grub #(assoc % :text (om/get-state owner :grub-text))))
|
||||
nil)
|
||||
(om/set-state! owner :edit-state next)))
|
||||
(when-not (= current next) (om/set-state! owner :edit-state next))))
|
||||
|
||||
(defn view [{:keys [id grub completed] :as props} owner]
|
||||
(defn view [{:keys [id text completed] :as grub} owner {:keys [remove-ch]}]
|
||||
(reify
|
||||
om/IInitState
|
||||
(init-state [_]
|
||||
(let [publisher (chan)]
|
||||
{:edit-state :waiting
|
||||
:grub grub
|
||||
:unmounted false}))
|
||||
{:edit-state :waiting
|
||||
:grub-text text
|
||||
:unmounted false})
|
||||
|
||||
om/IWillReceiveProps
|
||||
(will-receive-props [this {:keys [text]}]
|
||||
(om/set-state! owner :grub-text text))
|
||||
|
||||
om/IRenderState
|
||||
(render-state [_ {:keys [edit-state] :as state}]
|
||||
|
@ -77,9 +57,9 @@
|
|||
{:class [(when completed "completed")
|
||||
(when (= edit-state :pressed) "grub-active")
|
||||
(when (= edit-state :editing) "edit")]
|
||||
:on-click #(when (#{:waiting :pressed} edit-state)
|
||||
(put! (om/get-shared owner :grub-update) (complete-event @props))
|
||||
(.blur (om/get-node owner :grub-input)))
|
||||
:on-click (fn [e] (when (#{:waiting :pressed} edit-state)
|
||||
(om/transact! grub #(assoc % :completed (not completed)))
|
||||
(.blur (om/get-node owner :grub-input))))
|
||||
:on-mouse-down #(transition-state owner :mouse-down)
|
||||
:on-mouse-up #(transition-state owner :mouse-up)
|
||||
:on-mouse-leave #(transition-state owner :mouse-leave)
|
||||
|
@ -90,12 +70,12 @@
|
|||
{:type "text"
|
||||
:readOnly (if (= edit-state :editing) "" "readonly")
|
||||
:ref :grub-input
|
||||
:value (:grub state)
|
||||
:on-change #(om/set-state! owner :grub (.. % -target -value))
|
||||
:value (:grub-text state)
|
||||
:on-change #(om/set-state! owner :grub-text (.. % -target -value))
|
||||
:on-key-up #(when (dom/enter-pressed? %) (transition-state owner :enter))}]
|
||||
(when (= edit-state :editing)
|
||||
[:span.glyphicon.glyphicon-remove.pull-right
|
||||
{:on-click #(put! (om/get-shared owner :grub-remove) (remove-event id))}])]))
|
||||
{:on-click #(put! remove-ch id)}])]))
|
||||
|
||||
om/IDidMount
|
||||
(did-mount [_]
|
||||
|
|
|
@ -1,34 +1,37 @@
|
|||
(ns grub.view.grub-list
|
||||
(:require [om.core :as om :include-macros true]
|
||||
[sablono.core :as html :refer-macros [html]]
|
||||
[cljs.core.async :as a :refer [<! put! chan]]
|
||||
[grub.view.dom :as dom]
|
||||
(:require [grub.view.dom :as dom]
|
||||
[grub.view.grub :as grub-view]
|
||||
[cljs-uuid.core :as uuid])
|
||||
[om.core :as om :include-macros true]
|
||||
[sablono.core :as html :refer-macros [html]]
|
||||
[cljs.core.async :as a :refer [<! chan]])
|
||||
(:require-macros [grub.macros :refer [log logs]]
|
||||
[cljs.core.async.macros :refer [go go-loop]]))
|
||||
|
||||
(defn get-grub-ingredient [grub]
|
||||
(when-not (nil? (:grub grub))
|
||||
(let [text (clojure.string/lower-case (:grub grub))
|
||||
(when-not (nil? (:text grub))
|
||||
(let [text (clojure.string/lower-case (:text grub))
|
||||
match (re-find #"[a-z]{3}.*$" text)]
|
||||
match)))
|
||||
|
||||
(defn sort-grubs [grubs]
|
||||
(sort-by (juxt :completed get-grub-ingredient :grub) (vals grubs)))
|
||||
(->> grubs
|
||||
(vals)
|
||||
(sort-by (juxt :completed get-grub-ingredient :text))))
|
||||
|
||||
(defn add-grub [add new-grub owner]
|
||||
(when (not (empty? new-grub))
|
||||
(om/set-state! owner :new-grub "")
|
||||
(put! add (grub-view/add-event new-grub))))
|
||||
(defn add-grub [owner grubs new-grub-text]
|
||||
(when (not (empty? new-grub-text))
|
||||
(let [new-grub (grub-view/new-grub new-grub-text)]
|
||||
(om/set-state! owner :new-grub-text "")
|
||||
(om/transact! grubs #(assoc % (:id new-grub) new-grub)))))
|
||||
|
||||
(defn view [props owner]
|
||||
(defn view [grubs owner]
|
||||
(reify
|
||||
om/IInitState
|
||||
(init-state [_]
|
||||
{:new-grub ""})
|
||||
{:new-grub-text ""
|
||||
:remove-grub-ch (chan)})
|
||||
om/IRenderState
|
||||
(render-state [this {:keys [new-grub] :as state}]
|
||||
(render-state [this {:keys [new-grub-text remove-grub-ch] :as state}]
|
||||
(let [add (om/get-shared owner :grub-add)]
|
||||
(html
|
||||
[:div
|
||||
|
@ -38,22 +41,35 @@
|
|||
[:input.form-control#add-grub-input
|
||||
{:type "text"
|
||||
:placeholder "What do you need?"
|
||||
:value new-grub
|
||||
:value new-grub-text
|
||||
:on-key-up #(when (dom/enter-pressed? %)
|
||||
(add-grub add new-grub owner))
|
||||
:on-change #(om/set-state! owner :new-grub (dom/event-val %))}]]
|
||||
(add-grub owner grubs new-grub-text))
|
||||
:on-change #(om/set-state! owner :new-grub-text (dom/event-val %))}]]
|
||||
[:button.btn.btn-primary
|
||||
{:id "add-grub-btn"
|
||||
:type "button"
|
||||
:on-click #(add-grub add new-grub owner)}
|
||||
[:span.glyphicon.glyphicon-plus]]]
|
||||
:on-click #(add-grub owner grubs new-grub-text)}
|
||||
[:span.glyphicon.glyphicon-plus#add-grub-btn]]]
|
||||
[:ul#grub-list.list-group
|
||||
(for [grub (sort-grubs props)]
|
||||
(om/build grub-view/view grub {:key :id}))]
|
||||
(for [grub (sort-grubs grubs)]
|
||||
(om/build grub-view/view grub {:key :id :opts {:remove-ch remove-grub-ch}}))]
|
||||
[:button.btn.pull-right
|
||||
{:id "clear-all-btn"
|
||||
:class (when (empty? props) "hidden")
|
||||
:class (when (empty? grubs) "hidden")
|
||||
:type "button"
|
||||
:on-click #(put! (om/get-shared owner :grub-clear-all)
|
||||
{:event :clear-all-grubs})}
|
||||
"Clear all"]])))))
|
||||
:on-click #(om/update! grubs {})}
|
||||
"Clear all"]])))
|
||||
om/IWillMount
|
||||
(will-mount [_]
|
||||
(let [add-grubs-ch (om/get-shared owner :add-grubs-ch)
|
||||
remove-grub-ch (om/get-state owner :remove-grub-ch)]
|
||||
(go-loop []
|
||||
(let [grubs-map (<! add-grubs-ch)]
|
||||
(when-not (nil? grubs-map)
|
||||
(om/transact! grubs #(merge % grubs-map))
|
||||
(recur))))
|
||||
(go-loop []
|
||||
(let [id (<! remove-grub-ch)]
|
||||
(when-not (nil? id)
|
||||
(om/transact! grubs #(dissoc % id))
|
||||
(recur))))))))
|
||||
|
|
|
@ -1,29 +1,19 @@
|
|||
(ns grub.view.recipe
|
||||
(:require [om.core :as om :include-macros true]
|
||||
(:require [grub.view.dom :as dom]
|
||||
[grub.view.grub :as grub-view]
|
||||
[grub.util :as util]
|
||||
[om.core :as om :include-macros true]
|
||||
[sablono.core :as html :refer-macros [html]]
|
||||
[cljs.core.async :as a :refer [<! put! chan]]
|
||||
[cljs-uuid.core :as uuid]
|
||||
[grub.view.dom :as dom]
|
||||
[grub.view.grub :as grub-view])
|
||||
[cljs-uuid.core :as uuid])
|
||||
(:require-macros [grub.macros :refer [log logs]]
|
||||
[cljs.core.async.macros :refer [go go-loop]]))
|
||||
|
||||
(defn add-event [name grubs]
|
||||
{:event :add-recipe
|
||||
:id (str "recipe-" (uuid/make-random))
|
||||
(defn new-recipe [name grubs]
|
||||
{:id (str "recipe-" (uuid/make-random))
|
||||
:name name
|
||||
:grubs grubs})
|
||||
|
||||
(defn update-event [id name grubs]
|
||||
{:event :update-recipe
|
||||
:id id
|
||||
:name name
|
||||
:grubs grubs})
|
||||
|
||||
(defn remove-event [id]
|
||||
{:event :remove-recipe
|
||||
:id id})
|
||||
|
||||
(defn parse-grubs-from-str [grubs-str]
|
||||
(->> grubs-str
|
||||
(clojure.string/split-lines)
|
||||
|
@ -32,8 +22,8 @@
|
|||
|
||||
(defn add-grubs [add-grubs-ch grubs-str]
|
||||
(let [grubs (parse-grubs-from-str grubs-str)
|
||||
event (grub-view/add-list-event grubs)]
|
||||
(put! add-grubs-ch event)))
|
||||
grubs-map (util/map-by-key :id grubs)]
|
||||
(put! add-grubs-ch grubs-map)))
|
||||
|
||||
(def transitions
|
||||
{:waiting {:click :editing}
|
||||
|
@ -44,38 +34,35 @@
|
|||
(let [current (om/get-state owner :edit-state)
|
||||
next (or (get-in transitions [current event]) current)]
|
||||
(condp = [current next]
|
||||
[:editing :waiting] (let [update-ch (om/get-shared owner :recipe-update)
|
||||
id (:id @(om/get-props owner))
|
||||
[:editing :waiting] (let [recipe (om/get-props owner)
|
||||
name (om/get-state owner :name)
|
||||
|
||||
grubs (om/get-state owner :grubs)
|
||||
event (update-event id name grubs)]
|
||||
(put! update-ch event))
|
||||
grubs (om/get-state owner :grubs)]
|
||||
(om/transact! recipe #(assoc % :name name :grubs grubs)))
|
||||
nil)
|
||||
(om/set-state! owner :edit-state next)))
|
||||
(when-not (= current next) (om/set-state! owner :edit-state next))))
|
||||
|
||||
(defn num-newlines [str]
|
||||
(count (re-seq #"\n" str)))
|
||||
|
||||
(defn view [{:keys [id] :as props} owner]
|
||||
(defn view [{:keys [id] :as recipe} owner {:keys [remove-recipe-ch]}]
|
||||
(reify
|
||||
om/IInitState
|
||||
(init-state [_]
|
||||
(let [publisher (chan)]
|
||||
{:edit-state :waiting
|
||||
:name (:name props)
|
||||
:grubs (:grubs props)
|
||||
:name (:name recipe)
|
||||
:grubs (:grubs recipe)
|
||||
:unmounted false}))
|
||||
|
||||
om/IWillReceiveProps
|
||||
(will-receive-props [this next-props]
|
||||
(om/set-state! owner :name (:name next-props))
|
||||
(om/set-state! owner :grubs (:grubs next-props)))
|
||||
(will-receive-props [this next-recipe]
|
||||
(om/set-state! owner :name (:name next-recipe))
|
||||
(om/set-state! owner :grubs (:grubs next-recipe)))
|
||||
|
||||
om/IRenderState
|
||||
(render-state [this {:keys [edit-state name grubs]}]
|
||||
(let [update (om/get-shared owner :recipe-update)
|
||||
add-grubs-ch (om/get-shared owner :recipe-add-grubs)]
|
||||
(let [update (om/get-shared owner :recipe-update)]
|
||||
(html
|
||||
[:div.panel.panel-default.recipe-panel
|
||||
{:on-click
|
||||
|
@ -83,16 +70,17 @@
|
|||
(dom/click-on-elem? % (om/get-node owner :save-btn))))
|
||||
(transition-state owner :click))}
|
||||
[:div.panel-heading.recipe-header
|
||||
{:class (when (= edit-state :editing) "edit")}
|
||||
[:input.form-control.recipe-header-input
|
||||
{:type "text"
|
||||
:readOnly (if (= edit-state :editing) "" "readonly")
|
||||
:value name
|
||||
:on-change #(om/set-state! owner :name (dom/event-val %))}]
|
||||
[:button.btn.btn-primary.btn-sm.recipe-add-grubs-btn
|
||||
[:button.btn.btn-primary.btn-sm.recipe-add-grubs-btn
|
||||
{:type "button"
|
||||
:class (when (= edit-state :editing) "hidden")
|
||||
:ref :add-grubs-btn
|
||||
:on-click #(add-grubs add-grubs-ch grubs)}
|
||||
:on-click #(add-grubs (om/get-shared owner :add-grubs-ch) grubs)}
|
||||
[:span.glyphicon.glyphicon-plus]
|
||||
" Grubs"]]
|
||||
[:div.panel-body.recipe-grubs
|
||||
|
@ -105,7 +93,7 @@
|
|||
:on-change #(om/set-state! owner :grubs (dom/event-val %))}]
|
||||
[:button.btn.btn-danger.pull-left.recipe-remove-btn
|
||||
{:type "button"
|
||||
:on-click #(put! (om/get-shared owner :recipe-remove) (remove-event id))}
|
||||
:on-click #(put! remove-recipe-ch id)}
|
||||
[:span.glyphicon.glyphicon-trash]]
|
||||
[:button.btn.btn-primary.pull-right.recipe-done-btn
|
||||
{:type "button"
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
(ns grub.view.recipe-list
|
||||
(:require [om.core :as om :include-macros true]
|
||||
[sablono.core :as html :refer-macros [html]]
|
||||
[cljs.core.async :as a :refer [<! put! chan]]
|
||||
[cljs-uuid.core :as uuid]
|
||||
[grub.view.dom :as dom]
|
||||
(:require [grub.view.dom :as dom]
|
||||
[grub.view.grub :as grub-view]
|
||||
[grub.view.recipe :as recipe])
|
||||
[grub.view.recipe :as recipe]
|
||||
[om.core :as om :include-macros true]
|
||||
[sablono.core :as html :refer-macros [html]]
|
||||
[cljs.core.async :as a :refer [<! chan]]
|
||||
[cljs-uuid.core :as uuid])
|
||||
(:require-macros [grub.macros :refer [log logs]]
|
||||
[cljs.core.async.macros :refer [go go-loop]]))
|
||||
|
||||
(defn add-recipe [ch name grubs owner]
|
||||
(defn add-recipe [owner name grubs]
|
||||
(when (and (not (empty? name))
|
||||
(not (empty? grubs)))
|
||||
(om/set-state! owner :new-recipe-name "")
|
||||
(om/set-state! owner :new-recipe-grubs "")
|
||||
(put! ch (recipe/add-event name grubs))))
|
||||
(let [recipes (om/get-props owner)
|
||||
new-recipe (recipe/new-recipe name grubs)]
|
||||
(om/set-state! owner :new-recipe-name "")
|
||||
(om/set-state! owner :new-recipe-grubs "")
|
||||
(om/transact! recipes #(assoc % (:id new-recipe) new-recipe)))))
|
||||
|
||||
(def transitions
|
||||
{:waiting {:click :editing}
|
||||
|
@ -28,7 +30,7 @@
|
|||
[:editing :save :waiting] (let [add-ch (om/get-shared owner :recipe-add)
|
||||
name (om/get-state owner :new-recipe-name)
|
||||
grubs (om/get-state owner :new-recipe-grubs)]
|
||||
(add-recipe add-ch name grubs owner))
|
||||
(add-recipe owner name grubs))
|
||||
nil)
|
||||
(om/set-state! owner :edit-state next)))
|
||||
|
||||
|
@ -36,11 +38,10 @@
|
|||
(reify
|
||||
om/IInitState
|
||||
(init-state [_]
|
||||
(let [publisher (chan)]
|
||||
{:edit-state :waiting
|
||||
:new-recipe-name ""
|
||||
:new-recipe-grubs ""
|
||||
:unmounted false}))
|
||||
{:edit-state :waiting
|
||||
:new-recipe-name ""
|
||||
:new-recipe-grubs ""
|
||||
:unmounted false})
|
||||
|
||||
om/IRenderState
|
||||
(render-state [this {:keys [edit-state new-recipe-name new-recipe-grubs]}]
|
||||
|
@ -48,7 +49,7 @@
|
|||
[:div.panel.panel-default.recipe-panel
|
||||
{:on-click #(when (not (dom/click-on-elem? % (om/get-node owner :save-btn)))
|
||||
(transition-state owner :click))}
|
||||
[:div.panel-heading.recipe-header
|
||||
[:div.panel-heading.recipe-header.new
|
||||
[:input.form-control.recipe-header-input
|
||||
{:id "new-recipe-name"
|
||||
:type "text"
|
||||
|
@ -67,7 +68,7 @@
|
|||
{:type "button"
|
||||
:ref :save-btn
|
||||
:on-click #(transition-state owner :save)}
|
||||
"Save"]]]))
|
||||
[:span.glyphicon.glyphicon-ok#save-recipe-btn]]]]))
|
||||
|
||||
om/IWillMount
|
||||
(will-mount [_]
|
||||
|
@ -92,12 +93,24 @@
|
|||
|
||||
(defn view [recipes owner]
|
||||
(reify
|
||||
om/IRender
|
||||
(render [this]
|
||||
om/IInitState
|
||||
(init-state [_]
|
||||
{:remove-recipe-ch (chan)})
|
||||
om/IRenderState
|
||||
(render-state [_ {:keys [remove-recipe-ch]}]
|
||||
(html
|
||||
[:div
|
||||
[:h3.recipes-title "Recipes"]
|
||||
(om/build new-recipe-view recipes)
|
||||
[:ul#recipe-list.list-group.recipe-list
|
||||
(for [recipe (vals recipes)]
|
||||
(om/build recipe/view recipe {:key :id}))]]))))
|
||||
(om/build recipe/view
|
||||
recipe
|
||||
{:key :id :opts {:remove-recipe-ch remove-recipe-ch}}))]]))
|
||||
om/IWillMount
|
||||
(will-mount [_]
|
||||
(let [remove-recipe-ch (om/get-state owner :remove-recipe-ch)]
|
||||
(go-loop []
|
||||
(let [removed-id (<! remove-recipe-ch)]
|
||||
(when-not (nil? removed-id)
|
||||
(om/transact! recipes #(dissoc % removed-id)))))))))
|
||||
|
|
|
@ -3,43 +3,43 @@
|
|||
[cljs.reader]
|
||||
goog.net.WebSocket
|
||||
goog.events.EventHandler
|
||||
goog.events.EventTarget)
|
||||
goog.events.EventTarget
|
||||
[hasch.core :as hasch])
|
||||
(:require-macros [cljs.core.async.macros :refer [go go-loop]]
|
||||
[grub.macros :refer [log logs]]))
|
||||
|
||||
(def websocket* (atom nil))
|
||||
(def pending-events (atom []))
|
||||
(def server-url (str "ws://" (.-host (.-location js/document))))
|
||||
(def pending-msg (atom nil))
|
||||
|
||||
(defn on-connected [event]
|
||||
(defn send-pending-msg [websocket]
|
||||
(when (and (.isOpen websocket)
|
||||
(not (nil? @pending-msg)))
|
||||
(logs "Send message:" @pending-msg)
|
||||
(.send websocket (pr-str @pending-msg))
|
||||
(reset! pending-msg nil)))
|
||||
|
||||
(defn on-connected [websocket event]
|
||||
(log "Connected:" event)
|
||||
(when (> (count @pending-events))
|
||||
(doseq [event @pending-events] (.send @websocket* event))
|
||||
(reset! pending-events [])))
|
||||
(send-pending-msg websocket))
|
||||
|
||||
(defn send-outgoing-events [ch]
|
||||
(go-loop []
|
||||
(let [event (<! ch)]
|
||||
(if (.isOpen @websocket*)
|
||||
(.send @websocket* event)
|
||||
(swap! pending-events conj event))
|
||||
(recur))))
|
||||
(defn on-message [from event]
|
||||
(let [msg (cljs.reader/read-string (.-message event))]
|
||||
(a/put! from msg)))
|
||||
|
||||
(defn on-message-fn [out]
|
||||
(fn [event]
|
||||
(let [grub-event (cljs.reader/read-string (.-message event))]
|
||||
(go (>! out grub-event)))))
|
||||
(def ws (atom nil))
|
||||
|
||||
|
||||
(defn get-remote-chan [to-remote]
|
||||
(let [server-url (str "ws://" (.-host (.-location js/document)))
|
||||
handler (goog.events.EventHandler.)
|
||||
remote-events (chan)]
|
||||
(reset! websocket* (goog.net.WebSocket.))
|
||||
(.listen handler @websocket* goog.net.WebSocket.EventType.OPENED on-connected false)
|
||||
(.listen handler @websocket* goog.net.WebSocket.EventType.MESSAGE (on-message-fn remote-events) false)
|
||||
(.listen handler @websocket* goog.net.WebSocket.EventType.CLOSED #(log "Closed:" %) false)
|
||||
(.listen handler @websocket* goog.net.WebSocket.EventType.ERROR #(log "Error:" %) false)
|
||||
(send-outgoing-events to-remote)
|
||||
(go (>! to-remote {:event :send-all-items}))
|
||||
(.open @websocket* server-url)
|
||||
remote-events))
|
||||
(defn connect-client! [to from]
|
||||
(let [handler (goog.events.EventHandler.)
|
||||
websocket (goog.net.WebSocket.)
|
||||
listen (fn [type fun] (.listen handler websocket type fun false))]
|
||||
(reset! ws websocket)
|
||||
(listen goog.net.WebSocket.EventType.OPENED (partial on-connected websocket))
|
||||
(listen goog.net.WebSocket.EventType.MESSAGE (partial on-message from))
|
||||
(listen goog.net.WebSocket.EventType.CLOSED #(log "Closed:" %))
|
||||
(listen goog.net.WebSocket.EventType.ERROR #(log "Error:" %))
|
||||
(go (loop []
|
||||
(when-let [msg (<! to)]
|
||||
(reset! pending-msg msg)
|
||||
(send-pending-msg websocket)
|
||||
(recur))))
|
||||
(.open websocket server-url)))
|
||||
|
|
30
src/cljx/grub/diff.cljx
Normal file
30
src/cljx/grub/diff.cljx
Normal file
|
@ -0,0 +1,30 @@
|
|||
(ns grub.diff
|
||||
(:require [clojure.data :as data]
|
||||
[clojure.set :as set]))
|
||||
|
||||
(defn deleted [a b]
|
||||
(set/difference (into #{} (keys a)) (into #{} (keys b))))
|
||||
|
||||
(defn updated [a b]
|
||||
(second (data/diff a b)))
|
||||
|
||||
(defn diff-maps [a b]
|
||||
{:deleted (deleted a b)
|
||||
:updated (updated a b)})
|
||||
|
||||
(defn diff-states [prev next]
|
||||
(->> prev
|
||||
(keys)
|
||||
(map (fn [k] [k (diff-maps (k prev) (k next))]))
|
||||
(into {})))
|
||||
|
||||
(defn patch-map [state diff]
|
||||
(-> state
|
||||
(#(apply dissoc % (into [] (:deleted diff))))
|
||||
(#(merge-with merge % (:updated diff)))))
|
||||
|
||||
(defn patch-state [state diff]
|
||||
(->> state
|
||||
(keys)
|
||||
(map (fn [k] [k (patch-map (k state) (k diff))]))
|
||||
(into {})))
|
12
src/cljx/grub/message.cljx
Normal file
12
src/cljx/grub/message.cljx
Normal file
|
@ -0,0 +1,12 @@
|
|||
(ns grub.message)
|
||||
|
||||
(def full-sync-request {:type :full-sync})
|
||||
|
||||
(defn full-sync [state]
|
||||
{:type :full-sync
|
||||
:state state})
|
||||
|
||||
(defn diff-msg [diff hash]
|
||||
{:type :diff
|
||||
:diff diff
|
||||
:hash hash})
|
98
src/cljx/grub/state.cljx
Normal file
98
src/cljx/grub/state.cljx
Normal file
|
@ -0,0 +1,98 @@
|
|||
(ns grub.state
|
||||
(:require [grub.diff :as diff]
|
||||
[grub.message :as message]
|
||||
[grub.sync :as sync]
|
||||
#+clj [clojure.core.async :as a :refer [<! >! chan go]]
|
||||
#+cljs [cljs.core.async :as a :refer [<! >! chan]])
|
||||
#+cljs (:require-macros [grub.macros :refer [log logs]]
|
||||
[cljs.core.async.macros :refer [go]]))
|
||||
|
||||
(defn make-agent
|
||||
([client? in out states] (make-agent client? in out states sync/empty-state))
|
||||
([client? in out states initial-shadow]
|
||||
(go (loop [shadow initial-shadow]
|
||||
(when-let [msg (<! in)]
|
||||
(condp = (:type msg)
|
||||
:diff
|
||||
(let [states* @states
|
||||
shadow (sync/get-history-state states* (:hash msg))]
|
||||
(if shadow
|
||||
(let [new-states (sync/apply-diff states* (:diff msg))
|
||||
new-shadow (diff/patch-state shadow (:diff msg))
|
||||
{:keys [diff hash]} (sync/diff-states (sync/get-current-state new-states) new-shadow)]
|
||||
(when-not (= states* new-states)
|
||||
(reset! states new-states))
|
||||
(when-not (or client? (sync/empty-diff? (:diff msg)))
|
||||
(>! out (message/diff-msg diff hash)))
|
||||
(if client?
|
||||
(recur new-shadow)
|
||||
(recur (sync/get-current-state new-states))))
|
||||
(if client?
|
||||
(do (>! out message/full-sync-request)
|
||||
(recur shadow))
|
||||
(let [state (sync/get-current-state states*)]
|
||||
(>! out (message/full-sync state))
|
||||
(recur state)))))
|
||||
|
||||
:full-sync
|
||||
(if client?
|
||||
(let [state (:state msg)]
|
||||
(reset! states (sync/new-state state))
|
||||
(recur state))
|
||||
(let [state (sync/get-current-state @states)]
|
||||
(>! out (message/full-sync state)) ;; HERE
|
||||
(recur state)))
|
||||
|
||||
:new-state
|
||||
(let [{:keys [diff hash]} (sync/diff-states (:state msg) shadow)]
|
||||
(when-not (sync/empty-diff? diff)
|
||||
(>! out (message/diff-msg diff hash)))
|
||||
(recur shadow))
|
||||
(recur shadow)))))))
|
||||
|
||||
(defn make-server-agent
|
||||
([in out states] (make-agent false in out states))
|
||||
([in out states initial-shadow] (make-agent false in out states initial-shadow)))
|
||||
|
||||
(defn make-client-agent
|
||||
([in out states] (make-agent true in out states))
|
||||
([in out states initial-shadow] (make-agent true in out states initial-shadow)))
|
||||
|
||||
(def states (atom []))
|
||||
(def empty-state sync/empty-state)
|
||||
|
||||
#+clj
|
||||
(defn sync-new-client! [>client <client]
|
||||
(let [client-id (java.util.UUID/randomUUID)
|
||||
state-changes (chan)
|
||||
state-change-events (a/map< (fn [s] {:type :new-state :state s}) state-changes)
|
||||
client-events (chan)]
|
||||
(add-watch states client-id (fn [_ _ _ new-states]
|
||||
(a/put! state-changes (sync/get-current-state new-states))))
|
||||
(a/go-loop []
|
||||
(let [[val _] (a/alts! [<client state-change-events])]
|
||||
(if val
|
||||
(do (>! client-events val)
|
||||
(recur))
|
||||
(do (remove-watch states client-id)
|
||||
(a/close! <client)
|
||||
(a/close! state-change-events)))))
|
||||
(make-server-agent client-events >client states)))
|
||||
|
||||
#+clj
|
||||
(defn init-server [to-db initial-state]
|
||||
(reset! states (sync/new-state initial-state))
|
||||
(add-watch states :to-db (fn [_ _ old-states new-states]
|
||||
(a/put! to-db (sync/get-current-state new-states)))))
|
||||
|
||||
#+cljs
|
||||
(defn init-client [in out state-changes current-state]
|
||||
(reset! states (sync/initial-state {} {}))
|
||||
(add-watch states :render (fn [_ _ _ new-states]
|
||||
(let [new-state (sync/get-current-state new-states)]
|
||||
(reset! current-state new-state))))
|
||||
(a/pipe (a/map< (fn [s]
|
||||
(swap! states sync/add-history-state s)
|
||||
{:type :new-state :state s}) state-changes) in)
|
||||
(make-client-agent in out states)
|
||||
(a/put! out message/full-sync-request))
|
41
src/cljx/grub/sync.cljx
Normal file
41
src/cljx/grub/sync.cljx
Normal file
|
@ -0,0 +1,41 @@
|
|||
(ns grub.sync
|
||||
(:require [grub.diff :as diff]
|
||||
[grub.util :as util]
|
||||
[hasch.core :as hasch]))
|
||||
|
||||
(def empty-state {:grubs {} :recipes {}})
|
||||
|
||||
(defn initial-state [grubs recipes]
|
||||
(let [state {:grubs (util/map-by-key :id grubs)
|
||||
:recipes (util/map-by-key :id recipes)}]
|
||||
[{:state state :hash (hasch/uuid state)}]))
|
||||
|
||||
(defn new-state [state]
|
||||
[{:hash (hasch/uuid state)
|
||||
:state state}])
|
||||
|
||||
(defn get-current-state [states]
|
||||
(:state (last states)))
|
||||
|
||||
(defn get-history-state [states hash]
|
||||
(:state (first (filter #(= (:hash %) hash) states))))
|
||||
|
||||
(defn add-history-state [states new-state]
|
||||
(let [last-hash (:hash (last states))
|
||||
new-hash (hasch/uuid new-state)]
|
||||
(if (= last-hash new-hash)
|
||||
states
|
||||
(conj states {:hash new-hash :state new-state}))))
|
||||
|
||||
(defn diff-states [states shadow]
|
||||
(let [state states;(get-current-state states)
|
||||
]
|
||||
{:hash (hasch/uuid shadow)
|
||||
:diff (diff/diff-states shadow state)}))
|
||||
|
||||
(defn apply-diff [states diff]
|
||||
(let [new-state (diff/patch-state (get-current-state states) diff)]
|
||||
(add-history-state states new-state)))
|
||||
|
||||
(defn empty-diff? [diff]
|
||||
(= diff {:recipes {:deleted #{}, :updated nil}, :grubs {:deleted #{}, :updated nil}}))
|
7
src/cljx/grub/util.cljx
Normal file
7
src/cljx/grub/util.cljx
Normal file
|
@ -0,0 +1,7 @@
|
|||
(ns grub.util)
|
||||
|
||||
(defn map-by-key [key coll]
|
||||
(->> coll
|
||||
(map (fn [a] [(get a key) a]))
|
||||
(into {})))
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
(ns grub.test.integration.core
|
||||
(:require [grub.db :as db]
|
||||
[grub.websocket :as ws]
|
||||
[grub.state :as state]
|
||||
[clojure.core.async :as a :refer [<! >! chan go]]
|
||||
[clj-webdriver.taxi :as taxi]
|
||||
[clj-webdriver.core :as webdriver]
|
||||
[clojure.test :as test]))
|
||||
|
@ -20,7 +22,7 @@
|
|||
|
||||
(defn add-grub [driver grub-text]
|
||||
(taxi/input-text driver "#add-grub-input" grub-text)
|
||||
(taxi/click driver {:text "Add"}))
|
||||
(taxi/click driver "#add-grub-btn"))
|
||||
|
||||
(defn test-grubs-saved-to-server [url driver]
|
||||
(taxi/to driver url)
|
||||
|
@ -31,9 +33,9 @@
|
|||
(taxi/refresh driver)
|
||||
(Thread/sleep 200)
|
||||
(doseq [grub grubs]
|
||||
(test/is (taxi/find-element driver {:text grub})
|
||||
(test/is (taxi/find-element driver {:value grub})
|
||||
"Previously added grubs should be loaded on refresh")))
|
||||
(db/clear-grubs))
|
||||
(db/clear-all))
|
||||
|
||||
(defn test-added-grubs-sync [url driver1 driver2]
|
||||
(taxi/to driver1 url)
|
||||
|
@ -42,7 +44,7 @@
|
|||
(doseq [grub grubs]
|
||||
(add-grub driver1 grub))
|
||||
(doseq [grub grubs]
|
||||
(test/is (taxi/find-element driver2 {:text grub})
|
||||
(test/is (taxi/find-element driver2 {:value grub})
|
||||
"Added grubs should appear in other browser"))))
|
||||
|
||||
(defn get-rand-recipe []
|
||||
|
@ -53,7 +55,7 @@
|
|||
(taxi/click driver "#new-recipe-name")
|
||||
(taxi/input-text driver "#new-recipe-name" name)
|
||||
(taxi/input-text driver "#new-recipe-grubs" grubs)
|
||||
(taxi/click driver {:text "Done"}))
|
||||
(taxi/click driver "#save-recipe-btn"))
|
||||
|
||||
(defn test-added-recipes-sync [url driver1 driver2]
|
||||
(taxi/to driver1 url)
|
||||
|
@ -71,9 +73,9 @@
|
|||
(test-added-recipes-sync site-url driver1 driver2))
|
||||
|
||||
(defn start-db-and-websocket-server! []
|
||||
(let [db-chan (db/connect-and-handle-events "grub-integration-test")]
|
||||
(db/clear-all)
|
||||
(ws/pass-received-events-to-clients-and-db db-chan)))
|
||||
(let [to-db (chan)]
|
||||
(db/connect-and-handle-events to-db "grub-integration-test")
|
||||
(state/init-server to-db (db/get-current-state))))
|
||||
|
||||
(defn run []
|
||||
(println "Starting integration test")
|
||||
|
|
133
src/test/grub/test/unit/diff.clj
Normal file
133
src/test/grub/test/unit/diff.clj
Normal file
|
@ -0,0 +1,133 @@
|
|||
(ns grub.test.unit.diff
|
||||
(:require [grub.diff :as diff]
|
||||
[clojure.test :refer :all]))
|
||||
|
||||
|
||||
(def empty-diff {:grubs {:deleted #{} :updated nil}
|
||||
:recipes {:deleted #{} :updated nil}})
|
||||
|
||||
(deftest diff-empty-states
|
||||
(let [empty-state {:grubs {} :recipes {}}]
|
||||
(is (= empty-diff
|
||||
(diff/diff-states empty-state empty-state)))))
|
||||
|
||||
(deftest diff-equal-states
|
||||
(is (= empty-diff
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}}))))
|
||||
|
||||
(deftest diff-added-grub
|
||||
(is (= {:grubs {:deleted #{}
|
||||
:updated {"id" {:completed false, :text "asdf"}}}
|
||||
:recipes {:deleted #{} :updated nil}}
|
||||
(diff/diff-states {:grubs {} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf" :completed false}} :recipes {}}))))
|
||||
|
||||
(deftest diff-deleted-grub
|
||||
(is (= {:grubs {:deleted #{"id"}
|
||||
:updated nil}
|
||||
:recipes {:deleted #{} :updated nil}}
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {} :recipes {}}))))
|
||||
|
||||
(deftest diff-edited-grub
|
||||
(is (= {:grubs {:deleted #{}
|
||||
:updated {"id" {:text "asdf2"}}}
|
||||
:recipes {:deleted #{} :updated nil}}
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf2" :completed false}} :recipes {}}))))
|
||||
|
||||
(deftest diff-completed-grub
|
||||
(is (= {:grubs {:deleted #{}
|
||||
:updated {"id" {:completed true}}}
|
||||
:recipes {:deleted #{} :updated nil}}
|
||||
(diff/diff-states {:grubs {"id" {:text "asdf" :completed false}} :recipes {}}
|
||||
{:grubs {"id" {:text "asdf" :completed true}} :recipes {}}))))
|
||||
|
||||
(deftest diff-added-recipe
|
||||
(is (= {:grubs {:deleted #{}
|
||||
:updated nil}
|
||||
:recipes {:deleted #{} :updated {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}}}
|
||||
(diff/diff-states {:grubs {} :recipes {}}
|
||||
{:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}}))))
|
||||
|
||||
(deftest diff-edited-recipe
|
||||
(is (= {:grubs {:deleted #{}
|
||||
:updated nil}
|
||||
:recipes {:deleted #{} :updated {"id" {:name "Bleu Cheese Soup" }}}}
|
||||
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}}
|
||||
{:grubs {} :recipes {"id" {:name "Bleu Cheese Soup"
|
||||
:grubs "Some grubs"}}}))))
|
||||
|
||||
(deftest diff-deleted-recipe
|
||||
(is (= {:grubs {:deleted #{} :updated nil}
|
||||
:recipes {:deleted #{"id"} :updated nil}}
|
||||
(diff/diff-states {:grubs {} :recipes {"id" {:name "Blue Cheese Soup"
|
||||
:grubs "Some grubs"}}}
|
||||
{:grubs {} :recipes {}}))))
|
||||
|
||||
(def before-state
|
||||
{:grubs
|
||||
{"grub-same" {:completed false
|
||||
:text "3 garlic cloves"}
|
||||
"grub-completed" {:completed false
|
||||
:text "2 tomatoes"}
|
||||
"grub-updated" {:completed false
|
||||
:text "BBQ sauce"}
|
||||
"grub-deleted" {:completed true
|
||||
:text "diapers"}}
|
||||
:recipes
|
||||
{"recipe-same" {:grubs "3 T. butter\n1 yellow onion\n1 1/2 dl red pepper\n1 dl apple\n3 garlic cloves\n1 t. curry\n3 dl water\n2-2 1/2 T. wheat flour\n1 kasvisliemikuutio\n200 g blue cheese\n2 dl apple juice\n2 dl milk\n1 t. basil\n1 package take-and-bake french bread"
|
||||
:name "Blue Cheese Soup"}
|
||||
"recipe-updated" {:grubs "450 g lean stew beef (lapa/naudan etuselkä), cut into 1-inch cubes\n2 T. vegetable oil\n5 dl water\n2 lihaliemikuutios\n350 ml burgundy (or another red wine)\n1 garlic clove\n1 bay leaf (laakerinlehti)\n1/2 t. basil\n3 carrots\n1 yellow onion\n4 potatoes\n1 cup celery\n2 tablespoons of cornstarch (maissijauho/maizena)"
|
||||
:name "Beef Stew"}
|
||||
"recipe-deleted" {:grubs "8 slices rye bread\n400 g chicken breast\nBBQ sauce\nketchup\nmustard\nbutter\n1 package rocket\n4 tomatoes\n2 red onions\n1 bottle Coca Cola"
|
||||
:name "Chickenburgers"}}})
|
||||
|
||||
(def after-state
|
||||
{:grubs
|
||||
{"grub-same" {:completed false,
|
||||
:text "3 garlic cloves"}
|
||||
"grub-completed" {:completed true,
|
||||
:text "2 tomatoes"}
|
||||
"grub-updated" {:completed false,
|
||||
:text "Ketchup"}
|
||||
"grub-added" {:completed false
|
||||
:text "Toothpaste"}}
|
||||
:recipes
|
||||
{"recipe-same" {:grubs "3 T. butter\n1 yellow onion\n1 1/2 dl red pepper\n1 dl apple\n3 garlic cloves\n1 t. curry\n3 dl water\n2-2 1/2 T. wheat flour\n1 kasvisliemikuutio\n200 g blue cheese\n2 dl apple juice\n2 dl milk\n1 t. basil\n1 package take-and-bake french bread"
|
||||
:name "Blue Cheese Soup"}
|
||||
"recipe-updated" {:grubs "300 g lean stew beef (lapa/naudan etuselkä), cut into 1-inch cubes\n2 T. vegetable oil\n5 dl water\n2 lihaliemikuutios\n400 ml burgundy (or another red wine)\n1 garlic clove\n1 bay leaf (laakerinlehti)\n1/2 t. basil\n2 carrots\n1 yellow onion\n4 potatoes\n1 cup celery\n2 tablespoons of cornstarch (maissijauho/maizena)"
|
||||
:name "Beef Stew"}
|
||||
"recipe-added" {:grubs "400 g ground beef\nhamburger buns\n2 red onions\n4 tomatoes\ncheddar cheese\nketchup\nmustard\npickles\nfresh basil\n1 bottle Coca Cola"
|
||||
:name "Burgers"}}})
|
||||
|
||||
(def expected-diff
|
||||
{:recipes
|
||||
{:deleted #{"recipe-deleted"}
|
||||
:updated
|
||||
{"recipe-added"
|
||||
{:name "Burgers"
|
||||
:grubs
|
||||
"400 g ground beef\nhamburger buns\n2 red onions\n4 tomatoes\ncheddar cheese\nketchup\nmustard\npickles\nfresh basil\n1 bottle Coca Cola"}
|
||||
"recipe-updated"
|
||||
{:grubs
|
||||
"300 g lean stew beef (lapa/naudan etuselkä), cut into 1-inch cubes\n2 T. vegetable oil\n5 dl water\n2 lihaliemikuutios\n400 ml burgundy (or another red wine)\n1 garlic clove\n1 bay leaf (laakerinlehti)\n1/2 t. basil\n2 carrots\n1 yellow onion\n4 potatoes\n1 cup celery\n2 tablespoons of cornstarch (maissijauho/maizena)"}}}
|
||||
:grubs
|
||||
{:deleted #{"grub-deleted"}
|
||||
:updated
|
||||
{"grub-completed" {:completed true}
|
||||
"grub-updated" {:text "Ketchup"}
|
||||
"grub-added"
|
||||
{:completed false :text "Toothpaste"}}}})
|
||||
|
||||
(deftest diff-many-changes
|
||||
(is (= expected-diff (diff/diff-states before-state after-state))))
|
||||
|
||||
(deftest patch-returns-original-state
|
||||
(is
|
||||
(let [diff (diff/diff-states before-state after-state)]
|
||||
(= after-state (diff/patch-state before-state diff)))))
|
206
src/test/grub/test/unit/state.clj
Normal file
206
src/test/grub/test/unit/state.clj
Normal file
|
@ -0,0 +1,206 @@
|
|||
(ns grub.test.unit.state
|
||||
(:require [grub.state :as state]
|
||||
[clojure.test :refer :all]
|
||||
[midje.sweet :refer :all]
|
||||
[hasch.core :as hasch]
|
||||
[clojure.core.async :as a :refer [<!! >!! chan go]]))
|
||||
|
||||
(defn hashed-states [& states]
|
||||
(->> states
|
||||
(map (fn [s] {:hash (hasch/uuid s)
|
||||
:state s}))
|
||||
(into [])))
|
||||
|
||||
(defn states-atom [& states]
|
||||
(atom (apply hashed-states states)))
|
||||
|
||||
(defn <!!? [c]
|
||||
(let [[v p] (a/alts!! [c (a/timeout 100)])]
|
||||
v))
|
||||
|
||||
(fact "Applies diff an returns empty diff when no server changes"
|
||||
(let [states (states-atom
|
||||
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}})
|
||||
msg {:type :diff
|
||||
:diff {:grubs {:updated {"1" {:completed true}} :deleted #{}}}
|
||||
:hash (:hash (first @states))}
|
||||
in (chan 1)
|
||||
out (chan 1)]
|
||||
(state/make-server-agent in out states)
|
||||
(>!! in msg)
|
||||
(let [response (<!!? out)]
|
||||
@states => (hashed-states
|
||||
{:grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
|
||||
{:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}})
|
||||
response => {:type :diff
|
||||
:diff {:grubs {:deleted #{}, :updated nil}
|
||||
:recipes {:deleted #{}, :updated nil}}
|
||||
:hash (:hash (last @states))})))
|
||||
|
||||
(fact "Applies diff and returns changes when server has changed"
|
||||
(let [states (states-atom
|
||||
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}}
|
||||
:recipes {}})
|
||||
msg {:type :diff
|
||||
:diff {:grubs {:updated {"1" {:completed true}} :deleted #{}}}
|
||||
:hash (:hash (first @states))}
|
||||
in (chan 1)
|
||||
out (chan 1)]
|
||||
(state/make-server-agent in out states)
|
||||
(>!! in msg)
|
||||
(let [response (<!!? out)]
|
||||
@states => (hashed-states
|
||||
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}}
|
||||
:recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed true}
|
||||
"2" {:text "3 onions" :completed false}}
|
||||
:recipes {}})
|
||||
response => {:type :diff
|
||||
:diff {:grubs {:deleted #{}
|
||||
:updated {"2" {:completed false, :text "3 onions"}}}
|
||||
:recipes {:deleted #{}, :updated nil}}
|
||||
:hash (hasch/uuid {:grubs {"1" {:text "2 apples" :completed true}}
|
||||
:recipes {}})})))
|
||||
|
||||
(fact "Force full sync if client is out of sync"
|
||||
(let [states (states-atom
|
||||
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}}
|
||||
:recipes {}})
|
||||
msg {:type :diff
|
||||
:diff {:grubs {:updated {"0" {:completed true}} :deleted #{}}}
|
||||
:hash (:hash {:grubs {"0" {:text "milk" :completed false}}
|
||||
:recipes {}})}
|
||||
in (chan 1)
|
||||
out (chan 1)]
|
||||
(state/make-server-agent in out states)
|
||||
(>!! in msg)
|
||||
(let [response (<!!? out)]
|
||||
@states => (hashed-states
|
||||
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}}
|
||||
:recipes {}})
|
||||
response => {:type :full-sync
|
||||
:state {:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}}
|
||||
:recipes {}}})))
|
||||
|
||||
(fact "Full sync if client requests it"
|
||||
(let [states (states-atom
|
||||
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}}
|
||||
:recipes {}})
|
||||
msg {:type :full-sync}
|
||||
in (chan 1)
|
||||
out (chan 1)]
|
||||
(state/make-server-agent in out states)
|
||||
(>!! in msg)
|
||||
(let [response (<!!? out)]
|
||||
@states => (hashed-states
|
||||
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}}
|
||||
:recipes {}})
|
||||
response => {:type :full-sync
|
||||
:state {:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}}
|
||||
:recipes {}}})))
|
||||
|
||||
(fact "Passes diffs of new states to client"
|
||||
(let [states (states-atom
|
||||
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}}
|
||||
:recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}
|
||||
"3" {:text "milk" :completed false}}
|
||||
:recipes {}})
|
||||
client-state {:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
msg {:type :new-state
|
||||
:state (:state (last @states))}
|
||||
in (chan 1)
|
||||
out (chan 1)]
|
||||
(state/make-server-agent in out states client-state)
|
||||
(>!! in msg)
|
||||
(let [response (<!!? out)]
|
||||
@states => (hashed-states
|
||||
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}}
|
||||
:recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed false}
|
||||
"2" {:text "3 onions" :completed false}
|
||||
"3" {:text "milk" :completed false}}
|
||||
:recipes {}})
|
||||
response => {:type :diff
|
||||
:diff {:grubs {:deleted #{}
|
||||
:updated {"2" {:text "3 onions" :completed false}
|
||||
"3" {:text "milk" :completed false}}}
|
||||
:recipes {:deleted #{}, :updated nil}}
|
||||
:hash (hasch/uuid client-state)})))
|
||||
|
||||
(fact "Client-only changes synced with server"
|
||||
(let [client-shadow {:grubs {"1" {:text "2 apples" :completed true}} :recipes {}}
|
||||
client-states (states-atom
|
||||
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed true}} :recipes {}})
|
||||
server-shadow {:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
server-states (states-atom server-shadow)
|
||||
client-in (chan)
|
||||
client-out (chan)
|
||||
server-in (chan)
|
||||
server-out (chan)
|
||||
client-state-changes (chan 1)
|
||||
msg {:type :new-state
|
||||
:state {:grubs {"1" {:text "2 apples" :completed true}} :recipes {}}}]
|
||||
(a/pipe client-out server-in)
|
||||
(a/pipe server-out client-in)
|
||||
(state/make-client-agent client-in client-out client-states server-shadow)
|
||||
(state/make-server-agent server-in server-out server-states client-shadow)
|
||||
(add-watch client-states :test (fn [_ _ _ new-states] (a/put! client-state-changes new-states)))
|
||||
(>!! client-in msg)
|
||||
(<!!? client-state-changes)
|
||||
(:state (last @client-states)) => {:grubs {"1" {:completed true, :text "2 apples"}}
|
||||
:recipes {}}
|
||||
(:state (last @server-states)) => {:grubs {"1" {:completed true, :text "2 apples"}}
|
||||
:recipes {}}))
|
||||
|
||||
(fact "Client and server changes synced"
|
||||
(let [client-shadow {:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
client-states (states-atom
|
||||
{:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
{:grubs {"1" {:text "2 apples" :completed true}} :recipes {}})
|
||||
server-shadow {:grubs {"1" {:text "2 apples" :completed false}} :recipes {}}
|
||||
server-states (states-atom
|
||||
server-shadow
|
||||
{:grubs {"1" {:text "4 apples" :completed false}} :recipes {}})
|
||||
client-in (chan)
|
||||
client-out (chan)
|
||||
server-in (chan)
|
||||
server-out (chan)
|
||||
msg {:type :new-state
|
||||
:state {:grubs {"1" {:text "2 apples" :completed true}} :recipes {}}}
|
||||
client-state-changes (chan 1)]
|
||||
(a/pipe client-out server-in)
|
||||
(a/pipe server-out client-in)
|
||||
(state/make-client-agent client-in client-out client-states server-shadow)
|
||||
(state/make-server-agent server-in server-out server-states client-shadow)
|
||||
(add-watch client-states :test (fn [_ _ _ new-states] (a/put! client-state-changes new-states)))
|
||||
(>!! client-in msg)
|
||||
(<!!? client-state-changes)
|
||||
@client-states => (hashed-states
|
||||
{:grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
|
||||
{:grubs {"1" {:completed true, :text "2 apples"}}, :recipes {}}
|
||||
{:grubs {"1" {:completed true, :text "4 apples"}}, :recipes {}})
|
||||
@server-states => (hashed-states
|
||||
{:grubs {"1" {:completed false, :text "2 apples"}}, :recipes {}}
|
||||
{:grubs {"1" {:completed false, :text "4 apples"}}, :recipes {}}
|
||||
{:grubs {"1" {:completed true, :text "4 apples"}}, :recipes {}})))
|
27
src/test/grub/test/unit/sync.clj
Normal file
27
src/test/grub/test/unit/sync.clj
Normal file
|
@ -0,0 +1,27 @@
|
|||
(ns grub.test.unit.sync
|
||||
(:require [grub.sync :as s]
|
||||
[clojure.test :refer :all]
|
||||
[hasch.core :as hasch]))
|
||||
|
||||
(deftest initial-state
|
||||
(let [grubs [{:id "1" :text "2 bananas" :completed false}
|
||||
{:id "2" :text "3 onions" :completed false}]
|
||||
recipes []
|
||||
expected-state {:grubs {"1" {:id "1" :text "2 bananas" :completed false}
|
||||
"2" {:id "2" :text "3 onions" :completed false}}
|
||||
:recipes {}}
|
||||
expected-hash (hasch/uuid expected-state)]
|
||||
(is (= [{:state expected-state :hash expected-hash}] (s/initial-state grubs recipes)))))
|
||||
|
||||
(deftest get-current-state-returns-last-state
|
||||
(let [states [{:hash "asdf" :state {:a :b}}
|
||||
{:hash "fdsa" :state {:c :d}}]]
|
||||
(is (= {:c :d} (s/get-current-state states)))))
|
||||
|
||||
(deftest get-history-state-returns-state-with-hash
|
||||
(let [states [{:hash "hash1" :state {:a :b}}
|
||||
{:hash "hash2" :state {:c :d}}
|
||||
{:hash "hash3" :state {:e :f}}]]
|
||||
(is (= {:a :b} (s/get-history-state states "hash1")))
|
||||
(is (= {:c :d} (s/get-history-state states "hash2")))
|
||||
(is (= {:e :f} (s/get-history-state states "hash3")))))
|
Loading…
Reference in a new issue