diff --git a/public/css/styles.css b/public/css/styles.css index 81472c5..0a67e5e 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1,3 +1,11 @@ +html, body { + height: 100%; + min-height: 100%; + width: 100%; + min-width: 100%; + margin: 0px; +} + h3 { margin-top: 10px; } @@ -27,16 +35,47 @@ tr:hover .grub-close { user-select: none; } -.grub-item:active { +.grub-item.grub-active { color: #ffffff; background-color: rgb(71, 73, 73); } +.grub-item .input-span { + display: block; + overflow: hidden; +} + +.grub-item .grub-static { + display: inline; +} + +.grub-item .grub-input { + display: none; +} + +.grub-item.edit .grub-static { + display: none; +} + +.grub-item.edit .grub-input { + display: inline; + width: 100%; +} + +.grub-item .glyphicon { + margin-right: 5px; +} + .completed { text-decoration: line-through; color: #a9a9a9; } +.completed.edit { + text-decoration: none; + color: default; +} + #clear-all-btn { float: right; } diff --git a/spec/clj/grub/db_spec.clj b/spec/clj/grub/db_spec.clj index 58a0b1f..a16ea1e 100644 --- a/spec/clj/grub/db_spec.clj +++ b/spec/clj/grub/db_spec.clj @@ -51,6 +51,18 @@ {:_id (:_id test-grub) :completed false} (mc/find-one-as-map db/grub-collection {:_id (:_id test-grub)}))))) + (describe "Update" + (it "should update grub info when an update event comes" + (let [test-grub {:_id 123456 :grub "original"}] + (mc/insert db/grub-collection test-grub) + (>!! @db/incoming-events {:event :update + :_id (:_id test-grub) + :grub "updated"}) + (short-delay) + (should= + {:_id (:_id test-grub) :grub "updated"} + (mc/find-one-as-map db/grub-collection {:_id (:_id test-grub)}))))) + (describe "Delete" (it "should delete a grub when a delete event comes" (let [test-grub {:_id 123456 :completed true}] diff --git a/spec/cljs/state_spec.cljs b/spec/cljs/state_spec.cljs index cdb0a69..87920d7 100644 --- a/spec/cljs/state_spec.cljs +++ b/spec/cljs/state_spec.cljs @@ -56,22 +56,4 @@ clear-all-event {:event :clear-all}] (reset! state/grubs [test-grub]) (state/handle-event clear-all-event) - (should= [] @state/grubs))))) - - (describe - "view event handling" - (describe "Create" - (it "should add a new grub to the state when a create event comes" - (let [test-grub {:grub "testgrub"} - create-event (assoc test-grub :event :create)] - (state/handle-view-event create-event) - (js/setTimeout (fn [] (let [created-grub (first @state/grubs)] - (should= (:grub test-grub) (:grub created-grub))))))) - (it "should generate an _id for the new grub" - (let [test-grub {:grub "testgrub"} - create-event (assoc test-grub :event :create)] - (state/handle-view-event create-event) - (js/setTimeout (fn [] - (let [added-grub (first (filter #(= (:grub %) (:grub test-grub)) - @state/grubs))] - (should-not-be-nil (:_id added-grub)))))))))) + (should= [] @state/grubs)))))) diff --git a/src/clj/grub/db.clj b/src/clj/grub/db.clj index 93cae87..6897f4d 100644 --- a/src/clj/grub/db.clj +++ b/src/clj/grub/db.clj @@ -29,6 +29,11 @@ {:_id (:_id event)} {mo/$set {:completed false}})) +(defmethod handle-event :update [event] + (mc/update grub-collection + {:_id (:_id event)} + {mo/$set {:grub (:grub event)}})) + (defmethod handle-event :delete [event] (mc/remove grub-collection {:_id (:_id event)})) diff --git a/src/cljs/grub/async-utils.cljs b/src/cljs/grub/async-utils.cljs index e4a99f5..601ab8d 100644 --- a/src/cljs/grub/async-utils.cljs +++ b/src/cljs/grub/async-utils.cljs @@ -1,7 +1,15 @@ (ns grub.async-utils - (:require [cljs.core.async :as async :refer [! chan put! alts!]]) - (:require-macros [cljs.core.async.macros :as m :refer [go]] - [grub.macros :refer [go-loop]])) + (:refer-clojure :exclude [map filter]) + (:require [cljs.core.async :as async :refer [! chan put! alts! close!]]) + (:require-macros [cljs.core.async.macros :refer [go]] + [grub.macros :refer [do-chan]])) + +(defn log [in] + (let [out (chan)] + (do-chan [e in] + (.log js/console e) + (>! out e)) + out)) (defn put-all! [cs x] (doseq [c cs] @@ -22,53 +30,57 @@ (defn fan-in ([ins] (fan-in (chan) ins)) - ([c ins] - (go-loop - (let [[x] (alts! ins)] - (>! c x))) - c)) + ([out ins] + (go (loop [ins (vec ins)] + (when (> (count ins) 0) + (let [[x in] (alts! ins)] + (when x + (>! out x) + (recur ins)) + (recur (vec (disj (set ins) in)))))) + (close! out)) + out)) -(defn copy-chan +(defn copy ([c] (first (fan-out c 1))) ([out c] (first (fan-out c [out])))) -(defn event-chan - ([type] (event-chan js/window type)) - ([el type] (event-chan (chan) el type)) - ([c el type] - (let [writer #(put! c %)] - (.addEventListener el type writer) - {:chan c - :unsubscribe #(.removeEventListener el type writer)}))) - -(defn map-chan - ([f source] (map-chan (chan) f source)) - ([c f source] - (go-loop - (>! c (f (! c v)))) - c)) - -(defn do-chan! [f source] - (go-loop - (let [v (! out v))) + (go (loop [] + (if-let [x (! out (f x)) + (recur)) + (close! out)))) out)) + +(defn map-filter [f in] + (let [out (chan)] + (go (loop [] + (if-let [x (! out val)) + (recur)) + (close! out)))) + out)) + +(defn filter [pred in] + (let [out (chan)] + (go (loop [] + (if-let [x (! out x)) + (recur)) + (close! out)))) + out)) + +(defn siphon + ([in] (siphon in [])) + ([in coll] + (go (loop [coll coll] + (if-let [v (! chan timeout close!]]) + (:require-macros [grub.macros :refer [log logs go-loop]] + [dommy.macros :refer [deftemplate sel1 node]] + [cljs.core.async.macros :refer [go]])) + +(defn listen + ([el type] (listen el type nil)) + ([el type f] (listen el type f (chan))) + ([el type f out] + (let [push-fn (fn [e] (when f (f e)) (go (>! out e))) + unlisten #(do (dommy/unlisten! el type push-fn) + (close! out))] + (dommy/listen! el type push-fn) + {:chan out :unlisten unlisten}))) + +(defn listen-once + ([el type] (listen el type nil)) + ([el type f] (listen el type f (chan))) + ([el type f out] + (let [push-fn (fn [e] (when f (f e)) (go (>! out e))) + unlisten #(do (dommy/unlisten! el type push-fn) + (close! out))] + (dommy/listen-once! el type push-fn) + {:chan out :unlisten unlisten}))) + +(def add-grub-text + (node [:input.form-control {:id "add-grub-input" :type "text" :placeholder "2 grubs"}])) + +(def add-grub-btn + (node [:button.btn.btn-primary {:id "add-grub-btn" :type "button"} "Add"])) + +(def clear-all-btn + (node [:button.btn.hidden {:id "clear-all-btn" :type "button"} "Clear all"])) + +(defn make-grub-node [grub] + (node [:li.list-group-item.grub-item + {:id (:_id grub) + :class (when (:completed grub) "completed")} + [:span.grub-static + (if (:completed grub) + [:span.glyphicon.glyphicon-check] + [:span.glyphicon.glyphicon-unchecked]) + [:span.grub-text (:grub grub)]] + [:input.grub-input {:type "text" :value (:grub grub)}]])) + +(defn grubs-selector [] + [(sel1 :#grub-list) :.grub-item]) + +(deftemplate main-template [] + [:div.container + [:div.row.show-grid + [:div.col-lg-4] + [:div.col-lg-4 + [:h3 "Grub List"] + [:div.input-group + add-grub-text + [:span.input-group-btn + add-grub-btn]] + [:ul#grub-list.list-group] + clear-all-btn] + [:div.col-lg-4] + [:div.col-lg-2]]]) + +(defn render-body [] + (dommy/prepend! (sel1 :body) (main-template))) + +(defn render-grub-list [grubs] + (let [grub-list (sel1 :#grub-list) + sorted-grubs (sort-by (juxt :completed :_id) grubs)] + (aset grub-list "innerHTML" "") + (doseq [grub sorted-grubs] + (let [node (make-grub-node grub)] + (dommy/append! grub-list node))))) + +(defn get-add-grub-text [] + (dommy/value add-grub-text)) + +(defn clear-add-grub-text [] + (dommy/set-value! add-grub-text "")) + + +(defprotocol IActivatable + (-activate! [view]) + (-deactivate! [view])) + +(defprotocol IHideable + (-hide! [view]) + (-show! [view])) + +(defprotocol IEditable + (-set-editing! [view]) + (-unset-editing! [view])) + +(extend-type js/HTMLElement + IActivatable + (-activate! [view] + (dommy/add-class! view :grub-active)) + (-deactivate! [view] + (dommy/remove-class! view :grub-active))) + +(extend-type js/HTMLElement + IHideable + (-hide! [view] + (dommy/add-class! view :hidden)) + (-show! [view] + (dommy/remove-class! view :hidden))) + +(extend-type js/HTMLLIElement + IEditable + (-set-editing! [view] + (-deactivate! view) + (dommy/add-class! view :edit) + (.focus (sel1 view :input))) + (-unset-editing! [view] + (dommy/remove-class! view :edit))) + + diff --git a/src/cljs/grub/macros.clj b/src/cljs/grub/macros.clj index 5509ef0..5ec8371 100644 --- a/src/cljs/grub/macros.clj +++ b/src/cljs/grub/macros.clj @@ -11,3 +11,13 @@ `(cljs.core.async.macros/go (while true ~@body))) + +(defmacro do-chan [[binding chan] & body] + `(let [chan# ~chan] + (cljs.core.async.macros/go + (loop [] + (if-let [~binding (cljs.core.async/! incoming-events event)) - (go (>! outgoing-events event))) - -(defmulti handle-view-event :event :default :unknown-event) - -(defmethod handle-view-event :create [event] - (let [create-event (-> event - (assoc :event :add) - (assoc :_id (str "grub-" (.now js/Date))) - (assoc :completed false))] - (pass-on-view-event create-event))) - -(defmethod handle-view-event :unknown-event [event] - (pass-on-view-event event)) - - (defn handle-incoming-events [] - (go-loop (handle-event (! chan]]) - (:require-macros [grub.macros :refer [log logs go-loop]] + [cljs.core.async :refer [! chan timeout close!]]) + (:require-macros [grub.macros :refer [log logs go-loop do-chan]] [dommy.macros :refer [deftemplate sel1 node]] [cljs.core.async.macros :refer [go]])) (def outgoing-events (chan)) -(def add-grub-text - (node [:input.form-control {:id "add-grub-input" :type "text" :placeholder "2 grubs"}])) - -(def add-grub-btn - (node [:button.btn.btn-primary {:id "add-grub-btn" :type "button"} "Add"])) - -(deftemplate main-template [] - [:div.container - [:div.row.show-grid - [:div.col-lg-4] - [:div.col-lg-4 - [:h3 "Grub List"] - [:div.input-group - add-grub-text - [:span.input-group-btn - add-grub-btn]] - [:ul#grub-list.list-group] - [:button.btn.hidden {:id "clear-all-btn" :type "button"} "Clear all"]] - [:div.col-lg-4] - [:div.col-lg-2]]]) - -(defn make-grub-node [grub] - (if (:completed grub) - (node [:li.list-group-item.completed.grub-item {:id (:_id grub)} - [:span.glyphicon.glyphicon-check] - (str " " (:grub grub))]) - (node [:li.list-group-item.grub-item {:id (:_id grub)} - [:span.glyphicon.glyphicon-unchecked] - (str " " (:grub grub))]))) - -(defn render-body [] - (dommy/prepend! (sel1 :body) (main-template))) - -(defprotocol IHideable - (-hide! [view]) - (-show! [view])) - -(extend-type js/HTMLElement - IHideable - (-hide! [view] - (dommy/add-class! view :hidden)) - (-show! [view] - (dommy/remove-class! view :hidden))) - -(defn get-add-grub-text [] - (let [text (dommy/value add-grub-text)] - (dommy/set-value! add-grub-text "") - text)) - (defn get-grubs-from-clicks [] - (let [out (chan)] - (dommy/listen! add-grub-btn :click #(go (>! out (get-add-grub-text)))) - out)) - -(defn put-grubs-if-enter-pressed [out event] - (when (= (.-keyIdentifier event) "Enter") - (go (>! out (get-add-grub-text))))) + (->> (:chan (dom/listen dom/add-grub-btn :click)) + (a/map #(dom/get-add-grub-text)))) (defn get-grubs-from-enter [] - (let [out (chan)] - (dommy/listen! add-grub-text - :keyup - (partial put-grubs-if-enter-pressed out)) - out)) + (->> (:chan (dom/listen dom/add-grub-text :keyup)) + (a/filter #(= (.-keyIdentifier %) "Enter")) + (a/map #(dom/get-add-grub-text)))) -(defn get-added-events [] - (let [grubs (fan-in [(get-grubs-from-clicks) - (get-grubs-from-enter)])] +(defn get-created-events [] + (let [grubs (a/fan-in [(get-grubs-from-clicks) + (get-grubs-from-enter)])] (->> grubs - (filter-chan #(not (empty? %))) - (map-chan (fn [g] {:event :create :grub g}))))) + (a/filter #(not (empty? %))) + (a/map (fn [g] {:grub g}))))) -(defn get-completed-event [event] +(defn get-clear-all-events [] + (:chan (dom/listen dom/clear-all-btn :click))) + +(defn get-grub-mousedown-events [] + (:chan (dom/listen (dom/grubs-selector) :mousedown))) + +(defn get-grub-mouseup-events [] + (:chan (dom/listen (dom/grubs-selector) :mouseup))) + +(defn get-grub-mouseleave-events [] + (:chan (dom/listen (dom/grubs-selector) :mouseleave))) + +(defn get-body-clicks [] + (:chan (dom/listen (sel1 :body) :click))) + +(defn get-enters [] + (->> (:chan (dom/listen (sel1 :body) :keyup)) + (a/filter #(= (.-keyIdentifier %) "Enter")))) + +(defn parse-completed-event [event] (let [target (.-target event) id (.-id target) completed (dommy/has-class? target "completed") event-type (if completed :uncomplete :complete)] {:_id id :event event-type})) - -(defn get-clear-all-events [] - (let [events (chan)] - (dommy/listen! (sel1 :#clear-all-btn) :click #(go (>! events {:event :clear-all}))) - events)) -(defn render-grub-list [grubs] - (let [grub-list (sel1 :#grub-list) - sorted-grubs (sort-by (juxt :completed :_id) grubs)] - (aset grub-list "innerHTML" "") - (doseq [grub sorted-grubs] - (let [node (make-grub-node grub)] - (dommy/listen! node :click #(go (>! outgoing-events (get-completed-event %)))) - (dommy/append! grub-list node))))) - -(defn push-outgoing-events [] - (fan-in outgoing-events [(get-added-events) - (get-clear-all-events)])) - -(defn watch-for-state-changes [] +(defn re-render-when-state-changes [] (add-watch state/grubs :grub-add-watch (fn [key ref old new] (if (empty? new) - (-hide! (sel1 :#clear-all-btn)) - (-show! (sel1 :#clear-all-btn))) - (render-grub-list new)))) + (dom/-hide! dom/clear-all-btn) + (dom/-show! dom/clear-all-btn)) + (dom/render-grub-list new)))) + +(defn event-loop [] + (let [out (chan) + created (get-created-events) + clear-all (get-clear-all-events) + mousedown (get-grub-mousedown-events) + mouseup (get-grub-mouseup-events) + mouseleave (get-grub-mouseleave-events) + body-click (get-body-clicks) + edit (chan) + enter (get-enters)] + (go (loop [mousedown-time nil + edit-elem nil] + (let [[event c] (alts! [created clear-all mousedown + mouseup mouseleave edit body-click + enter])] + (if edit-elem + (cond + (or (and (= c body-click) + (not (dommy/descendant? (.-target event) edit-elem))) + (= c enter)) + (do (dom/-unset-editing! edit-elem) + (let [grub-text (.-value (sel1 edit-elem :.grub-input)) + id (.-id edit-elem)] + (>! out {:event :update + :grub grub-text + :_id id})) + (recur nil nil)) + + :else (recur nil edit-elem)) + (cond + (= c created) + (let [add-event (-> event + (assoc :event :add) + (assoc :_id (str "grub-" (.now js/Date))) + (assoc :completed false))] + (>! out add-event) + (dom/clear-add-grub-text) + (recur mousedown-time edit-elem)) + + (= c clear-all) + (do (>! out {:event :clear-all}) + (recur mousedown-time edit-elem)) + + (= c mousedown) + (do (dom/-activate! (.-selectedTarget event)) + (let [now (.now js/Date)] + (go (! edit {:mousedown-time now :elem (.-selectedTarget event)})) + (recur now edit-elem))) + + (= c mouseup) + (do (dom/-deactivate! (.-selectedTarget event)) + (>! out (parse-completed-event event)) + (recur nil edit-elem)) + + (= c mouseleave) + (do (dom/-deactivate! (.-selectedTarget event)) + (recur nil edit-elem)) + + (= c edit) + (if (and mousedown-time (= (:mousedown-time event) mousedown-time)) + (do (dom/-set-editing! (:elem event)) + (recur nil (:elem event))) + (recur nil edit-elem)) + + :else (recur mousedown-time edit-elem)))))) + out)) (defn init [] - (render-body) - (watch-for-state-changes) - (push-outgoing-events)) + (dom/render-body) + (re-render-when-state-changes) + (a/copy outgoing-events (event-loop))) diff --git a/src/cljs/grub/websocket.cljs b/src/cljs/grub/websocket.cljs index 12b07b6..de4ed13 100644 --- a/src/cljs/grub/websocket.cljs +++ b/src/cljs/grub/websocket.cljs @@ -22,8 +22,8 @@ (go (>! outgoing-events grub-event)))))) (defn connect-to-server [] - (let [full-url (str "ws://" (.-host (.-location js/document)) "/ws")] - (reset! websocket* (js/WebSocket. full-url)) + (let [server-url (str "ws://" (.-host (.-location js/document)) "/ws")] + (reset! websocket* (js/WebSocket. server-url)) (aset @websocket* "onopen" (fn [event] (log "Connected:" event))) (aset @websocket* "onclose" (fn [event] (log "Connection closed:" event))) (aset @websocket* "onerror" (fn [event] (log "Connection error:" event)))