├── 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 | 
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 |
--------------------------------------------------------------------------------