diff --git a/project.clj b/project.clj index d4fd613..4d187d7 100644 --- a/project.clj +++ b/project.clj @@ -23,7 +23,7 @@ :output-to "public/js/grub_dev.js" ;:source-map "public/js/grub_dev.js.map" :optimizations :whitespace - :pretty-print false}} + :pretty-print true}} :prod {:source-paths ["src/cljs"] :compiler {:output-to "public/js/grub.js" :optimizations :simple}}}} diff --git a/public/css/styles.css b/public/css/styles.css index d5de970..efda7ac 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -14,6 +14,10 @@ h3 { clear: right; } +.recipe-list { + margin-top: 10px; +} + .hidden { display: none; } @@ -84,6 +88,11 @@ tr:hover .grub-close { color: default; } +.panel-body { + padding: 10px; + padding-top: 0px; +} + .recipe-panel { padding: 0px; margin-bottom: -1px; @@ -110,14 +119,14 @@ tr:hover .grub-close { outline: none; } -.recipe-done-btn { +.recipe-btn { margin-top: 10px; } -.recipe-steps { +.recipe-grubs { } -.recipe-steps-input { +.recipe-grubs-input { border: none; box-shadow: none; transition: none; @@ -125,13 +134,9 @@ tr:hover .grub-close { resize: none; } -.recipe-steps-input:focus { +.recipe-grubs-input:focus { border: none; box-shadow: none; transition: none; border-bottom: none; } - -#new-recipe { - margin-bottom: 20px; -} diff --git a/src/clj/grub/db.clj b/src/clj/grub/db.clj index 456f087..1d4cec6 100644 --- a/src/clj/grub/db.clj +++ b/src/clj/grub/db.clj @@ -12,36 +12,38 @@ (defmulti handle-event :event :default :unknown-event) -(defmethod handle-event :add [event] +(defmethod handle-event :add-grub [event] (let [grub (-> event (select-keys [:_id :grub :completed]))] (mc/insert grub-collection grub))) -(defmethod handle-event :complete [event] +(defmethod handle-event :complete-grub [event] (mc/update grub-collection {:_id (:_id event)} {mo/$set {:completed true}})) -(defmethod handle-event :uncomplete [event] +(defmethod handle-event :uncomplete-grub [event] (mc/update grub-collection {:_id (:_id event)} {mo/$set {:completed false}})) -(defmethod handle-event :update [event] +(defmethod handle-event :update-grub [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)})) - -(defmethod handle-event :clear-all [event] +(defmethod handle-event :clear-all-grubs [event] (clear-grubs)) (defmethod handle-event :add-recipe [event] (let [recipe (select-keys event [:_id :name :steps])] (mc/insert recipe-collection recipe))) +(defmethod handle-event :update-recipe [event] + (mc/update recipe-collection + {:_id (:_id event)} + {mo/$set {:name (:name event) :grubs (:grubs event)}})) + (defmethod handle-event :unknown-event [event] (println "Cannot handle unknown event:" event)) @@ -50,7 +52,7 @@ sorted-grubs (sort-by :_id (vec grubs)) events (map (fn [g] (-> g (select-keys [:_id :grub :completed]) - (assoc :event :add))) + (assoc :event :add-grub))) sorted-grubs) out (chan)] (a/onto-chan out events) @@ -60,7 +62,7 @@ (let [recipes (mc/find-maps recipe-collection) sorted-recipes (sort-by :_id (vec recipes)) events (map (fn [r] (-> r - (select-keys [:_id :name :steps]) + (select-keys [:_id :name :grubs]) (assoc :event :add-recipe))) sorted-recipes) out (chan)] @@ -72,7 +74,6 @@ (defn handle-incoming-events [in] (a/go-loop [] (let [event (! chan]]) - (: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) - (a/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) - (a/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.pull-right - {: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]) - -(defn make-recipe-node [id name steps] - (node [:div.panel.panel-default.recipe-panel {:id id} - [:div.panel-heading.recipe-header - [:input.form-control.recipe-header-input - {:id "recipe-name" - :type "text" - :placeholder "Grub pie" - :value name}]] - [:div.panel-body.recipe-steps.hidden - [:textarea.form-control.recipe-steps-input - {:id "recipe-steps" - :rows 3 - :placeholder "2 grubs"} - steps] - [:button.btn.btn-primary.recipe-done-btn.hidden.pull-right - {:type "button"} "Done"]]])) - -(defn add-new-recipe [id name steps] - (log "add new recipe:" name "steps:" steps) - (let [node (make-recipe-node id name steps) - recipe-list (sel1 :#recipe-list)] - (logs "node:" node) - (logs "recipe-list:" recipe-list) - (dommy/append! recipe-list node) - node)) - -(def new-recipe (make-recipe-node "new-recipe" "" "")) - -(defn recipes-selector [] - [(sel1 :#recipe-list) :.recipe-panel]) - -(defn recipe-done-btns-selector [] - [(sel1 :body) :.recipe-done-btn]) - -(deftemplate main-template [] - [:div.container - [:div.row - [:div.col-sm-6.leftmost-column - [: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-sm-6 - [:h3.recipes-title "Recipes"] - new-recipe - [:ul#recipe-list.list-group.recipe-list]]]]) - -(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])) - -(defprotocol IExpandable - (-expand! [view]) - (-unexpand! [view])) - -(defprotocol IClearable - (-clear! [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))) - -(extend-type js/HTMLDivElement - IExpandable - (-expand! [view] - (dommy/remove-class! (sel1 view ".recipe-steps") :hidden) - (dommy/remove-class! (sel1 view ".recipe-done-btn") :hidden)) - (-unexpand! [view] - (dommy/add-class! (sel1 view ".recipe-steps") :hidden) - (dommy/add-class! (sel1 view ".recipe-done-btn") :hidden))) - -(extend-type js/HTMLDivElement - IClearable - (-clear! [view] - (dommy/set-value! (sel1 view "#recipe-name") "") - (dommy/set-value! (sel1 view "#recipe-steps") ""))) - diff --git a/src/cljs/grub/macros.clj b/src/cljs/grub/macros.clj index 4b51a8d..afe35a6 100644 --- a/src/cljs/grub/macros.clj +++ b/src/cljs/grub/macros.clj @@ -6,3 +6,29 @@ (defmacro logs [& args] (let [strings (map (fn [a] `(pr-str ~a)) args)] `(.log js/console ~@strings))) + + +;; Maybe monad +(defmacro and-let* [bindings & body] + (when (not= (count bindings) 2) + (throw (IllegalArgumentException. + "and-let* requires an even number of forms in binding vector"))) + (let [form (bindings 0) + tst (bindings 1)] + `(let [temp# ~tst] + (when temp# + (let [~form temp#] + ~@body))))) + +(defmacro and-let [bindings & body] + (when (not (even? (count bindings))) + (throw (IllegalArgumentException. + "and-let requires an even number of forms in binding vector"))) + (let [whenlets (reduce (fn [sexpr bind] + (let [form (first bind) + tst (second bind)] + (conj sexpr `(and-let* [~form ~tst])))) + () + (partition 2 bindings)) + body (cons 'do body)] + `(->> ~body ~@whenlets))) diff --git a/src/cljs/grub/state.cljs b/src/cljs/grub/state.cljs index 77dc5e2..868afbe 100644 --- a/src/cljs/grub/state.cljs +++ b/src/cljs/grub/state.cljs @@ -18,6 +18,7 @@ (defmulti handle-event :event :default :unknown-event) (defmethod handle-event :add [event] + (logs "handle event add:" event) (let [grub (select-keys event [:_id :grub :completed])] (swap! grubs (fn [current] (conj current grub))))) diff --git a/src/cljs/grub/view.cljs b/src/cljs/grub/view.cljs index c0e4bdb..8b54dc2 100644 --- a/src/cljs/grub/view.cljs +++ b/src/cljs/grub/view.cljs @@ -1,313 +1,25 @@ (ns grub.view (:require [grub.state :as state] - [grub.dom :as dom] + [grub.view.dom :as dom] + [grub.view.grub :as grub-view] + [grub.view.recipe :as recipe-view] [dommy.core :as dommy] [cljs.core.async :as a :refer [! chan]]) - (:require-macros [grub.macros :refer [log logs do-chan]] + (:require-macros [grub.macros :refer [log logs and-let]] [dommy.macros :refer [deftemplate sel1 node]] [cljs.core.async.macros :refer [go go-loop]])) -(defn re-render-when-state-changes [] - (add-watch state/grubs - :grub-add-watch - (fn [key ref old new] - (if (empty? new) - (dom/-hide! dom/clear-all-btn) - (dom/-show! dom/clear-all-btn)) - (dom/render-grub-list new)))) - -(defn get-grubs-from-clicks [] - (->> (:chan (dom/listen dom/add-grub-btn :click)) - (a/map< #(dom/get-add-grub-text)))) - -(defn get-grubs-from-enter [] - (->> (:chan (dom/listen dom/add-grub-text :keyup)) - (a/filter< #(= (.-keyIdentifier %) "Enter")) - (a/map< #(dom/get-add-grub-text)))) - -(defn get-created-events [] - (let [grubs (a/merge [(get-grubs-from-clicks) - (get-grubs-from-enter)])] - (->> grubs - (a/filter< #(not (empty? %))) - (a/map< (fn [g] {:grub g}))))) - -(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 get-ctrl-enters [] - (->> (:chan (dom/listen (sel1 :body) :keyup)) - (a/filter< #(and (= (.-keyIdentifier %) "Enter") (.-ctrlKey %))))) - -(defn get-new-recipe-clicks [] - (:chan (dom/listen dom/new-recipe :click))) - -(defn get-edit-recipe-clicks [] - (->> (:chan (dom/listen (dom/recipes-selector) :click)) - (a/map< (fn [e] {:elem (.-selectedTarget e)})))) - -(defn get-recipe-done-btn-clicks [] - (->> (:chan (dom/listen (dom/recipe-done-btns-selector) :click)) - (a/map< (fn [e] (log "done button click:" (.-selectedTarget e)) - {:elem (.-selectedTarget e)})))) - -(defn parse-completed-event [event] - (let [target (.-selectedTarget event) - id (.-id target) - completed (dommy/has-class? target "completed") - event-type (if completed :uncomplete :complete)] - {:_id id :event event-type})) - -(defmulti enter-state - (fn [old-state new-state-name args] - new-state-name) - :default :unhandled) - -(defmethod enter-state :unhandled [old-state new-state-name args] - (logs "Unhandled enter transition from " (:name old-state) "to" new-state-name) - old-state) - -(defmulti exit-state - (fn [state] - (:name state)) - :default :unhandled) - -(defmethod exit-state :unhandled [state] - (logs "Unhandled exit transition from " (:name state)) - state) - -(defn transition [state new-state-name & args] - (logs "transition from" (:name state) "to" new-state-name) - (-> state - (exit-state) - (enter-state new-state-name args) - (assoc :name new-state-name))) - -(defmulti handle-event - (fn [state event] - [(:name state) (:event event)]) - :default [:unhandled-state :unhandled-event]) - -(defmethod handle-event [:unhandled-state :unhandled-event] [state event] - (logs "Unhandled event [" (:name state) (:event event) "]") - state) - - - - -(defmethod handle-event [:default :created] [state event] - (let [add-event (-> event - (assoc :event :add) - (assoc :_id (str "grub-" (.now js/Date))) - (assoc :completed false))] - (go (>! (:out state) add-event)) - (dom/clear-add-grub-text) - state)) - -(defmethod handle-event [:default :clear-all] [state event] - (go (>! (:out state) {:event :clear-all})) - state) - -(defmethod handle-event [:default :mousedown] [state event] - (let [mouseevent (:data event)] - (dom/-activate! (.-selectedTarget mouseevent)) - (let [now (.now js/Date) - new-state (assoc state :mousedown-time now)] - (go (! (:edit (:channels state)) - {:mousedown-time now :elem (.-selectedTarget mouseevent)})) - new-state))) - -(defmethod handle-event [:default :mouseup] [state event] - (dom/-deactivate! (.-selectedTarget (:data event))) - (go (>! (:out state) (parse-completed-event (:data event)))) - (let [new-state (assoc state :mousedown-time nil)] - new-state)) - -(defmethod handle-event [:default :mouseleave] [state event] - (dom/-deactivate! (.-selectedTarget (:data event))) - state) - -(defmethod handle-event [:default :edit] [state event] - (if (and (:mousedown-time state) - (= (:mousedown-time event) - (:mousedown-time state))) - (transition state :edit-grub (:elem event)) - state)) - -(defmethod handle-event [:default :new-recipe-click] [state event] - (transition state :new-recipe)) - -(defmethod handle-event [:default :edit-recipe-click] [state event] - (transition state :edit-recipe (:elem event))) - -(defmethod handle-event [:default :add-recipe] [state event] - (log "handle event add-recipe") - (dom/add-new-recipe (:_id event) (:name event) (:steps event)) - state) - - - - -(defmethod enter-state :edit-grub [old-state new-state-name [edit-elem]] - (dom/-set-editing! edit-elem) - (assoc old-state :edit-elem edit-elem)) - -(defmethod exit-state :edit-grub [state] - (let [edit-elem (:edit-elem state)] - (dom/-unset-editing! edit-elem) - (let [grub-text (.-value (sel1 edit-elem :.grub-input)) - id (.-id edit-elem) - update-event {:event :update :grub grub-text :_id id} - new-state (dissoc state :edit-elem)] - (go (>! (:out state) update-event)) - new-state))) - -(defmethod handle-event [:edit-grub :body-click] [state event] - (let [clicked-elem (.-target (:data event)) - edit-elem (:edit-elem state)] - (if (dommy/descendant? clicked-elem edit-elem) - state - (transition state :default)))) - -(defmethod handle-event [:edit-grub :enter] [state event] - (transition state :default)) - - - - -(defmethod enter-state :new-recipe [old-state new-state-name args] - (dom/-expand! dom/new-recipe) - old-state) - -(defn get-new-recipe-info [] - (let [name (.-value (sel1 dom/new-recipe "#recipe-name")) - steps (.-value (sel1 dom/new-recipe "#recipe-steps"))] - (when (not (or (empty? name) (empty? steps))) - (let [id (str "recipe-" (.now js/Date))] - {:name name :steps steps :_id id})))) - -(defmethod exit-state :new-recipe [state] - (dom/-unexpand! dom/new-recipe) - (let [recipe-info (get-new-recipe-info)] - (if recipe-info - (let [recipe-node (dom/add-new-recipe (:_id recipe-info) - (:name recipe-info) - (:steps recipe-info))] - (log "new recipe name:" (:name recipe-info) "steps" (:steps recipe-info)) - (dom/-clear! dom/new-recipe) - (go (>! (:out state) (assoc recipe-info :event :add-recipe))) - (assoc state - :recipes (assoc (:recipes state) (.-id recipe-node) recipe-node))) - state))) - -(defmethod handle-event [:new-recipe :body-click] [state event] - (let [clicked-elem (.-target (:data event))] - (if (dommy/descendant? clicked-elem dom/new-recipe) - state - (transition state :default)))) - -(defmethod handle-event [:new-recipe :recipe-done-btn-click] [state event] - (log "handle new recipe done btn click") - (if (dommy/descendant? (:elem event) dom/new-recipe) - (transition state :default) - state)) - -(defmethod handle-event [:new-recipe :ctrl-enter] [state event] - (transition state :default)) - - -(defmethod enter-state :edit-recipe [old-state new-state-name [elem]] - (dom/-expand! elem) - (assoc old-state :edit-elem elem)) - -(defmethod exit-state :edit-recipe [state] - (let [recipe-node (:edit-elem state) - recipe-name (.-value (sel1 recipe-node "#recipe-name")) - recipe-steps (.-value (sel1 recipe-node "#recipe-steps"))] - (log "update recipe new name:" recipe-name "new steps:" recipe-steps) - (dom/-unexpand! recipe-node) - (-> state - (dissoc :edit-elem)))) - -(defmethod handle-event [:edit-recipe :body-click] [state event] - (log "edit-recipe body click") - (let [clicked-elem (.-target (:data event)) - recipe-node (:edit-elem state)] - (if (dommy/descendant? clicked-elem recipe-node) - state - (transition state :default)))) - -(defmethod handle-event [:edit-recipe :recipe-done-btn-click] [state event] - (if (dommy/descendant? (:elem event) (:edit-elem state)) - (transition state :default) - state)) - - - - -(defn main-loop [channels] - (let [out (chan) - events (chan) - event-mix (a/mix events)] - (doseq [[name c] (seq channels)] (a/admix event-mix c)) - (go-loop [state {:name :default - :channels channels - :out out - :recipes {}}] - (let [event (! chan]]) + (: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) + (a/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) + (a/close! out))] + (dommy/listen-once! el type push-fn) + {:chan out :unlisten unlisten}))) + +(defn get-away-clicks [elem] + (let [{c :chan unlisten :unlisten} (listen (sel1 :body) :click) + filtered-chan (a/filter< #(not (dommy/descendant? (.-target %) elem)) c)] + {:unlisten unlisten :chan filtered-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"])) + +(def clear-all-btn + (node [:button.btn.hidden.pull-right + {:id "clear-all-btn" :type "button"} + "Clear all"])) + +(defn clear-grubs! [] + (dommy/set-html! (sel1 :#grub-list) "")) + +(defn get-grub-completed-glyph [completed] + (node (if completed + [:span.glyphicon.glyphicon-check] + [:span.glyphicon.glyphicon-unchecked]))) + +(defn make-grub-node [id grub completed] + (node [:li.list-group-item.grub-item + {:id id + :class (when completed "completed")} + [:span.grub-static + (get-grub-completed-glyph completed) + [:span.grub-text grub]] + [:input.grub-input {:type "text" :value grub}]])) + +(defn grubs-selector [] + [(sel1 :#grub-list) :.grub-item]) + +(defn make-recipe-node + ([id name grubs] (make-recipe-node id name grubs false)) + ([id name grubs new-recipe] + (node [:div.panel.panel-default.recipe-panel + {:id id} + [:div.panel-heading.recipe-header + [:input.form-control.recipe-header-input + {:id "recipe-name" + :type "text" + :placeholder "Grub pie" + :value name}]] + [:div.panel-body.recipe-grubs.hidden + [:textarea.form-control.recipe-grubs-input + {:id "recipe-grubs" + :rows 3 + :placeholder "2 grubs"} + grubs] + (when-not new-recipe + [:button.btn.btn-primary.pull-left.recipe-btn.recipe-add-grubs-btn + {:type "button"} "Add Grubs"]) + [:button.btn.btn-primary.hidden.pull-right.recipe-btn.recipe-done-btn + {:type "button"} "Done"]]]))) + +(def new-recipe (make-recipe-node "new-recipe" "" "" true)) + +(defn recipes-selector [] + [(sel1 :#recipe-list) :.recipe-panel]) + +(defn recipe-done-btns-selector [] + [(sel1 :body) :.recipe-done-btn]) + +(defn recipe-add-grubs-btns-selector [] + [(sel1 :body) :.recipe-add-grubs-btn]) + +(deftemplate main-template [] + [:div.container + [:div.row + [:div.col-sm-6.leftmost-column + [: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-sm-6 + [:h3.recipes-title "Recipes"] + new-recipe + [:ul#recipe-list.list-group.recipe-list]]]]) + +(defn render-body [] + (dommy/prepend! (sel1 :body) (main-template))) + +(defn get-add-grub-text [] + (dommy/value add-grub-text)) + +(defn clear-add-grub-text [] + (dommy/set-value! add-grub-text "")) + + +(defprotocol IHideable + (-hide! [this]) + (-show! [this])) + +(defprotocol IGrub + (-activate! [this]) + (-deactivate! [this]) + + (-complete! [this]) + (-uncomplete! [this]) + (-completed? [this]) + + (-set-editing! [this]) + (-unset-editing! [this]) + (-editing? [this]) + (-update-grub! [this grub])) + +(defprotocol IRecipe + (-expand! [this]) + (-unexpand! [this]) + + (-update-recipe! [this]) + + (-get-name [this]) + (-get-grubs-str [this]) + (-get-grubs [this])) + +(defprotocol IClearable + (-clear! [this])) + +(extend-type js/HTMLElement + IHideable + (-hide! [this] + (dommy/add-class! this :hidden)) + (-show! [this] + (dommy/remove-class! this :hidden))) + + +(extend-type js/HTMLElement + IGrub + (-activate! [this] + (dommy/add-class! this :grub-active)) + (-deactivate! [this] + (dommy/remove-class! this :grub-active)) + + (-complete! [this] + (dommy/add-class! this :completed) + (dommy/replace! (sel1 this ".glyphicon") + (get-grub-completed-glyph true))) + (-uncomplete! [this] + (dommy/remove-class! this :completed) + (dommy/replace! (sel1 this ".glyphicon") + (get-grub-completed-glyph false))) + (-completed? [this] + (dommy/has-class? this :completed)) + + (-set-editing! [this] + (-deactivate! this) + (dommy/add-class! this :edit) + (.focus (sel1 this :input))) + (-unset-editing! [this] + (dommy/remove-class! this :edit)) + (-editing? [this] + (dommy/has-class? this :edit))) + +(defrecord Grub [elem id grub completed] + dommy.template/PElement + (-elem [this] elem) + + IGrub + (-set-editing! [this] (-set-editing! elem)) + (-unset-editing! [this] (-unset-editing! elem)) + (-editing? [this] (-editing? elem)) + + (-complete! [this] (-complete! elem)) + (-uncomplete! [this] (-uncomplete! elem)) + (-completed? [this] (-completed? elem)) + + (-set-editing! [this] (-set-editing! elem)) + (-unset-editing! [this] (-unset-editing! elem)) + (-editing? [this] (-editing? elem)) + + (-update-grub! [this grub] + (dommy/set-text! (sel1 elem ".grub-text") grub) + (dommy/set-value! (sel1 elem ".grub-input") grub))) + +(defn add-new-grub [id grub completed] + (let [node (make-grub-node id grub completed) + grub (Grub. node id grub completed) + grub-list (sel1 :#grub-list)] + (dommy/append! grub-list grub) + (dommy/set-value! (sel1 :#add-grub-input) "") + grub)) + +(extend-type js/HTMLDivElement + IRecipe + (-expand! [this] + (dommy/remove-class! (sel1 this ".recipe-grubs") :hidden) + (dommy/remove-class! (sel1 this ".recipe-done-btn") :hidden)) + (-unexpand! [this] + (dommy/add-class! (sel1 this ".recipe-grubs") :hidden) + (dommy/add-class! (sel1 this ".recipe-done-btn") :hidden)) + + (-get-name [this] + (dommy/value (sel1 this :#recipe-name))) + (-get-grubs-str [this] + (dommy/value (sel1 this :#recipe-grubs))) + (-get-grubs [this] + (let [split-grubs (clojure.string/split-lines (-get-grubs-str this))] + (when split-grubs (into [] split-grubs))))) + + +(extend-type js/HTMLDivElement + IClearable + (-clear! [this] + (dommy/set-value! (sel1 this "#recipe-name") "") + (dommy/set-value! (sel1 this "#recipe-grubs") ""))) + +(defrecord Recipe [elem id name grubs] + dommy.template/PElement + (-elem [this] elem) + + IRecipe + (-expand! [this] (-expand! elem)) + (-unexpand! [this] (-unexpand! elem)) + + (-clear! [this] (-clear! elem)) + + (-update-recipe! [this] + (dommy/set-value! (sel1 this :#recipe-name) name) + (dommy/set-text! (sel1 this :#recipe-grubs) grubs))) + +(defn add-new-recipe [id name grubs] + (let [node (make-recipe-node id name grubs) + recipe (Recipe. node id name grubs) + recipe-list (sel1 :#recipe-list)] + (dommy/append! recipe-list recipe) + recipe)) + diff --git a/src/cljs/grub/view/grub.cljs b/src/cljs/grub/view/grub.cljs new file mode 100644 index 0000000..942de62 --- /dev/null +++ b/src/cljs/grub/view/grub.cljs @@ -0,0 +1,170 @@ +(ns grub.view.grub + (:require [grub.view.dom :as dom] + [dommy.core :as dommy] + [cljs.core.async :as a :refer [! chan]]) + (:require-macros [grub.macros :refer [log logs and-let]] + [dommy.macros :refer [sel1]] + [cljs.core.async.macros :refer [go go-loop]])) + +(defn get-add-grub-clicks [] + (:chan (dom/listen dom/add-grub-btn :click))) + +(defn get-add-grub-enters [] + (->> (:chan (dom/listen dom/add-grub-text :keyup)) + (a/filter< #(= (.-keyIdentifier %) "Enter")))) + +(defn get-create-events [] + (let [events (a/merge [(get-add-grub-clicks) + (get-add-grub-enters)])] + (->> events + (a/map< #(dom/get-add-grub-text)) + (a/filter< #(not (empty? %))) + (a/map< (fn [g] + {:event :add-grub + :_id (str "grub-" (.now js/Date)) + :grub g + :completed false}))))) + +(defn parse-complete-event [elem] + (let [id (.-id elem) + completed (dom/-completed? elem) + event-type (if completed :uncomplete-grub :complete-grub)] + {:_id id + :event event-type})) + +(defn get-complete-events [] + (->> (:chan (dom/listen (dom/grubs-selector) :click)) + (a/map< #(.-selectedTarget %)) + (a/filter< #(not (dom/-editing? %))) + (a/map< parse-complete-event))) + +(defn get-clear-all-events [] + (->> (:chan (dom/listen dom/clear-all-btn :click)) + (a/map< (fn [e] {:event :clear-all-grubs})))) + +(defn get-grub-mousedown-events [] + (let [{c :chan unlisten :unlisten} (dom/listen (dom/grubs-selector) :mousedown)] + {:unlisten unlisten + :chan (a/map< (fn [e] {:selected-grub (.-selectedTarget e)}) c)})) + +(defn get-grub-mouseup-events [grub-elem] + (dom/listen grub-elem :mouseup)) + +(defn get-grub-mouseleave-events [grub-elem] + (dom/listen grub-elem :mouseleave)) + +(defn wait-for-mousedown-on-grub [] + (let [out (chan)] + (go (let [{mousedown :chan unlisten :unlisten} (get-grub-mousedown-events) + event (! out selected-grub))) + out)) + +(defn wait-for-grub-mousedown-timeout [grub] + (let [out (chan)] + (dom/-activate! grub) + (go (let [{mouseup :chan + unlisten-mouseup :unlisten} (get-grub-mouseup-events grub) + {mouseleave :chan + unlisten-mouseleave :unlisten } (get-grub-mouseleave-events grub) + timeout (a/timeout 500) + [_ c] (a/alts! [mouseup mouseleave timeout])] + (unlisten-mouseleave) + (unlisten-mouseup) + (dom/-deactivate! grub) + (>! out (= c timeout)))) + out)) + +(defn get-enters [] + (let [{c :chan unlisten :unlisten} (dom/listen (sel1 :body) :keyup)] + {:unlisten unlisten + :chan (a/filter< #(= (.-keyIdentifier %) "Enter") c)})) + +(defn make-grub-update-event [grub-elem orig-grub-text] + (let [grub-text (.-value (sel1 grub-elem :.grub-input)) + id (.-id grub-elem)] + (when (not (= grub-text orig-grub-text)) + {:event :update-grub + :grub grub-text + :_id id}))) + +(defn wait-for-update-event [grub] + (let [out (chan) + orig-grub (.-value (sel1 grub :.grub-input))] + (go (let [{bodyclick :chan + unlisten-bodyclick :unlisten} (dom/get-away-clicks grub) + {enter :chan + unlisten-enter :unlisten} (get-enters)] + (dom/-set-editing! grub) + (a/alts! [bodyclick enter]) + (unlisten-bodyclick) + (unlisten-enter) + (dom/-unset-editing! grub) + (if-let [update-event (make-grub-update-event grub orig-grub)] + (>! out update-event) + (a/close! out)))) + out)) + +(defn get-update-events [] + (let [out (chan)] + (go-loop [] + (and-let [grub (! out update-event)) + (recur)) + out)) + +(defn get-grub-with-index [grubs id] + (let [grub-index (->> grubs + (map-indexed vector) + (filter #(= (:_id (second %)) id)) + (first) + (first)) + grub (grubs grub-index)] + [grub-index grub])) + +(defmulti handle-event (fn [event grubs] (:event event)) + :default :unknown-event) + +(defmethod handle-event :unknown-event [event grubs] + ;(logs "Cannot handle unknown event:" event) + grubs) + +(defmethod handle-event :add-grub [event grubs] + (let [grub (dom/add-new-grub (:_id event) (:grub event) (:completed event))] + (dom/-show! dom/clear-all-btn) + (assoc grubs (:id grub) grub))) + +(defmethod handle-event :complete-grub [event grubs] + (let [grub (get grubs (:_id event))] + (dom/-complete! grub) + (assoc-in grubs [(:_id event) :completed] true))) + +(defmethod handle-event :uncomplete-grub [event grubs] + (dom/-uncomplete! (get grubs (:_id event))) + (assoc-in grubs [(:_id event) :completed] false)) + +(defmethod handle-event :update-grub [event grubs] + (dom/-update-grub! (get grubs (:_id event)) (:grub event)) + (assoc-in grubs [(:_id event) :grub] (:grub event))) + +(defmethod handle-event :clear-all-grubs [event grubs] + (dom/-hide! dom/clear-all-btn) + (dom/clear-grubs!) + {}) + +(defn handle-grubs [remote-events] + (let [out (chan) + local-events [(get-create-events) + (get-complete-events) + (get-clear-all-events) + (get-update-events)]] + (go-loop [grubs {}] + (let [[event c] (a/alts! (conj local-events remote-events))] + (when-not (= c remote-events) + (>! out event)) + (recur (handle-event event grubs)))) + out)) diff --git a/src/cljs/grub/view/recipe.cljs b/src/cljs/grub/view/recipe.cljs new file mode 100644 index 0000000..1484677 --- /dev/null +++ b/src/cljs/grub/view/recipe.cljs @@ -0,0 +1,153 @@ +(ns grub.view.recipe + (:require [grub.view.dom :as dom] + [dommy.core :as dommy] + [cljs.core.async :as a :refer [! chan]]) + (:require-macros [grub.macros :refer [log logs and-let]] + [dommy.macros :refer [sel1]] + [cljs.core.async.macros :refer [go go-loop]])) + +(defn wait-for-new-recipe-input-click [] + (:chan (dom/listen-once dom/new-recipe :click))) + +(defn get-ctrl-enters [] + (let [{c :chan unlisten :unlisten} (dom/listen (sel1 :body) :keyup) + filtered-chan (a/filter< #(and (= (.-keyIdentifier %) "Enter") + (.-ctrlKey %)) + c)] + {:chan filtered-chan :unlisten unlisten})) + +(defn parse-new-recipe-event [] + (let [name (dom/-get-name dom/new-recipe) + grubs (dom/-get-grubs-str dom/new-recipe)] + (when (not (or (empty? name) (empty? grubs))) + (let [id (str "recipe-" (.now js/Date))] + {:event :add-recipe + :name name + :grubs grubs + :_id id})))) + +(defn wait-for-create-event [] + (let [out (chan) + {ctrl-enters :chan + ctrl-enters-unlisten :unlisten} (get-ctrl-enters) + {away-clicks :chan + away-clicks-unlisten :unlisten} (dom/get-away-clicks dom/new-recipe) + {done-clicks :chan + done-clicks-unlisten :unlisten} (dom/listen + (sel1 dom/new-recipe ".recipe-done-btn") + :click)] + (go (a/alts! [ctrl-enters away-clicks done-clicks]) + (ctrl-enters-unlisten) + (away-clicks-unlisten) + (done-clicks-unlisten) + (if-let [event (parse-new-recipe-event)] + (>! out event) + (a/close! out))) + out)) + +(defn get-create-events [] + (let [out (chan)] + (go-loop [] + (! out create-event) + (dom/-clear! dom/new-recipe)) + (dom/-unexpand! dom/new-recipe) + (recur)) + out)) + +(defn wait-for-edit-recipe-input-click [] + (->> (:chan (dom/listen-once (dom/recipes-selector) :click)) + (a/map< #(.-selectedTarget %)))) + +(defn parse-update-recipe-event [elem] + (let [id (.-id elem) + name (dom/-get-name elem) + grubs (dom/-get-grubs-str elem)] + (when (not (or (empty? name) (empty? grubs))) + {:event :update-recipe + :name name + :grubs grubs + :_id id}))) + +(defn wait-for-update-event [elem] + (let [out (chan) + {ctrl-enters :chan + ctrl-enters-unlisten :unlisten} (get-ctrl-enters) + {away-clicks :chan + away-clicks-unlisten :unlisten} (dom/get-away-clicks elem) + {done-clicks :chan + done-clicks-unlisten :unlisten} (dom/listen + (sel1 elem ".recipe-done-btn") + :click)] + (go (a/alts! [ctrl-enters away-clicks done-clicks]) + (ctrl-enters-unlisten) + (away-clicks-unlisten) + (done-clicks-unlisten) + (if-let [event (parse-update-recipe-event elem)] + (>! out event) + (a/close! out))) + out)) + +(defn get-update-events [] + (let [out (chan)] + (go-loop [] + (let [recipe-elem (! out update-event)) + (dom/-unexpand! recipe-elem) + (recur))) + out)) + +(defn get-add-grub-events [] + (let [out (chan) + clicks (:chan (dom/listen (dom/recipe-add-grubs-btns-selector) :click))] + (go-loop [] + (let [e ( recipe + (assoc :name (:name event)) + (assoc :grubs (:grubs event)))] + (dom/-update-recipe! updated-recipe) + (assoc recipes (:id recipes) updated-recipe))) + +(defn handle-recipes [remote-events] + (let [out (chan) + local-events [(get-create-events) + (get-update-events)] + add-grub-events (get-add-grub-events)] + (a/pipe add-grub-events out) + (go-loop [recipes {}] + (let [[event c] (a/alts! (conj local-events remote-events))] + (when-not (= c remote-events) + (>! out event)) + (recur (handle-event event recipes)))) + out))