├── logo.png ├── resources └── public │ ├── css │ └── style.css │ └── index.html ├── src └── conduit │ ├── controllers │ ├── router.cljs │ ├── tags.cljs │ ├── profile.cljs │ ├── tag_articles.cljs │ ├── article.cljs │ ├── articles.cljs │ ├── comments.cljs │ ├── form.cljs │ └── user.cljs │ ├── components │ ├── grid.cljs │ ├── footer.cljs │ ├── login.cljs │ ├── register.cljs │ ├── settings.cljs │ ├── editor.cljs │ ├── root.cljs │ ├── header.cljs │ ├── base.cljs │ ├── comment.cljs │ ├── forms │ │ ├── comment.cljs │ │ ├── user_settings.cljs │ │ └── article.cljs │ ├── article.cljs │ ├── home.cljs │ └── forms.cljs │ ├── helpers │ └── form.cljs │ ├── routes.cljs │ ├── router.cljs │ ├── effects.cljs │ ├── core.cljs │ ├── api.cljs │ └── mixins.cljs ├── .gitignore ├── package.json ├── dev ├── server.clj └── user.clj ├── README.md └── project.clj /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roman01la/cljs-rum-realworld-example-app/master/logo.png -------------------------------------------------------------------------------- /resources/public/css/style.css: -------------------------------------------------------------------------------- 1 | .loader { 2 | text-align: center; 3 | padding: 16px 0; 4 | } 5 | .tags-list { 6 | margin: 0; 7 | padding: 0; 8 | } 9 | .article-footer { 10 | display: flex; 11 | justify-content: space-between; 12 | } 13 | -------------------------------------------------------------------------------- /src/conduit/controllers/router.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.controllers.router) 2 | 3 | (defmulti control (fn [event] event)) 4 | 5 | (defmethod control :init [_ [route]] 6 | {:state route}) 7 | 8 | (defmethod control :push [_ [route]] 9 | {:state route}) 10 | -------------------------------------------------------------------------------- /src/conduit/components/grid.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.grid 2 | (:require [rum.core :as rum])) 3 | 4 | (rum/defc Row [& children] 5 | (apply vector :div.row children)) 6 | 7 | (rum/defc Column [class & children] 8 | (apply vector :div {:class class} children)) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /resources/public/js/compiled/** 2 | figwheel_server.log 3 | pom.xml 4 | *jar 5 | /lib/ 6 | /classes/ 7 | /out/ 8 | /target/ 9 | .lein-deps-sum 10 | .lein-repl-history 11 | .lein-plugins/ 12 | .repl 13 | .nrepl-port 14 | .idea 15 | conduit.iml 16 | node_modules 17 | *.iml 18 | 19 | \.rebel_readline_history 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": {}, 3 | "devDependencies": { 4 | "husky": "^0.14.3", 5 | "lint-staged": "^4.0.4", 6 | "parlinter": "^1.2.0" 7 | }, 8 | "scripts": { 9 | "precommit": "lint-staged" 10 | }, 11 | "lint-staged": { 12 | "*.{clj,cljs,cljc,edn}": [ 13 | "parlinter --trim --write \"**/*.{clj,cljs,cljc,edn}\"", 14 | "git add" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/conduit/components/footer.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.footer 2 | (:require [rum.core :as rum])) 3 | 4 | (rum/defc Footer [] 5 | [:footer 6 | [:div.container 7 | [:a.logo-font {:href "/"} "conduit"] 8 | [:span.attribution 9 | "An interactive learning project from " 10 | [:a {:href "https://thinkster.io"} "Thinkster"] 11 | ". " 12 | "Code & design licensed under MIT."]]]) 13 | -------------------------------------------------------------------------------- /src/conduit/helpers/form.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.helpers.form 2 | (:import goog.format.EmailAddress)) 3 | 4 | (defn email? [v] 5 | (.isValid (EmailAddress. v))) 6 | 7 | (defn length? [{:keys [min max]}] 8 | (fn [v] 9 | (let [length (.-length v)] 10 | (and (if min (if (>= length min) true false) true) 11 | (if max (if (<= length max) true false) true))))) 12 | 13 | (defn present? [v] 14 | (not (empty? v))) -------------------------------------------------------------------------------- /src/conduit/routes.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.routes) 2 | 3 | (def routes 4 | ["/" [["" :home] 5 | [["page/" :page] :home] 6 | [["tag/" :id] [["" :tag] 7 | [["/page/" :page] :tag]]] 8 | [["article/" :id] :article] 9 | ["editor" [["" :editor] 10 | [["/" :slug] :editor]]] 11 | ["login" :login] 12 | ["register" :register] 13 | ["settings" :settings]]]) 14 | -------------------------------------------------------------------------------- /src/conduit/components/login.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.login 2 | (:require [rum.core :as rum] 3 | [conduit.components.forms :refer [LoginForm]])) 4 | 5 | (rum/defc Login [r route params] 6 | [:.auth-page 7 | [:.container.page 8 | [:.row 9 | [:.col-md-6.offset-md-3.col-xs-12 10 | [:h1.text-xs-center "Sign in"] 11 | [:p.text-xs-center 12 | [:a {:href "#/register"} "Need an account?"]] 13 | (LoginForm r route params)]]]]) 14 | -------------------------------------------------------------------------------- /src/conduit/components/register.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.register 2 | (:require [rum.core :as rum] 3 | [conduit.components.forms :refer [RegisterForm]])) 4 | 5 | (rum/defc Register [r route params] 6 | [:.auth-page 7 | [:.container.page 8 | [:.row 9 | [:.col-md-6.offset-md-3.col-xs-12 10 | [:h1.text-xs-center "Sign up"] 11 | [:p.text-xs-center 12 | [:a {:href "#/login"} "Have an account?"]] 13 | (RegisterForm r route params)]]]]) 14 | -------------------------------------------------------------------------------- /src/conduit/controllers/tags.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.controllers.tags) 2 | 3 | (def initial-state 4 | []) 5 | 6 | (defmulti control (fn [event] event)) 7 | 8 | (defmethod control :default [_ _ state] 9 | {:state state}) 10 | 11 | (defmethod control :init [] 12 | {:state initial-state}) 13 | 14 | (defmethod control :load [_ _ state] 15 | {:http {:endpoint :tags 16 | :on-load :load-ready}}) 17 | 18 | (defmethod control :load-ready [_ [{:keys [tags]}]] 19 | {:state tags}) 20 | -------------------------------------------------------------------------------- /dev/server.clj: -------------------------------------------------------------------------------- 1 | (ns server 2 | (:require [compojure.core :refer :all] 3 | [compojure.route :as r] 4 | [ring.middleware.defaults :refer [wrap-defaults site-defaults]] 5 | [ring.util.response :as res])) 6 | 7 | (defroutes app 8 | (r/resources "/" {:root "public"}) 9 | (GET "*" [] (-> (res/resource-response "index.html" {:root "public"}) 10 | (res/content-type "text/html")))) 11 | 12 | (def handler (wrap-defaults #'app site-defaults)) 13 | -------------------------------------------------------------------------------- /src/conduit/router.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.router 2 | (:require [bidi.bidi :as bidi] 3 | [goog.events :as events] 4 | [clojure.string :as str])) 5 | 6 | 7 | (defn start! [on-set-page routes] 8 | (letfn [(handle-route [] 9 | (let [uri (str/replace js/location.hash "#" "")] 10 | (->> (if-not (empty? uri) uri "/") 11 | (bidi/match-route routes) 12 | on-set-page)))] 13 | (events/listen js/window "hashchange" handle-route) 14 | (handle-route) 15 | handle-route)) 16 | 17 | 18 | (defn stop! [handler] 19 | (events/unlisten js/window "hashchange" handler)) 20 | -------------------------------------------------------------------------------- /src/conduit/components/settings.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.settings 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus] 4 | [conduit.components.forms.user-settings :refer [UserSettings]])) 5 | 6 | (rum/defc Settings < rum/reactive 7 | [r route params] 8 | (when-let [current-user (rum/react (citrus/subscription r [:user :current-user]))] 9 | [:div.settings-page 10 | [:div.container.page 11 | [:div.row 12 | [:div.col-md-6.offset-md-3.col-xs-12 13 | [:h1.text-xs-center "Your Settings"] 14 | (UserSettings r route params current-user) 15 | [:hr] 16 | [:a.btn.btn-outline-danger {:href "#/logout"} "Or click here to logout."]]]]])) 17 | -------------------------------------------------------------------------------- /src/conduit/components/editor.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.editor 2 | (:require [rum.core :as rum] 3 | [conduit.components.forms.article :refer [ArticleForm]] 4 | [conduit.mixins :as mixins] 5 | [citrus.core :as citrus])) 6 | 7 | (rum/defc Editor < rum/reactive 8 | (mixins/dispatch-on-mount 9 | (fn [_ _ params] 10 | (when params 11 | {:article [:load {:id (params :slug)}]}))) 12 | [r route params] 13 | (let [article (rum/react (citrus/subscription r [:article :article]))] 14 | [:.editor-page 15 | [:.container.page 16 | [:.row 17 | [:.col-md-10.offset-md-1.col-xs-12 18 | (ArticleForm r route params (when params article))]]]])) 19 | -------------------------------------------------------------------------------- /resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Conduit 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/conduit/controllers/profile.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.controllers.profile) 2 | 3 | (def initial-state 4 | {}) 5 | 6 | (defmulti control (fn [event] event)) 7 | 8 | (defmethod control :default [_ _ state] 9 | {:state state}) 10 | 11 | (defmethod control :init [] 12 | {:state initial-state}) 13 | 14 | (defmethod control :load [_ _ state] 15 | {:state state}) 16 | 17 | (defmethod control :follow [_ [id token callback]] 18 | {:http {:endpoint :follow 19 | :slug id 20 | :method :post 21 | :token token 22 | :on-load callback 23 | :on-error callback}}) 24 | 25 | (defmethod control :follow-ready [_ _ state] 26 | {:state state}) 27 | 28 | (defmethod control :follow-error [_ _ state] 29 | {:state state}) 30 | 31 | (defmethod control :unfollow [_ [id token callback]] 32 | {:http {:endpoint :follow 33 | :slug id 34 | :method :delete 35 | :token token 36 | :on-load callback 37 | :on-error callback}}) 38 | 39 | (defmethod control :unfollow-ready [_ _ state] 40 | {:state state}) 41 | 42 | (defmethod control :unfollow-error [_ _ state] 43 | {:state state}) 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![ClojureScript + Rum example app](logo.png) 2 | 3 | 4 | > ### ClojureScript + Rum codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld-example-apps) spec and API. 5 | 6 | ## Development 7 | 8 | - Install Java 9 | - Install Leiningen `brew install leiningen` 10 | - Install rlwrap `brew install rlwrap` 11 | - Install NPM dependencies `npm i` 12 | - Run development server `rlwrap lein figwheel dev` 13 | - Build for production `lein cljsbuild once min` 14 | 15 | ## Want to contribute? 16 | 17 | - Explore [RealWorld](https://github.com/gothinkster/realworld) project repo 18 | - Read [front-end spec](https://github.com/gothinkster/realworld/tree/master/spec#frontend-specs) (requirements) of the project 19 | - Learn [Rum](https://github.com/tonsky/rum/) and [Citrus](https://github.com/roman01la/citrus) libraries 20 | - Choose an issue with `help wanted` label 21 | - If there's no issue for a task you want to do create an issue with description of the task so everyone knows what are you working on 22 | - Follow the code style as much as possible, we want the codebase to be consistent 23 | - Send PRs with small commits for a review, it's easier to understand small changes 24 | - Join our Gitter chat [realworld-dev/clojurescript](https://gitter.im/realworld-dev/clojurescript) 25 | -------------------------------------------------------------------------------- /src/conduit/controllers/tag_articles.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.controllers.tag-articles) 2 | 3 | (def initial-state 4 | {:articles [] 5 | :pages-count 0 6 | :loading? true}) 7 | 8 | (defmulti control (fn [event] event)) 9 | 10 | (defmethod control :default [_ _ state] 11 | {:state state}) 12 | 13 | (defmethod control :init [] 14 | {:state initial-state}) 15 | 16 | (defmethod control :load [_ [{:keys [tag page]}] state] 17 | {:state (assoc state :loading? true) 18 | :http {:endpoint :articles 19 | :params {:tag tag 20 | :limit 10 21 | :offset (* (-> page (or 1) js/parseInt dec) 10)} 22 | :on-load :load-ready}}) 23 | 24 | (defmethod control :load-ready [_ [{:keys [articles articlesCount]}] state] 25 | {:state 26 | (-> state 27 | (assoc :articles articles) 28 | (assoc :pages-count (-> articlesCount (/ 10) Math/round)) 29 | (assoc :loading? false))}) 30 | 31 | (defmethod control :reset [] 32 | {:state initial-state}) 33 | 34 | (defmethod control :update [_ [id transform data] state] 35 | {:state (update state :articles (fn [articles] 36 | (map 37 | (fn [article] 38 | (if (= (:slug article) id) 39 | (transform data) 40 | article)) 41 | articles)))}) 42 | -------------------------------------------------------------------------------- /src/conduit/components/root.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.root 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus] 4 | [conduit.mixins :as mixins] 5 | [conduit.components.home :as home] 6 | [conduit.components.article :as article] 7 | [conduit.components.login :as login] 8 | [conduit.components.register :as register] 9 | [conduit.components.editor :as editor] 10 | [conduit.components.settings :as settings] 11 | [conduit.components.header :refer [Header]] 12 | [conduit.components.footer :refer [Footer]])) 13 | 14 | (rum/defc Root < rum/reactive 15 | (mixins/dispatch-on-mount 16 | (fn [] {:user [:check-auth]})) 17 | [r] 18 | (let [{route :handler params :route-params} (rum/react (citrus/subscription r [:router])) 19 | {:keys [current-user loading?]} (rum/react (citrus/subscription r [:user]))] 20 | [:div 21 | (Header r route {:loading? loading? 22 | :logged-in? current-user}) 23 | (case route 24 | :home (home/Home r route params) 25 | :tag (home/HomeTag r route params) 26 | :article (article/Article r route params) 27 | :login (login/Login r route params) 28 | :register (register/Register r route params) 29 | :editor (editor/Editor r route params) 30 | :settings (settings/Settings r route params) 31 | [:div "404"]) 32 | (Footer)])) 33 | -------------------------------------------------------------------------------- /src/conduit/components/header.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.header 2 | (:require [rum.core :as rum])) 3 | 4 | (def nav-items 5 | [{:label "Home" 6 | :route :home 7 | :link "/" 8 | :display-for :always} 9 | {:label "New Post" 10 | :route :editor 11 | :icon "ion-compose" 12 | :link "/editor" 13 | :display-for :logged} 14 | {:label "Settings" 15 | :route :settings 16 | :icon "ion-gear-a" 17 | :link "/settings" 18 | :display-for :logged} 19 | {:label "Sign in" 20 | :route :login 21 | :link "/login" 22 | :display-for :non-logged} 23 | {:label "Sign up" 24 | :route :sign-up 25 | :link "/register" 26 | :display-for :non-logged}]) 27 | 28 | (rum/defc NavItem [curr-route {:keys [label icon route link]}] 29 | [:li.nav-item {:class (when (= route curr-route) "active")} 30 | [:a.nav-link {:href (str "#" link)} 31 | (when icon [:i {:class icon}]) 32 | (when icon " ") 33 | label]]) 34 | 35 | (rum/defc Header [r route {:keys [loading? logged-in?]}] 36 | (let [user-nav-items (filter #(not= (if logged-in? :non-logged :logged) (:display-for %)) nav-items)] 37 | [:nav.navbar.navbar-light 38 | [:div.container 39 | [:a.navbar-brand {:href "#/"} "conduit"] 40 | (when-not loading? 41 | [:ul.nav.navbar-nav.pull-xs-right 42 | (map #(rum/with-key (NavItem route %) (:label %)) user-nav-items)])]])) 43 | -------------------------------------------------------------------------------- /src/conduit/components/base.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.base 2 | (:require [rum.core :as rum])) 3 | 4 | (rum/defc Icon 5 | ([icon] (Icon {} icon)) 6 | ([{:keys [on-click]} icon] 7 | [:i {:on-click on-click 8 | :class (str "ion-" (name icon))}])) 9 | 10 | 11 | (defn- btn-class [class type size outline?] 12 | (str 13 | class 14 | " " 15 | (case size 16 | :L "btn-lg" 17 | "btn-sm") 18 | " " 19 | (case type 20 | :secondary (str "btn-" (if outline? "outline-secondary" "secondary")) 21 | (str "btn-" (if outline? "outline-primary" "primary"))))) 22 | 23 | (rum/defc Button 24 | ([label] 25 | (Button {} label)) 26 | ([{:keys [icon class type size outline? disabled? on-click]} label] 27 | [:button.btn 28 | {:class (btn-class class type size outline?) 29 | :disabled disabled? 30 | :on-click on-click} 31 | (when icon (Icon icon)) 32 | (when icon " ") 33 | label])) 34 | 35 | (rum/defc ArticleMeta [{:keys [image username createdAt]} & children] 36 | [:header.article-meta 37 | [:a {:href "profile.html"} 38 | [:img {:src image}]] 39 | [:div.info 40 | [:a.author {:href ""} username] 41 | [:span.date (-> createdAt js/Date. .toDateString)]] 42 | children]) 43 | 44 | (rum/defc Tags [tags] 45 | [:ul.tags-list 46 | (->> tags 47 | (map (fn [tag] 48 | [:li.tag-default.tag-pill.tag-outline 49 | {:key tag} 50 | [:a {:href (str "#/tag/" tag)} 51 | tag]])))]) 52 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require 3 | [figwheel-sidecar.repl-api :as f])) 4 | 5 | ;; user is a namespace that the Clojure runtime looks for and 6 | ;; loads if its available 7 | 8 | ;; You can place helper functions in here. This is great for starting 9 | ;; and stopping your webserver and other development services 10 | 11 | ;; The definitions in here will be available if you run "lein repl" or launch a 12 | ;; Clojure repl some other way 13 | 14 | ;; You have to ensure that the libraries you :require are listed in your dependencies 15 | 16 | ;; Once you start down this path 17 | ;; you will probably want to look at 18 | ;; tools.namespace https://github.com/clojure/tools.namespace 19 | ;; and Component https://github.com/stuartsierra/component 20 | 21 | 22 | (defn fig-start 23 | "This starts the figwheel server and watch based auto-compiler." 24 | [] 25 | ;; this call will only work are long as your :cljsbuild and 26 | ;; :figwheel configurations are at the top level of your project.clj 27 | ;; and are not spread across different lein profiles 28 | 29 | ;; otherwise you can pass a configuration into start-figwheel! manually 30 | (f/start-figwheel!)) 31 | 32 | (defn fig-stop 33 | "Stop the figwheel server and watch based auto-compiler." 34 | [] 35 | (f/stop-figwheel!)) 36 | 37 | ;; if you are in an nREPL environment you will need to make sure you 38 | ;; have setup piggieback for this to work 39 | (defn cljs-repl 40 | "Launch a ClojureScript REPL that is connected to your build and host environment." 41 | [] 42 | (f/cljs-repl)) 43 | -------------------------------------------------------------------------------- /src/conduit/components/comment.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.comment 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus] 4 | [conduit.components.base :as base])) 5 | 6 | (rum/defc Form < rum/reactive [r] 7 | (let [avatar-url (rum/react (citrus/subscription r [:user :current-user :image]))] 8 | [:form.card.comment-form 9 | [:div.card-block 10 | [:textarea.form-control 11 | {:placeholder "Write a comment..." 12 | :rows 3}]] 13 | [:div.card-footer 14 | [:img.comment-author-img {:src avatar-url}] 15 | (base/Button "Post Comment")]])) 16 | 17 | (rum/defc Options < rum/reactive [r comment] 18 | (let [user (rum/react (citrus/subscription r [:user])) 19 | author-username (get-in comment [:author :username]) 20 | {params :route-params} (rum/react (citrus/subscription r [:router])) 21 | handle-delete #(citrus/dispatch! r :comments :delete-comment (:id params) (:id comment) (:token user))] 22 | (when (= author-username (get-in user [:current-user :username])) 23 | [:div.mod-options 24 | (base/Icon {:on-click handle-delete} :trash-a)]))) 25 | 26 | (rum/defc Comment [r {:keys [body author createdAt] :as comment}] 27 | (let [{:keys [username image]} author] 28 | [:div.card 29 | [:div.card-block 30 | [:p.card-text body]] 31 | [:div.card-footer 32 | [:a.comment-author {:href (str "#/profile/" username)} 33 | [:img.comment-author-img {:src image}]] 34 | [:span " "] 35 | [:a.comment-author {:href (str "#/profile/" username)} 36 | username] 37 | [:span.date-posted 38 | (-> createdAt js/Date. .toDateString)] 39 | (Options r comment)]])) 40 | -------------------------------------------------------------------------------- /src/conduit/effects.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.effects 2 | (:require [citrus.core :as citrus] 3 | [conduit.api :as api] 4 | [promesa.core :as p])) 5 | 6 | (defmulti dispatch! (fn [_ _ effect] 7 | (type effect))) 8 | 9 | (defmethod dispatch! Keyword [r c event & args] 10 | (apply citrus/dispatch! r c event args)) 11 | 12 | (defmethod dispatch! PersistentArrayMap [r c effects & oargs] 13 | (doseq [[effect [c event & args]] effects] 14 | (apply dispatch! r c event (concat args oargs)))) 15 | 16 | (defn http [r c {:keys [endpoint params slug on-load on-error method type headers token]}] 17 | (-> (api/fetch {:endpoint endpoint 18 | :params params 19 | :slug slug 20 | :method method 21 | :type type 22 | :headers headers 23 | :token token}) 24 | (p/then #(dispatch! r c on-load %)) 25 | (p/catch #(dispatch! r c on-error %)))) 26 | 27 | 28 | (defmulti local-storage (fn [_ _ params] (:action params))) 29 | 30 | (defmethod local-storage :get [r c {:keys [id on-success on-error]}] 31 | (if-let [token (.getItem js/localStorage id)] 32 | (when on-success 33 | (dispatch! r c on-success token)) 34 | (when on-error 35 | (dispatch! r c on-error)))) 36 | 37 | (defmethod local-storage :set [_ _ {:keys [id value]}] 38 | (.setItem js/localStorage id value)) 39 | 40 | (defmethod local-storage :remove [_ _ {:keys [id]}] 41 | (.removeItem js/localStorage id)) 42 | 43 | 44 | (defn redirect [_ _ path] 45 | (set! (.-hash js/location) (str "#/" path))) 46 | 47 | 48 | (defn dispatch [r _ events] 49 | (doseq [[ctrl event-vector] events] 50 | (apply citrus/dispatch! (into [r ctrl] event-vector)))) 51 | -------------------------------------------------------------------------------- /src/conduit/controllers/article.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.controllers.article) 2 | 3 | (def initial-state 4 | {:loading? false 5 | :article nil 6 | :errors nil}) 7 | 8 | (defmulti control (fn [event] event)) 9 | 10 | (defmethod control :default [_ _ state] 11 | {:state state}) 12 | 13 | (defmethod control :init [] 14 | {:state initial-state}) 15 | 16 | (defmethod control :load [_ [{:keys [id]}]] 17 | {:state {:loading? true} 18 | :http {:endpoint :article 19 | :slug id 20 | :on-load :load-ready}}) 21 | 22 | (defmethod control :load-ready [_ [{:keys [article]}]] 23 | {:state {:article article 24 | :loading? false}}) 25 | 26 | (defmethod control :update [_ [id transform data] state] 27 | {:state (merge state (transform data))}) 28 | 29 | (defmethod control :save [_ [{:keys [title description body tagList]} token id] state] 30 | (let [http-params (if id 31 | {:endpoint :article 32 | :slug id 33 | :method :put} 34 | {:endpoint :articles 35 | :method :post})] 36 | {:state (assoc state :loading? true) 37 | :http (into http-params 38 | {:params {:article {:title title 39 | :description description 40 | :body body 41 | :tagList tagList}} 42 | :token token 43 | :on-load :save-success 44 | :on-error :save-error})})) 45 | 46 | (defmethod control :save-success [_ [{article :article}] state] 47 | {:state (assoc state :article article 48 | :errors nil 49 | :loading? false) 50 | :redirect (str "article/" (:slug article))}) 51 | 52 | (defmethod control :save-error [_ [{errors :errors}] state] 53 | {:state (assoc state :errors errors 54 | :loading? false)}) -------------------------------------------------------------------------------- /src/conduit/components/forms/comment.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.forms.comment 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus] 4 | [conduit.components.base :as base] 5 | [conduit.helpers.form :as form-helper] 6 | [conduit.mixins :as mixins])) 7 | 8 | (def comment-form 9 | {:fields {:comment {:placeholder "Write a comment"}} 10 | :validators {:comment [[form-helper/present? "Please enter a comment"]]}}) 11 | 12 | (rum/defcs CommentForm < rum/reactive 13 | (mixins/dispatch-on-mount 14 | (fn [] 15 | {:form [:init-form]})) 16 | [state r] 17 | (let [{avatar-url :image token :token} (rum/react (citrus/subscription r [:user :current-user])) 18 | {params :route-params} (rum/react (citrus/subscription r [:router])) 19 | comments (rum/react (citrus/subscription r [:comments])) 20 | form (rum/react (citrus/subscription r [:form])) 21 | disabled? (or (:has-errors? form) (:pristine? form) (:loading? comments)) 22 | placeholder (get-in form [:fields :comment :placeholder]) 23 | handle-submit (fn [e] 24 | (.preventDefault e) 25 | (citrus/dispatch! r :comments :create 26 | (:id params) 27 | (get-in form [:data :comment]) 28 | token))] 29 | [:form.card.comment-form 30 | {:on-submit handle-submit} 31 | [:div.card-block 32 | [:textarea.form-control 33 | {:placeholder placeholder 34 | :rows 3 35 | :value (get-in form [:data :comment]) 36 | :on-change #(->> % .-target .-value (citrus/dispatch! r :form :change :comment)) 37 | :on-blur #(citrus/dispatch! r :form :validate :comment) 38 | :on-focus #(citrus/dispatch! r :form :focus :comment)}]] 39 | [:div.card-footer 40 | [:img.comment-author-img {:src avatar-url}] 41 | (base/Button {:disabled? disabled?} "Post Comment")]])) -------------------------------------------------------------------------------- /src/conduit/controllers/articles.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.controllers.articles) 2 | 3 | (def initial-state 4 | {:articles [] 5 | :pages-count 0 6 | :loading? false}) 7 | 8 | (defmulti control (fn [event] event)) 9 | 10 | (defmethod control :default [_ _ state] 11 | {:state state}) 12 | 13 | (defmethod control :init [] 14 | {:state initial-state}) 15 | 16 | (defmethod control :load [_ [{:keys [page]}] state] 17 | {:state (assoc state :loading? true) 18 | :http {:endpoint :articles 19 | :params {:limit 10 20 | :offset (* (-> page (or 1) js/parseInt dec) 10)} 21 | :on-load :load-ready}}) 22 | 23 | (defmethod control :load-ready [_ [{:keys [articles articlesCount]}] state] 24 | {:state 25 | (-> state 26 | (assoc :articles articles) 27 | (assoc :pages-count (-> articlesCount (/ 10) Math/round)) 28 | (assoc :loading? false))}) 29 | 30 | (defmethod control :favorite [_ [id token callback]] 31 | {:http {:endpoint :favorite 32 | :slug id 33 | :method :post 34 | :token token 35 | :on-load callback 36 | :on-error callback}}) 37 | 38 | (defmethod control :favorite-ready [_ _ state] 39 | {:state state}) 40 | 41 | (defmethod control :favorite-error [_ _ state] 42 | {:state state}) 43 | 44 | (defmethod control :unfavorite [_ [id token callback]] 45 | {:http {:endpoint :favorite 46 | :slug id 47 | :method :delete 48 | :token token 49 | :on-load callback 50 | :on-error callback}}) 51 | 52 | (defmethod control :unfavorite-ready [_ _ state] 53 | {:state state}) 54 | 55 | (defmethod control :unfavorite-error [_ _ state] 56 | {:state state}) 57 | 58 | (defmethod control :update [_ [id transform data] state] 59 | {:state (update state :articles (fn [articles] 60 | (map 61 | (fn [article] 62 | (if (= (:slug article) id) 63 | (transform data) 64 | article)) 65 | articles)))}) 66 | -------------------------------------------------------------------------------- /src/conduit/controllers/comments.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.controllers.comments) 2 | 3 | (def initial-state 4 | {:error nil 5 | :comments [] 6 | :loading? false 7 | :comments-candidate nil}) 8 | 9 | (defmulti control (fn [event] event)) 10 | 11 | (defmethod control :default [_ _ state] 12 | {:state state}) 13 | 14 | (defmethod control :init [] 15 | {:state initial-state}) 16 | 17 | (defmethod control :create [_ [article-id body token] state] 18 | {:state (assoc state :loading? true) 19 | :http {:endpoint :comments 20 | :slug article-id 21 | :method :post 22 | :params {:comment {:body body}} 23 | :token token 24 | :on-load :add-comment 25 | :on-error :save-error}}) 26 | 27 | (defmethod control :load [_ [{:keys [id]}] _] 28 | {:state (assoc initial-state :loading? true) 29 | :http {:endpoint :comments 30 | :slug id 31 | :on-load :load-ready}}) 32 | 33 | (defmethod control :add-comment [_ [{:keys [comment]}] state] 34 | {:state (-> state 35 | (assoc :loading? false) 36 | (update :comments conj comment)) 37 | :dispatch {:form [:reset]}}) 38 | 39 | (defmethod control :delete-comment [_ [article-id comment-id token] state] 40 | {:state (assoc state :comments-candidate (remove #(= (:id %) comment-id) (:comments state)) 41 | :loading? true) 42 | :http { 43 | :endpoint :comment 44 | :slug [article-id comment-id] 45 | :method :delete 46 | :token token 47 | :on-load :use-comments-candidate 48 | :on-error :save-error}}) 49 | 50 | (defmethod control :load-ready [_ [{:keys [comments]}] state] 51 | {:state (assoc state :comments comments 52 | :loading? false)}) 53 | 54 | (defmethod control :use-comments-candidate [_ _ state] 55 | {:state (assoc state :comments (:comments-candidate state) 56 | :comments-candidate nil 57 | :loading? false)}) 58 | 59 | (defmethod control :save-error [_ [{errors :errors}] state] 60 | {:state (assoc state :errors errors 61 | :loading? false)}) 62 | 63 | -------------------------------------------------------------------------------- /src/conduit/core.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.core 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus] 4 | [goog.dom :as dom] 5 | [conduit.routes :refer [routes]] 6 | [conduit.effects :as effects] 7 | [conduit.router :as router] 8 | [conduit.controllers.articles :as articles] 9 | [conduit.controllers.tags :as tags] 10 | [conduit.controllers.tag-articles :as tag-articles] 11 | [conduit.controllers.article :as article] 12 | [conduit.controllers.comments :as comments] 13 | [conduit.controllers.router :as router-controller] 14 | [conduit.controllers.user :as user] 15 | [conduit.controllers.profile :as profile] 16 | [conduit.controllers.form :as form] 17 | [conduit.components.root :refer [Root]] 18 | [conduit.components.article :refer [Article]])) 19 | 20 | ;; create Reconciler instance 21 | (defonce reconciler 22 | (citrus/reconciler 23 | {:state (atom {}) 24 | :controllers {:articles articles/control 25 | :tag-articles tag-articles/control 26 | :tags tags/control 27 | :article article/control 28 | :comments comments/control 29 | :router router-controller/control 30 | :user user/control 31 | :profile profile/control 32 | :form form/control} 33 | :effect-handlers {:http effects/http 34 | :local-storage effects/local-storage 35 | :redirect effects/redirect 36 | :dispatch effects/dispatch}})) 37 | 38 | ;; initialize controllers 39 | (defonce init-ctrl (citrus/broadcast-sync! reconciler :init)) 40 | 41 | (router/start! (fn [route] 42 | (doall 43 | [(citrus/dispatch! reconciler :router :push route) 44 | (when (= (:handler route) :logout) 45 | (citrus/dispatch! reconciler :user :logout))])) 46 | routes) 47 | 48 | (rum/mount (Root reconciler) 49 | (dom/getElement "app")) 50 | -------------------------------------------------------------------------------- /src/conduit/api.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.api 2 | (:require [httpurr.client.xhr :as xhr] 3 | [httpurr.status :as status] 4 | [promesa.core :as p])) 5 | 6 | (defmulti ->endpoint (fn [id] id)) 7 | 8 | (defmethod ->endpoint :articles [_ _] 9 | "articles") 10 | 11 | (defmethod ->endpoint :tags [_ _] 12 | "tags") 13 | 14 | (defmethod ->endpoint :article [_ slug] 15 | (str "articles/" slug)) 16 | 17 | (defmethod ->endpoint :comments [_ slug] 18 | (str "articles/" slug "/comments")) 19 | 20 | (defmethod ->endpoint :comment [_ [article-id comment-id]] 21 | (str "articles/" article-id "/comments/" comment-id)) 22 | 23 | (defmethod ->endpoint :users [_ _] 24 | "users") 25 | 26 | (defmethod ->endpoint :login [_ _] 27 | "users/login") 28 | 29 | (defmethod ->endpoint :user [_ _] 30 | "user") 31 | 32 | (defmethod ->endpoint :follow [_ slug] 33 | (str "profiles/" slug "/follow")) 34 | 35 | (defmethod ->endpoint :favorite [_ slug] 36 | (str "articles/" slug "/favorite")) 37 | 38 | (defn- ->uri [path] 39 | (str "https://conduit.productionready.io/api/" path)) 40 | 41 | (defn- parse-body [res] 42 | (-> res 43 | js/JSON.parse 44 | (js->clj :keywordize-keys true))) 45 | 46 | (defn- ->json [params] 47 | (.stringify js/JSON (clj->js params))) 48 | 49 | (defn- ->xhr [uri xhr-fn params] 50 | (-> uri 51 | (xhr-fn params) 52 | (p/then (fn [{status :status body :body :as response}] 53 | (condp = status 54 | status/ok (p/resolved (parse-body body)) 55 | (p/rejected (parse-body body))))))) 56 | 57 | (defn- method->xhr-fn [method] 58 | (case method 59 | :post xhr/post 60 | :put xhr/put 61 | :patch xhr/patch 62 | :delete xhr/delete 63 | xhr/get)) 64 | 65 | (defn- type->header [type] 66 | (case type 67 | :text {"Content-Type" "text/plain"} 68 | {"Content-Type" "application/json"})) 69 | 70 | (defn- token->header [token] 71 | (if token 72 | {"Authorization" (str "Token " token)} 73 | {})) 74 | 75 | (defn fetch [{:keys [endpoint params slug method type headers token]}] 76 | (let [xhr-fn (method->xhr-fn method) 77 | xhr-params {:query-params (when-not (contains? #{:post :put :patch} method) params) 78 | :body (when (contains? #{:post :put :patch} method) (->json params)) 79 | :headers (merge headers (type->header type) (token->header token))}] 80 | (-> (->endpoint endpoint slug) 81 | ->uri 82 | (->xhr xhr-fn xhr-params)))) 83 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject conduit "0.1.0-SNAPSHOT" 2 | :description "FIXME: write this!" 3 | :url "http://example.com/FIXME" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | 7 | :min-lein-version "2.7.1" 8 | 9 | :dependencies [[org.clojure/clojure "1.10.0"] 10 | [org.clojure/clojurescript "1.10.439"] 11 | [rum "0.11.2"] 12 | [org.roman01la/citrus "3.0.0"] 13 | [bidi "2.1.2"] 14 | [funcool/promesa "1.9.0"] 15 | [funcool/httpurr "1.0.0"] 16 | [markdown-clj "1.0.1"]] 17 | 18 | :plugins [[lein-figwheel "0.5.17"] 19 | [lein-cljsbuild "1.1.7" :exclusions [[org.clojure/clojure]]]] 20 | 21 | :source-paths ["src"] 22 | 23 | :cljsbuild {:builds 24 | [{:id "dev" 25 | :source-paths ["src"] 26 | :figwheel true 27 | :compiler {:main conduit.core 28 | :asset-path "/js/compiled/out" 29 | :output-to "resources/public/js/compiled/conduit.js" 30 | :output-dir "resources/public/js/compiled/out" 31 | :source-map-timestamp true 32 | :preloads [devtools.preload]}} 33 | {:id "min" 34 | :source-paths ["src"] 35 | :compiler {:output-to "resources/public/js/compiled/conduit.js" 36 | :main conduit.core 37 | :optimizations :advanced 38 | :parallel-build true 39 | :closure-defines {"goog.DEBUG" false} 40 | :pretty-print false}}]} 41 | 42 | :figwheel {:css-dirs ["resources/public/css"] 43 | :server-port 3000 44 | :ring-handler server/handler} 45 | 46 | :profiles {:dev {:dependencies [[binaryage/devtools "0.9.4"] 47 | [figwheel-sidecar "0.5.17"] 48 | [com.cemerick/piggieback "0.2.1"] 49 | [ring "1.5.1"] 50 | [ring/ring-defaults "0.2.1"] 51 | [compojure "1.5.0"]] 52 | 53 | :source-paths ["src" "dev"] 54 | ;; for CIDER 55 | ;; :plugins [[cider/cider-nrepl "0.12.0"]] 56 | :repl-options {:nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]} 57 | :clean-targets ^{:protect false} ["resources/public/js/compiled" 58 | :target-path]}}) 59 | -------------------------------------------------------------------------------- /src/conduit/controllers/form.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.controllers.form) 2 | 3 | (def initial-state 4 | {:init-data nil 5 | :init-fields nil 6 | :data nil 7 | :errors nil 8 | :fields nil 9 | :pristine? true 10 | :has-errors? false 11 | :validators nil}) 12 | 13 | (defmulti control (fn [event] event)) 14 | 15 | (defmethod control :default [_ _ state] 16 | {:state state}) 17 | 18 | (defmethod control :init [] 19 | {:state initial-state}) 20 | 21 | (defn fields-description->form-init-data [fields-description] 22 | (->> fields-description 23 | keys 24 | (reduce 25 | #(assoc %1 %2 (get-in fields-description [%2 :initial-value] "")) 26 | {}))) 27 | 28 | (defn check-errors [validators value] 29 | (->> validators 30 | (filter (fn [[validator]] (-> value validator not))) 31 | (map second))) 32 | 33 | (defn get-field-errors [form name value] 34 | (let [{:keys [validators]} form 35 | field-validators (get validators name)] 36 | (check-errors field-validators value))) 37 | 38 | ;; form updaters 39 | 40 | (defn update-has-errors? [form] 41 | (->> (form :errors) 42 | vals (apply concat) (every? nil?) not 43 | (assoc form :has-errors?))) 44 | 45 | (defn init-errors [form] 46 | (->> (:fields form) 47 | keys 48 | (reduce #(assoc %1 %2 nil) {}) 49 | (assoc form :errors))) 50 | 51 | (defn update-pristine? [form] 52 | (->> (form :data) 53 | (reduce-kv 54 | (fn [res k _] 55 | (and res (= (get-in form [:data k]) 56 | (get-in form [:init-data k])))) 57 | true) 58 | (assoc form :pristine?))) 59 | 60 | (defn reset [form] 61 | (-> form 62 | (assoc :data (:init-data form)) 63 | (assoc :fields (:init-fields form)) 64 | init-errors 65 | update-has-errors? 66 | (assoc :pristine? false) 67 | update-pristine?)) 68 | 69 | ;; ------------------------- 70 | 71 | (defmethod control :init-form [_ [form-description] state] 72 | (let [{:keys [fields validators]} form-description 73 | init-data (fields-description->form-init-data fields)] 74 | {:state (-> state 75 | (assoc :init-data init-data) 76 | (assoc :init-fields fields) 77 | (assoc :data init-data) 78 | (assoc :fields fields) 79 | (assoc :validators validators) 80 | init-errors 81 | update-has-errors?)})) 82 | 83 | (defmethod control :validate [_ [name] state] 84 | {:state (-> state 85 | (assoc-in [:errors name] (get-field-errors state name (get-in state [:data name]))) 86 | update-has-errors?)}) 87 | 88 | (defmethod control :change [_ [name value] state] 89 | {:state (-> state 90 | (assoc-in [:data name] value) 91 | (assoc-in [:errors name] (get-field-errors state name value)) 92 | update-has-errors? 93 | update-pristine?)}) 94 | 95 | (defmethod control :focus [_ [name] state] 96 | {:state (assoc-in state [:fields name :touched?] true)}) 97 | 98 | (defmethod control :reset [_ _ state] 99 | {:state (reset state)}) -------------------------------------------------------------------------------- /src/conduit/components/forms/user_settings.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.forms.user-settings 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus] 4 | [conduit.mixins :as mixins] 5 | [conduit.components.base :as base] 6 | [conduit.components.forms :refer [InputField TextareaField ServerErrors with-prevent-default]] 7 | [conduit.helpers.form :as form-helper])) 8 | 9 | (def user-settings-form 10 | {:fields {:image {:placeholder "URL of profile picture"} 11 | :username {:placeholder "Username"} 12 | :bio {:placeholder "Short bio about you" 13 | :component :textarea} 14 | :email {:placeholder "Email" 15 | :type "email"} 16 | :password {:placeholder "Password" 17 | :type "password"}} 18 | :validators {:username [[form-helper/present? "Please enter username"]] 19 | :email [[form-helper/present? "Please enter email"] 20 | [form-helper/email? "Invalid Email"]] 21 | :password [[form-helper/present? "Please enter password"] 22 | [(form-helper/length? {:min 8}) "Password is too short (minimum is 8 characters)"]]} 23 | :on-submit 24 | (fn [reconciler data] 25 | (let [{:keys [image username bio email password]} data] 26 | (citrus/dispatch! reconciler :user :update-settings 27 | {:image image 28 | :username username 29 | :bio bio 30 | :email email 31 | :password password})))}) 32 | 33 | (def get-field 34 | {:input InputField 35 | :textarea TextareaField}) 36 | 37 | (rum/defcs UserSettings < rum/reactive 38 | (mixins/form user-settings-form) 39 | {:will-unmount 40 | (fn [{[r] :rum/args :as state}] 41 | (citrus/dispatch! r :user :clear-errors) 42 | state)} 43 | [state r _ _ _] 44 | (let [{{:keys [fields data errors on-submit on-change on-focus validate has-errors? pristine?]} ::mixins/form} state 45 | loading? (rum/react (citrus/subscription r [:user :loading?])) 46 | server-errors (rum/react (citrus/subscription r [:user :errors])) 47 | disabled? (or has-errors? pristine? loading?)] 48 | [:form {:on-submit (when-not has-errors? 49 | (comp on-submit with-prevent-default))} 50 | [:fieldset 51 | (when server-errors 52 | (ServerErrors server-errors)) 53 | (for [[key {:keys [placeholder type container events component]}] fields] 54 | (let [value (get data key) 55 | Field (get-field (or component :input))] 56 | (rum/with-key 57 | (Field 58 | {:placeholder placeholder 59 | :type type 60 | :value value 61 | :errors (-> (get errors key) seq) 62 | :on-blur #(validate key value) 63 | :on-focus #(on-focus key) 64 | :on-change #(do 65 | (validate key %) 66 | (on-change key %)) 67 | :container container 68 | :events events}) 69 | key))) 70 | (base/Button 71 | {:class "pull-xs-right" 72 | :outline? false 73 | :disabled? disabled? 74 | :size :L} 75 | "Update Settings")]])) 76 | -------------------------------------------------------------------------------- /src/conduit/controllers/user.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.controllers.user) 2 | 3 | (def initial-state 4 | {:current-user nil 5 | :loading? false 6 | :token nil 7 | :errors nil}) 8 | 9 | (defmulti control (fn [event] event)) 10 | 11 | (defmethod control :default [_ _ state] 12 | {:state state}) 13 | 14 | (defmethod control :init [] 15 | {:state initial-state}) 16 | 17 | (defmethod control :login [_ [{:keys [email password]}] _] 18 | {:state (assoc initial-state :loading? true) 19 | :http {:endpoint :login 20 | :params {:user {:email email :password password}} 21 | :method :post 22 | :type :json 23 | :on-load :login-success 24 | :on-error :form-submit-error}}) 25 | 26 | (defmethod control :login-success [_ [{user :user {token :token} :user}] state] 27 | {:state (assoc state :token token 28 | :current-user user 29 | :errors nil 30 | :loading? false) 31 | :local-storage {:action :set 32 | :id "jwt-token" 33 | :value token} 34 | :redirect ""}) 35 | 36 | (defmethod control :register [_ [{:keys [username email password]}] _] 37 | {:state (assoc initial-state assoc :loading? true) 38 | :http {:endpoint :users 39 | :params {:user {:username username :email email :password password}} 40 | :method :post 41 | :type :json 42 | :on-load :register-success 43 | :on-error :form-submit-error}}) 44 | 45 | (defmethod control :register-success [_ [{user :user {token :token} :user}] state] 46 | {:state (assoc state 47 | :token token 48 | :current-user user 49 | :errors nil 50 | :loading? false) 51 | :local-storage {:action :set 52 | :id "jwt-token" 53 | :value token} 54 | :redirect ""}) 55 | 56 | (defmethod control :form-submit-error [_ [{errors :errors}] state] 57 | {:state (assoc state 58 | :errors errors 59 | :loading? false)}) 60 | 61 | (defmethod control :clear-errors [_ _ state] 62 | {:state (assoc state :errors nil)}) 63 | 64 | (defmethod control :check-auth [_ _ state] 65 | {:state state 66 | :local-storage {:action :get 67 | :id "jwt-token" 68 | :on-success :load-user}}) 69 | 70 | (defmethod control :load-user [_ [token] state] 71 | {:state (assoc state :token token :loading? true) 72 | :http {:endpoint :user 73 | :token token 74 | :on-load :load-user-success}}) 75 | 76 | (defmethod control :load-user-success [_ [{:keys [user]}] state] 77 | {:state (assoc state 78 | :current-user user 79 | :loading? false)}) 80 | 81 | (defmethod control :update-settings [_ [{:keys [username email password image bio]}] state] 82 | {:state (assoc state :loading? true) 83 | :http {:endpoint :user 84 | :params {:user {:username username 85 | :email email 86 | :password password 87 | :image image 88 | :bio bio}} 89 | :method :put 90 | :token (:token state) 91 | :on-load :update-settings-success 92 | :on-error :form-submit-error}}) 93 | 94 | (defmethod control :update-settings-success [_ [{user :user}] state] 95 | {:state (assoc state :current-user user 96 | :errors nil 97 | :loading? false) 98 | :redirect ""}) 99 | 100 | (defmethod control :logout [] 101 | {:state initial-state 102 | :local-storage {:action :remove 103 | :id "jwt-token"} 104 | :redirect ""}) 105 | -------------------------------------------------------------------------------- /src/conduit/components/forms/article.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.forms.article 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus] 4 | [conduit.mixins :as mixins] 5 | [conduit.components.base :as base] 6 | [conduit.components.forms :refer [InputField 7 | TextareaField 8 | ServerErrors 9 | TagInputFieldContainer 10 | with-prevent-default]] 11 | [conduit.helpers.form :as form-helper])) 12 | 13 | (defn- handle-keydown [data errors key] 14 | #(when (= 13 (.-keyCode %)) 15 | (.preventDefault %) 16 | (let [tagList (or (:tagList @data) []) 17 | tag (clojure.string/trim (key @data))] 18 | (swap! data assoc 19 | :tagList (if (and (not (empty? tag)) (= -1 (.indexOf tagList tag))) 20 | (conj (vec tagList) tag) tagList) 21 | key "")))) 22 | 23 | (defn- handle-submit [reconciler data errors validators [token id]] 24 | (let [{:keys [title description body tagList]} data] 25 | (citrus/dispatch! reconciler :article :save 26 | {:title title 27 | :description description 28 | :body body 29 | :tagList tagList} 30 | token 31 | id))) 32 | 33 | (def article-form 34 | {:fields {:title {:placeholder "Article Title"} 35 | :description {:placeholder "What's this article about?"} 36 | :body {:placeholder "Write your article (in markdown)"} 37 | :tag {:placeholder "Enter tags" 38 | :container TagInputFieldContainer 39 | :events {:on-key-down handle-keydown}} 40 | :tagList {:hidden true 41 | :initial-value []}} 42 | :validators {:title [[form-helper/present? "Please enter title"]] 43 | :body [[form-helper/present? "Please enter body"]]} 44 | :on-submit handle-submit}) 45 | 46 | (rum/defcs ArticleForm < rum/reactive 47 | (mixins/form article-form) 48 | {:will-unmount 49 | (fn [{[r] :rum/args :as state}] 50 | (citrus/dispatch! r :article :init) 51 | state)} 52 | [state r _ _ article-to-update] 53 | (let [{{:keys [fields data errors on-submit on-change on-focus validate pristine? has-errors?]} ::mixins/form} state 54 | token (rum/react (citrus/subscription r [:user :token])) 55 | server-errors (rum/react (citrus/subscription r [:article :errors])) 56 | loading? (rum/react (citrus/subscription r [:article :loading?])) 57 | disabled? (or has-errors? pristine? loading?)] 58 | [:form {:on-submit (when-not has-errors? 59 | (comp on-submit (fn [] [token (get article-to-update :slug)]) with-prevent-default))} 60 | (when server-errors 61 | (ServerErrors server-errors)) 62 | (for [[key {:keys [placeholder type container events]}] fields] 63 | (let [value (get data key)] 64 | (rum/with-key 65 | (InputField 66 | {:placeholder placeholder 67 | :type type 68 | :value value 69 | :errors (-> (get errors key) seq) 70 | :on-blur #(validate key value) 71 | :on-focus #(on-focus key) 72 | :on-change #(do 73 | (validate key %) 74 | (on-change key %)) 75 | :container container 76 | :events events}) 77 | key))) 78 | (base/Button 79 | {:class "pull-xs-right" 80 | :outline? false 81 | :disabled? disabled? 82 | :size :L} 83 | "Publish Article")])) -------------------------------------------------------------------------------- /src/conduit/mixins.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.mixins 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus])) 4 | 5 | (defn dispatch-on-mount [events-fn] 6 | {:did-mount 7 | (fn [{[r] :rum/args :as state}] 8 | (doseq [[ctrl event-vector] (apply events-fn (:rum/args state))] 9 | (apply citrus/dispatch! (into [r ctrl] event-vector))) 10 | state) 11 | :did-remount 12 | (fn [old {[r] :rum/args :as state}] 13 | (when (not= (:rum/args old) (:rum/args state)) 14 | (doseq [[ctrl event-vector] (apply events-fn (:rum/args state))] 15 | (apply citrus/dispatch! (into [r ctrl] event-vector)))) 16 | state)}) 17 | 18 | (defn- check-errors [validators value] 19 | (->> validators 20 | (filter (fn [[validator]] (-> value validator not))) 21 | (map second))) 22 | 23 | (defn- remove-hidden-fields [fields] 24 | (reduce-kv 25 | (fn [m k v] 26 | (if-not (contains? v :hidden) 27 | (assoc m k v) 28 | m)) 29 | {} 30 | fields)) 31 | 32 | (defn form [{:keys [fields validators on-submit]}] 33 | (let [data-init (->> fields keys (reduce 34 | #(assoc %1 %2 (get-in fields [%2 :initial-value] "")) 35 | {})) 36 | errors-init (->> fields keys (reduce #(assoc %1 %2 nil) {})) 37 | data (atom data-init) 38 | errors (atom errors-init) 39 | fields-init (->> fields 40 | (reduce-kv 41 | (fn [m k v] 42 | (assoc m k (-> v 43 | (#(if (contains? % :container) 44 | (assoc % :container ((:container %) data errors k)) %)) 45 | (#(if (contains? % :events) 46 | (assoc % :events 47 | (into {} (for [[evt-name evt-fn] (:events %)] 48 | {evt-name (evt-fn data errors k)}))) %))))) 49 | {})) 50 | fields (atom fields-init) 51 | foreign-data (atom {})] 52 | {:will-mount 53 | (fn [{[r _ _ current-values] :rum/args 54 | comp :rum/react-component 55 | :as state}] 56 | (when current-values 57 | (do 58 | (reset! data (into {} (for [[k v] @data] {k (or (get current-values k) 59 | v)}))) 60 | (reset! foreign-data current-values))) 61 | (add-watch data ::form-data (fn [_ _ old-state next-state] 62 | (when-not (= old-state next-state) 63 | (rum/request-render comp)))) 64 | (add-watch errors ::form-errors (fn [_ _ old-state next-state] 65 | (when-not (= old-state next-state) 66 | (rum/request-render comp)))) 67 | (add-watch fields ::form-fields (fn [_ _ old-state next-state] 68 | (when-not (= old-state next-state) 69 | (rum/request-render comp)))) 70 | state) 71 | :will-update 72 | (fn [{[_ _ _ current-values] :rum/args 73 | :as state}] 74 | (when (and current-values (not= current-values @foreign-data)) 75 | (do 76 | (reset! data (into {} 77 | (for [[k v] @data] {k (or (get current-values k) v)}))) 78 | (reset! foreign-data current-values))) 79 | state) 80 | :will-unmount 81 | (fn [state] 82 | (remove-watch data ::form-data) 83 | (remove-watch errors ::form-errors) 84 | (reset! data data-init) 85 | (reset! errors errors-init) 86 | (reset! fields fields-init) 87 | (assoc state ::form {})) 88 | :wrap-render 89 | (fn [render-fn] 90 | (fn [{[r] :rum/args :as state}] 91 | (let [has-errors? (->> @errors vals (apply concat) (every? nil?) not) 92 | pristine? (->> @fields remove-hidden-fields vals (map :touched?) (every? nil?)) 93 | state 94 | (assoc state ::form {:fields (remove-hidden-fields @fields) 95 | :validators validators 96 | :validate #(swap! errors assoc %1 (check-errors (get validators %1) %2)) 97 | :on-change #(swap! data assoc %1 %2) 98 | :on-submit #(on-submit r @data @errors validators %) 99 | :on-focus #(swap! fields assoc-in [% :touched?] true) 100 | :data @data 101 | :errors @errors 102 | :has-errors? has-errors? 103 | :pristine? pristine?})] 104 | (render-fn state))))})) 105 | -------------------------------------------------------------------------------- /src/conduit/components/article.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.article 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus] 4 | [markdown.core :as md] 5 | [conduit.components.base :as base] 6 | [conduit.components.grid :as grid] 7 | [conduit.mixins :as mixins] 8 | [conduit.components.comment :as comment] 9 | [conduit.components.base :refer [Icon]] 10 | [conduit.components.forms.comment :refer [CommentForm]])) 11 | 12 | (rum/defc Banner 13 | [article user actions] 14 | (let [{:keys [loading? title author createdAt favoritesCount slug favorited?]} article 15 | {:keys [username image]} author 16 | {:keys [on-follow 17 | on-unfollow 18 | following? 19 | on-favorite 20 | on-unfavorite]} actions 21 | author-is-user? (= (:username author) (:username user))] 22 | [:div.banner 23 | (if loading? 24 | [:div.container 25 | [:span "Loading article..."]] 26 | [:div.container 27 | [:h1 title] 28 | (base/ArticleMeta 29 | {:username username 30 | :createdAt createdAt 31 | :image image} 32 | (if author-is-user? 33 | [:a.btn.btn-outline-secondary.btn-sm 34 | {:href (str "#/editor/" slug)} 35 | (Icon "edit") 36 | " Edit Article"] 37 | (base/Button 38 | {:icon :plus-round 39 | :type :secondary 40 | :on-click (if following? on-unfollow on-follow)} 41 | (if following? 42 | (str "Unfollow " username " ") 43 | (str "Follow " username " ")))) 44 | [:span " "] 45 | (base/Button 46 | {:icon :heart 47 | :on-click (if favorited? on-unfavorite on-favorite)} 48 | (if favorited? 49 | (str "Unfavorite Post (" favoritesCount ")") 50 | (str "Favorite Post (" favoritesCount ")"))))])])) 51 | 52 | (rum/defc Actions 53 | [{:keys [author createdAt favoritesCount]} 54 | {:keys [on-follow 55 | on-unfollow 56 | following? 57 | on-favorite 58 | on-unfavorite 59 | favorited?]}] 60 | (let [{:keys [username image]} author] 61 | [:div.article-actions 62 | (base/ArticleMeta 63 | {:username username 64 | :createdAt createdAt 65 | :image image} 66 | (base/Button 67 | {:icon :plus-round 68 | :type :secondary 69 | :on-click (if following? on-unfollow on-follow)} 70 | (if following? 71 | (str "Unfollow " username " ") 72 | (str "Follow " username " "))) 73 | [:span " "] 74 | (base/Button 75 | {:icon :heart 76 | :on-click (if favorited? on-unfavorite on-favorite)} 77 | (if favorited? 78 | (str "Unfavorite Post (" favoritesCount ")") 79 | (str "Favorite Post (" favoritesCount ")"))))])) 80 | 81 | (rum/defc Page [r {:keys [body tagList] :as article} comments actions] 82 | [:div.container.page 83 | (grid/Row 84 | (grid/Column "col-md-12" 85 | [:div {:dangerouslySetInnerHTML 86 | {:__html (md/md->html body)}}] 87 | (base/Tags tagList))) 88 | [:hr] 89 | (Actions article actions) 90 | (grid/Row 91 | (grid/Column 92 | "col-xs-12 col-md-8 offset-md-2" 93 | (CommentForm r) 94 | (map #(comment/Comment r %) comments)))]) 95 | 96 | (rum/defc Article < 97 | rum/reactive 98 | (mixins/dispatch-on-mount 99 | (fn [_ _ {:keys [id]}] 100 | {:article [:load {:id id}] 101 | :comments [:load {:id id}]})) 102 | [r route params] 103 | (let [article (rum/react (citrus/subscription r [:article :article])) 104 | comments (rum/react (citrus/subscription r [:comments :comments])) 105 | token (rum/react (citrus/subscription r [:user :token])) 106 | user (rum/react (citrus/subscription r [:user :current-user])) 107 | {id :slug favorited? :favorited} article 108 | {user-id :username following? :following} (:author article) 109 | profile->author (fn [p] {:author (:profile p)}) 110 | on-follow #(citrus/dispatch! r :profile :follow user-id token {:dispatch [:article :update id profile->author]}) 111 | on-unfollow #(citrus/dispatch! r :profile :unfollow user-id token {:dispatch [:article :update id profile->author]}) 112 | on-favorite #(citrus/dispatch! r :articles :favorite id token {:dispatch [:article :update id :article]}) 113 | on-unfavorite #(citrus/dispatch! r :articles :unfavorite id token {:dispatch [:article :update id :article]}) 114 | actions {:on-follow on-follow 115 | :on-unfollow on-unfollow 116 | :following? following? 117 | :on-favorite on-favorite 118 | :on-unfavorite on-unfavorite 119 | :favorited? favorited?}] 120 | [:div.article-page 121 | (Banner article user actions) 122 | (Page r article comments actions)])) 123 | -------------------------------------------------------------------------------- /src/conduit/components/home.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.home 2 | (:require [rum.core :as rum] 3 | [bidi.bidi :as bidi] 4 | [citrus.core :as citrus] 5 | [conduit.mixins :as mixins] 6 | [conduit.routes :refer [routes]] 7 | [conduit.components.grid :as grid] 8 | [conduit.components.base :as base])) 9 | 10 | (rum/defc Banner [] 11 | [:div.banner 12 | [:div.container 13 | [:h1.logo-font "conduit"] 14 | [:p "A place to share your knowledge."]]]) 15 | 16 | 17 | (rum/defc FeedToggleItem [{:keys [label active? disabled? link icon]}] 18 | [:li.nav-item 19 | [:a.nav-link 20 | {:href link 21 | :class 22 | (cond 23 | active? "active" 24 | disabled? "disabled" 25 | :else nil)} 26 | (when icon 27 | [:i {:class icon}]) 28 | label]]) 29 | 30 | (rum/defc FeedToggle [tabs] 31 | [:div.feed-toggle 32 | [:ul.nav.nav-pills.outline-active 33 | (map #(rum/with-key (FeedToggleItem %) (:label %)) tabs)]]) 34 | 35 | (rum/defc ArticlePreview < rum/reactive 36 | [r article] 37 | (let [{:keys [author createdAt favoritesCount title description slug tagList favorited]} article 38 | {:keys [image username]} author 39 | token (rum/react (citrus/subscription r [:user :token])) 40 | {route :handler} (rum/react (citrus/subscription r [:router])) 41 | [on-favorite-success-handler 42 | on-unfavorite-success-handler] (if (= route :tag) 43 | [{:dispatch [:tag-articles :update slug :article]} 44 | {:dispatch [:tag-articles :update slug :article]}] 45 | [{:dispatch [:articles :update slug :article]} 46 | {:dispatch [:articles :update slug :article]}]) 47 | on-favorite #(citrus/dispatch! r :articles :favorite slug token on-favorite-success-handler) 48 | on-unfavorite #(citrus/dispatch! r :articles :unfavorite slug token on-unfavorite-success-handler)] 49 | [:div.article-preview 50 | (base/ArticleMeta 51 | {:username username 52 | :createdAt createdAt 53 | :image image} 54 | (base/Button 55 | {:icon :heart 56 | :class "pull-xs-right" 57 | :on-click (if favorited on-unfavorite on-favorite) 58 | :outline? (not favorited)} 59 | favoritesCount)) 60 | [:main 61 | [:a.preview-link {:href (str "#/article/" slug)} 62 | [:h1 title] 63 | [:p description]]] 64 | [:div.article-footer 65 | [:a.preview-link {:href (str "#/article/" slug)} 66 | [:span "Read more..."]] 67 | (base/Tags tagList)]])) 68 | 69 | 70 | (rum/defc TagItem [tag] 71 | [:a.tag-pill.tag-default {:href (str "#/tag/" tag)} 72 | tag]) 73 | 74 | (rum/defc SideBar [r tags] 75 | [:div.sidebar 76 | [:p "Popular Tags"] 77 | [:div.tag-list 78 | (map #(rum/with-key (TagItem %) %) tags)]]) 79 | 80 | 81 | (rum/defc PageItem [page route current-page slug] 82 | (let [path (apply bidi/path-for (into [routes route] (when slug [:id slug])))] 83 | [:li.page-item 84 | (when (= (if (not= js/isNaN current-page) current-page 1) page) 85 | {:class "active"}) 86 | [:a.page-link {:href (str "#" (if (= "/" path) "" path) "/page/" page)} 87 | page]])) 88 | 89 | (rum/defc Pagination [{:keys [route page pages-count slug]}] 90 | (when-not (zero? pages-count) 91 | [:nav {} 92 | (map #(rum/with-key (PageItem % route (-> page (or 1) js/parseInt) slug) %) 93 | (range 1 (inc pages-count)))])) 94 | 95 | 96 | (rum/defc Page [r {:keys [articles pagination tags tabs loading?]}] 97 | [:div.container.page 98 | (grid/Row 99 | (grid/Column "col-md-9" 100 | (FeedToggle tabs) 101 | (if (and loading? (nil? (seq articles))) 102 | [:div.loader "Loading articles..."] 103 | (->> articles 104 | (map #(rum/with-key (ArticlePreview r %) (:createdAt %))))) 105 | (when-not loading? 106 | (Pagination pagination))) 107 | (grid/Column "col-md-3" 108 | (SideBar r tags)))]) 109 | 110 | 111 | (rum/defc Layout [r data] 112 | [:div.home-page 113 | (Banner) 114 | (Page r data)]) 115 | 116 | (rum/defc -Home < rum/static 117 | [r route page {:keys [articles loading? pages-count]} tags id] 118 | (Layout r {:articles articles 119 | :loading? loading? 120 | :pagination 121 | {:pages-count pages-count 122 | :page page 123 | :slug id 124 | :route route} 125 | :tags tags 126 | :tabs 127 | [{:label "Your Feed" 128 | :active? false 129 | :link "#/"} 130 | {:label "Global Feed" 131 | :active? (nil? id) 132 | :link "#/"} 133 | (when id 134 | {:label (str " " id) 135 | :icon "ion-pound" 136 | :active? true})]})) 137 | 138 | (rum/defc Home < 139 | rum/reactive 140 | (mixins/dispatch-on-mount 141 | (fn [_ _ {:keys [page]}] 142 | {:tag-articles [:reset] 143 | :articles [:load {:page page}] 144 | :tags [:load]})) 145 | [r route {:keys [page]}] 146 | (let [articles (rum/react (citrus/subscription r [:articles])) 147 | tags (rum/react (citrus/subscription r [:tags]))] 148 | (-Home r route page articles tags nil))) 149 | 150 | (rum/defc HomeTag < 151 | rum/reactive 152 | (mixins/dispatch-on-mount 153 | (fn [_ _ {:keys [id page]}] 154 | {:tag-articles [:load {:tag id :page page}] 155 | :tags [:load]})) 156 | [r route {:keys [id page]}] 157 | (let [tag-articles (rum/react (citrus/subscription r [:tag-articles])) 158 | tags (rum/react (citrus/subscription r [:tags]))] 159 | (-Home r route page tag-articles tags id))) 160 | -------------------------------------------------------------------------------- /src/conduit/components/forms.cljs: -------------------------------------------------------------------------------- 1 | (ns conduit.components.forms 2 | (:require [rum.core :as rum] 3 | [citrus.core :as citrus] 4 | [clojure.string :as cstr] 5 | [conduit.mixins :as mixins] 6 | [conduit.components.base :as base] 7 | [conduit.helpers.form :as form-helper])) 8 | 9 | (rum/defc InputErrors [input-errors] 10 | [:ul.error-messages 11 | (for [err-msg input-errors] 12 | [:li {:key err-msg} err-msg])]) 13 | 14 | (rum/defc ServerErrors [server-errors] 15 | [:ul.error-messages 16 | (for [[k v] server-errors] 17 | [:li {:key k} 18 | (str (name k) 19 | " " 20 | (if (vector? v) 21 | (cstr/join ", " v) 22 | v))])]) 23 | 24 | (rum/defc InputFieldContainer [value & children] 25 | (apply vector :fieldset.form-group children)) 26 | 27 | (defn TagInputFieldContainer [data errors key] 28 | (fn [value & children] 29 | (let [{:keys [tagList]} @data] 30 | (apply vector :fieldset.form-group children 31 | #{[:.tag-list 32 | (map (fn [t] 33 | [:span.tag-default.tag-pill {:key t} 34 | [:i.ion-close-round 35 | {:on-click (fn [e] (swap! data assoc :tagList (remove #(= % t) tagList)))}] 36 | t]) 37 | tagList) 38 | ]})))) 39 | 40 | (rum/defc InputField 41 | [{:keys [placeholder type value errors on-blur on-focus on-change container events]}] 42 | (let [input-container (or container InputFieldContainer) 43 | input-events (merge {:on-change #(on-change (.. % -target -value)) 44 | :on-blur on-blur 45 | :on-focus on-focus} 46 | events)] 47 | (input-container 48 | value 49 | [:input.form-control.form-control-lg 50 | (into 51 | {:placeholder placeholder 52 | :type (or type :text) 53 | :value value} 54 | input-events)] 55 | (when errors 56 | (InputErrors errors))))) 57 | 58 | (rum/defc TextareaField 59 | [{:keys [placeholder type value errors on-blur on-focus on-change container events]}] 60 | (let [input-container (or container InputFieldContainer) 61 | input-events (merge {:on-change #(on-change (.. % -target -value)) 62 | :on-blur on-blur 63 | :on-focus on-focus} 64 | events)] 65 | (input-container 66 | value 67 | [:textarea.form-control.form-control-lg 68 | (into 69 | {:placeholder placeholder 70 | :type (or type :text) 71 | :value value} 72 | input-events)] 73 | (when errors 74 | (InputErrors errors))))) 75 | 76 | (def login-form 77 | {:fields {:email {:placeholder "Email"} 78 | :password {:placeholder "Password" :type "password"}} 79 | :validators {:email [[#(not (empty? %)) "Please enter email"] 80 | [form-helper/email? "Invalid Email"]] 81 | :password [[#(not (empty? %)) "Please enter password"]]} 82 | :on-submit 83 | (fn [reconciler data errors validators] 84 | (let [{:keys [email password]} data] 85 | (citrus/dispatch! reconciler :user :login {:email email 86 | :password password})))}) 87 | 88 | (def register-form 89 | {:fields {:username {:placeholder "Username"} 90 | :email {:placeholder "Email"} 91 | :password {:placeholder "Password" :type "password"}} 92 | :validators {:username [[#(not (empty? %)) "Please enter username"]] 93 | :email [[#(not (empty? %)) "Please enter email"] 94 | [form-helper/email? "Invalid Email"]] 95 | :password [[#(not (empty? %)) "Please enter password"] 96 | [(form-helper/length? {:min 8}) "Password is too short (minimum is 8 characters)"]]} 97 | :on-submit 98 | (fn [reconciler data errors validators] 99 | (let [{:keys [username email password]} data] 100 | (citrus/dispatch! reconciler :user :register {:username username 101 | :email email 102 | :password password})))}) 103 | 104 | (defn with-prevent-default [e] 105 | (.preventDefault e) 106 | e) 107 | 108 | (rum/defcs LoginForm < rum/reactive 109 | (mixins/form login-form) 110 | {:will-unmount 111 | (fn [{[r] :rum/args :as state}] 112 | (citrus/dispatch! r :user :clear-errors) 113 | state)} 114 | [state r _ _] 115 | (let [{{:keys [fields data errors on-submit on-change on-focus validate]} ::mixins/form} state 116 | server-errors (rum/react (citrus/subscription r [:user :errors])) 117 | has-errors? (->> errors vals (apply concat) (every? nil?) not) 118 | loading? (rum/react (citrus/subscription r [:user :loading?])) 119 | disabled? (or has-errors? 120 | loading? 121 | (not (->> fields vals (map #(contains? % :touched?)) (every? true?))) 122 | (->> fields vals (map :touched?) (every? nil?)))] 123 | [:form {:on-submit (when-not has-errors? 124 | (comp on-submit with-prevent-default))} 125 | (when server-errors 126 | (ServerErrors server-errors)) 127 | (for [[key {:keys [placeholder type]}] fields] 128 | (let [value (get data key)] 129 | (rum/with-key 130 | (InputField 131 | {:placeholder placeholder 132 | :type type 133 | :errors (-> (get errors key) seq) 134 | :on-blur #(validate key value) 135 | :on-focus #(on-focus key) 136 | :on-change #(do 137 | (validate key %) 138 | (on-change key %)) 139 | :value value}) 140 | key))) 141 | (base/Button 142 | {:class "pull-xs-right" 143 | :outline? false 144 | :disabled? disabled? 145 | :size :L} 146 | "Sign in")])) 147 | 148 | (rum/defcs RegisterForm < rum/reactive 149 | (mixins/form register-form) 150 | {:will-unmount 151 | (fn [{[r] :rum/args :as state}] 152 | (citrus/dispatch! r :user :clear-errors) 153 | state)} 154 | [state r _ _] 155 | (let [{{:keys [fields data errors on-submit on-change on-focus validate]} ::mixins/form} state 156 | server-errors (rum/react (citrus/subscription r [:user :errors])) 157 | has-errors? (->> errors vals (apply concat) (every? nil?) not) 158 | loading? (rum/react (citrus/subscription r [:user :loading?])) 159 | disabled? (or has-errors? 160 | loading? 161 | (not (->> fields vals (map #(contains? % :touched?)) (every? true?))) 162 | (->> fields vals (map :touched?) (every? nil?)))] 163 | [:form {:on-submit (when-not has-errors? 164 | (comp on-submit with-prevent-default))} 165 | (when server-errors 166 | (ServerErrors server-errors)) 167 | (for [[key {:keys [placeholder type]}] fields] 168 | (let [value (get data key)] 169 | (rum/with-key 170 | (InputField 171 | {:placeholder placeholder 172 | :type type 173 | :errors (-> (get errors key) seq) 174 | :on-blur #(validate key value) 175 | :on-focus #(on-focus key) 176 | :on-change #(do 177 | (validate key %) 178 | (on-change key %)) 179 | :value value}) 180 | key))) 181 | (base/Button 182 | {:class "pull-xs-right" 183 | :outline? false 184 | :disabled? disabled? 185 | :size :L} 186 | "Sign up")])) 187 | 188 | 189 | --------------------------------------------------------------------------------