Repository: jacekschae/conduit Branch: master Commit: ae3c15df1b76 Files: 14 Total size: 72.8 KB Directory structure: gitextract_1n4j2xc3/ ├── .gitignore ├── LICENSE.md ├── README.md ├── package.json ├── public/ │ └── index.html ├── shadow-cljs.edn ├── src/ │ └── conduit/ │ ├── core.cljs │ ├── db.cljs │ ├── events.cljs │ ├── router.cljs │ ├── subs.cljs │ └── views.cljs └── test/ ├── core_test.cljs └── runner.cljs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ /*.log /target /*-init.clj /resources/public/js /resources/public/test .nrepl-port .lein-failures out .DS_Store /node_modules /target /public/js /.shadow-cljs /.idea /.lsp /.clj-kondo /.idea ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) [2018] [Jacek Schae] Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # ![RealWorld Example App](https://cloud.githubusercontent.com/assets/556934/25448267/85369fdc-2a7d-11e7-9613-ab5ce5e1800f.png) > ### [ClojureScript](https://clojurescript.org/) and [re-frame](https://github.com/Day8/re-frame) 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. ### [Demo](https://conduit-re-frame-demo.netlify.com/)      [Demo with re-frame-10x](https://jacekschae.github.io/conduit-re-frame-10x-demo/)      [Demo with re-frisk](https://flexsurfer.github.io/conduit-re-frisk-demo/)      [RealWorld](https://github.com/gothinkster/realworld) This codebase was created to demonstrate a fully fledged fullstack application built with [ClojureScript](https://clojurescript.org/) and [re-frame](https://github.com/Day8/re-frame), including CRUD operations, authentication, routing, pagination, and more. For more information on how this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. > #### Application structure/style heavily inspired by [todomvc](https://github.com/Day8/re-frame/tree/master/examples/todomvc) # Learn how to build similar project with [Learn re-frame](https://www.learnreframe.com/) ## Setup And Run #### Copy repository ```shell git clone https://github.com/jacekschae/conduit.git && cd conduit ``` #### Install dependencies ```shell yarn install || npm install ``` #### Run dev server ```shell yarn dev || npm run dev ``` #### Compile an optimized version ```shell yarn release || npm run release ``` ================================================ FILE: package.json ================================================ { "name": "conduit", "version": "0.0.1", "description": "ClojureScript and re-frame codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the RealWorld spec and API.", "repository": { "type": "git", "url": "https://github.com/jacekschae/conduit" }, "author": { "name": "Jacek Schae", "email": "jacek.schae@gmail.com" }, "license": "MIT", "scripts": { "dev": "shadow-cljs watch app", "release": "shadow-cljs release app", "server": "shadow-cljs server", "clean": "rm -rf target; rm -rf public/js", "clean-win": "rmdir /s /q public/js & rmdir /s /q target" }, "dependencies": { "react": "16.9.0", "react-dom": "16.9.0" }, "devDependencies": { "shadow-cljs": "^2.16.4" } } ================================================ FILE: public/index.html ================================================ Conduit
================================================ FILE: shadow-cljs.edn ================================================ {:source-paths ["src"] :dependencies [[reagent "0.9.1"] [re-frame "0.10.7"] [day8.re-frame/http-fx "v0.2.0"] [cljs-ajax "0.7.3"] [bidi "2.1.5"] [kibu/pushy "0.3.8"] [binaryage/devtools "0.9.10"]] :fs-watch {:hawk false} :nrepl {:port 3333} :builds {:app {:target :browser :output-dir "public/js" :asset-path "/js" :modules {:main {:init-fn conduit.core/init}} :compiler-options {:shadow-keywords true} :devtools {:http-root "public" :http-port 3000}}}} ================================================ FILE: src/conduit/core.cljs ================================================ (ns conduit.core (:require [re-frame.core :refer [dispatch-sync]] [reagent.core :as reagent] [conduit.router :as router] [conduit.events] ;; These three are only [conduit.subs] ;; required to make the compiler [conduit.views])) ;; load them ;; -- Entry Point ------------------------------------------------------------- ;; Within ../../resources/public/index.html you'll see this code: ;; window.onload = function() { conduit.core.main() } this is the entry ;; function that kicks off the app once the HTML is loaded. ;; (defn ^:export init [] ;; Router config can be found within `./router.cljs`. Here we are just hooking ;; up the router on start (router/start!) ;; Put an initial value into app-db. ;; The event handler for `:initialise-db` can be found in `events.cljs` ;; Using the sync version of dispatch means that value is in ;; place before we go onto the next step. (dispatch-sync [:initialise-db]) ;; Render the UI into the HTML's
element ;; The view function `conduit.views/conduit-app` is the ;; root view for the entire UI. (reagent/render [conduit.views/conduit-app] (.getElementById js/document "app"))) ================================================ FILE: src/conduit/db.cljs ================================================ (ns conduit.db (:require [cljs.reader] [re-frame.core :refer [reg-cofx]])) ;; -- Default app-db Value --------------------------------------------------- ;; ;; When the application first starts, this will be the value put in app-db ;; Look in: ;; 1. `core.cljs` for "(dispatch-sync [:initialise-db])" ;; 2. `events.cljs` for the registration of :initialise-db handler ;; (def default-db {:active-page :home}) ;; what gets put into app-db by default. ;; -- Local Storage ---------------------------------------------------------- ;; ;; Part of the conduit challenge is to store a user in localStorage, and ;; on app startup, reload the user from when the program was last run. ;; (def conduit-user-key "conduit-user") ;; localstore key (defn set-user-ls [user] (.setItem js/localStorage conduit-user-key (str user))) ;; sorted-map written as an EDN map ;; Removes user information from localStorage when a user logs out. ;; (defn remove-user-ls [] (.removeItem js/localStorage conduit-user-key)) ;; -- cofx Registrations ----------------------------------------------------- ;; ;; Use `reg-cofx` to register a "coeffect handler" which will inject the user ;; stored in localStorge. ;; ;; To see it used, look in `events.cljs` at the event handler for `:initialise-db`. ;; That event handler has the interceptor `(inject-cofx :local-store-user)` ;; The function registered below will be used to fulfill that request. ;; ;; We must supply a `sorted-map` but in localStorage it is stored as a `map`. ;; (reg-cofx :local-store-user (fn [cofx _] (assoc cofx :local-store-user ;; put the local-store user into the coeffect under :local-store-user (into (sorted-map) ;; read in user from localstore, and process into a sorted map (some->> (.getItem js/localStorage conduit-user-key) (cljs.reader/read-string)))))) ;; EDN map -> map ================================================ FILE: src/conduit/events.cljs ================================================ (ns conduit.events (:require [conduit.db :refer [default-db set-user-ls remove-user-ls]] [re-frame.core :refer [reg-event-db reg-event-fx reg-fx inject-cofx trim-v after path]] [conduit.router :as router] [day8.re-frame.http-fx] ;; registers the :http-xhrio effect handler with re-frame [ajax.core :refer [json-request-format json-response-format]] [clojure.string :as str] [cljs.reader :as rdr])) ;; -- Interceptors -------------------------------------------------------------- ;; Every event handler can be "wrapped" in a chain of interceptors. Each of these ;; interceptors can do things "before" and/or "after" the event handler is executed. ;; They are like the "middleware" of web servers, wrapping around the "handler". ;; Interceptors are a useful way of factoring out commonality (across event ;; handlers) and looking after cross-cutting concerns like logging or validation. ;; ;; They are also used to "inject" values into the `coeffects` parameter of ;; an event handler, when that handler needs access to certain resources. ;; ;; Each event handler can have its own chain of interceptors. Below we create ;; the interceptor chain shared by all event handlers which manipulate user. ;; A chain of interceptors is a vector. ;; Explanation of `trim-v` is given further below. ;; (def set-user-interceptor [(path :user) ;; `:user` path within `db`, rather than the full `db`. (after set-user-ls) ;; write user to localstore (after) trim-v]) ;; removes first (event id) element from the event vec ;; After logging out, clean up local-storage so that when a user refreshes ;; the browser she/he is not automatically logged-in, and because it's ;; good practice to clean-up after yourself. ;; (def remove-user-interceptor [(after remove-user-ls)]) ;; -- Helpers ----------------------------------------------------------------- ;; (def api-url "https://api.realworld.io/api") (defn endpoint "Concat any params to api-url separated by /" [& params] (str/join "/" (cons api-url params))) (defn auth-header "Get user token and format for API authorization" [db] (when-let [token (get-in db [:user :token])] [:Authorization (str "Token " token)])) (defn add-epoch "Add :epoch timestamp based on :createdAt field." [item] (assoc item :epoch (-> item :createdAt rdr/parse-timestamp .getTime))) (defn index-by "Index collection by function f (usually a keyword) as a map" [f coll] (into {} (map (fn [item] (let [item (add-epoch item)] [(f item) item]))) coll)) (reg-fx :set-url (fn [{:keys [url]}] (router/set-token! url))) ;; -- Event Handlers ---------------------------------------------------------- ;; (reg-event-fx ;; usage: (dispatch [:initialise-db]) :initialise-db ;; sets up initial application state ;; the interceptor chain (a vector of interceptors) [(inject-cofx :local-store-user)] ;; gets user from localstore, and puts into coeffects arg ;; the event handler (function) being registered (fn [{:keys [local-store-user]} _] ;; take 2 vals from coeffects. Ignore event vector itself. {:db (assoc default-db :user local-store-user)})) ;; what it returns becomes the new application state (reg-event-fx ;; usage: (dispatch [:set-active-page {:page :home}) :set-active-page ;; triggered when the user clicks on a link that redirects to another page (fn [{:keys [db]} [_ {:keys [page slug profile favorited]}]] ;; destructure 2nd parameter to obtain keys (let [set-page (assoc db :active-page page)] (case page ;; -- URL @ "/" -------------------------------------------------------- :home {:db set-page :dispatch-n [(if (empty? (:user db)) ;; dispatch more than one event. [:get-articles {:limit 10}] ;; When a user is NOT logged in, display all articles, [:get-feed-articles {:limit 10}]) ;; otherwise get her/his feed articles. [:get-tags]]} ;; we also can't forget to get tags ;; -- URL @ "/login" | "/register" | "/settings" ----------------------- (:login :register :settings) {:db set-page} ;; `case` can group multiple clauses that do the same thing. ;; ie., `(:login :register :settings) {:db set-page}` is the same as ;; ``` ;; :login {:db set-page} ;; :register {:db set-page} ;; :settings {:db set-page} ;; ``` ;; -- URL @ "/editor" -------------------------------------------------- :editor {:db set-page :dispatch (if slug ;; When we click article to edit we need [:set-active-article slug] ;; to set it active or if we want to write [:reset-active-article])} ;; a new article we reset ;; -- URL @ "/article/:slug" ------------------------------------------- :article {:db (assoc set-page :active-article slug) ;; :dispatch-n to dispatch multiple events :dispatch-n [[:get-articles {:limit 10}] [:get-article-comments {:slug slug}] [:get-user-profile {:profile (get-in db [:articles slug :author :username])}]]} ;; -- URL @ "/profile/:slug" ------------------------------------------- :profile {:db (assoc set-page :active-article slug) ;; :dispatch-n to dispatch multiple events :dispatch-n [[:get-user-profile {:profile profile}] [:get-articles {:author profile}]]} ;; -- URL @ "/profile/:slug/favorites" --------------------------------- :favorited {:db (assoc db :active-page :profile) ;; even though we are at :favorited, we still :dispatch [:get-articles {:favorited favorited}]})))) ;; display :profile with :favorited articles (reg-event-db ;; usage: (dispatch [:reset-active-article]) :reset-active-article ;; triggered when the user enters new-article i.e. editor without slug (fn [db _] ;; 1st parameter in -db events is db, 2nd parameter not important therefore _ (dissoc db :active-article))) ;; compute and return the new state (reg-event-fx ;; usage: (dispatch [:set-active-article slug]) :set-active-article (fn [{:keys [db]} [_ slug]] ;; 1st parameter in -fx events is no longer just db. It is a map which contains a :db key. {:db (assoc db :active-article slug) ;; The handler is returning a map which describes two side-effects: :dispatch-n [[:get-article-comments {:slug slug}] ;; change to app-state :db and future event in this case :dispatch-n [:get-user-profile {:profile (get-in db [:articles slug :author :username])}]]})) ;; -- GET Articles @ /api/articles -------------------------------------------- ;; (reg-event-fx ;; usage (dispatch [:get-articles {:limit 10 :tag "tag-name" ...}]) :get-articles ;; triggered every time user request articles with different params (fn [{:keys [db]} [_ params]] ;; params = {:limit 10 :tag "tag-name" ...} {:http-xhrio {:method :get :uri (endpoint "articles") ;; evaluates to "api/articles/" :params params ;; include params in the request :headers (auth-header db) ;; get and pass user token obtained during login :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:get-articles-success] ;; trigger :get-articles-success event :on-failure [:api-request-error {:request-type :get-articles ;; trigger :api-request-error event :loading :articles}]} :db (-> db (assoc-in [:loading :articles] true) (assoc-in [:filter :offset] (:offset params)) ;; base on passed params set a filter (assoc-in [:filter :tag] (:tag params)) ;; so that we can easily show and hide (assoc-in [:filter :author] (:author params)) ;; appropriate ui components (assoc-in [:filter :favorites] (:favorited params)) (assoc-in [:filter :feed] false))})) ;; we need to disable filter by feed every time since it's not supported query param (reg-event-db :get-articles-success (fn [db [_ {articles :articles, articles-count :articlesCount}]] (-> db (assoc-in [:loading :articles] false) ;; turn off loading flag for this event (assoc :articles-count articles-count ;; change app-state by adding articles-count :articles (index-by :slug articles))))) ;; and articles, which we index by slug ;; -- GET Article @ /api/articles/:slug --------------------------------------- ;; (reg-event-fx ;; usage (dispatch [:get-article {:slug "slug"}]) :get-article ;; triggered when a user upserts article, i.e., is redirected to article page after saving an article (fn [{:keys [db]} [_ params]] ;; params = {:slug "slug"} {:http-xhrio {:method :get :uri (endpoint "articles" (:slug params)) ;; evaluates to "api/articles/:slug" :headers (auth-header db) ;; get and pass user token obtained during login :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:get-article-success] ;; trigger :get-article-success event :on-failure [:api-request-error {:request-type :get-article ;; trigger :api-request-error with request type :get-article :loading :article}]} :db (assoc-in db [:loading :article] true)})) (reg-event-db :get-article-success (fn [db [_ {article :article}]] (-> db (assoc-in [:loading :article] false) (assoc :articles (index-by :slug [article]))))) ;; -- POST/PUT Article @ /api/articles(/:slug) -------------------------------- ;; (reg-event-fx ;; usage (dispatch [:upsert-article article]) :upsert-article ;; when we update or insert (upsert) we are sending the same shape of information (fn [{:keys [db]} [_ params]] ;; params = {:slug "article-slug" :article {:body "article body"} } {:db (assoc-in db [:loading :article] true) :http-xhrio {:method (if (:slug params) :put :post) ;; when we get a slug we'll update (:put) otherwise insert (:post) :uri (if (:slug params) ;; Same logic as above but we go with different endpoint - (endpoint "articles" (:slug params)) ;; one with :slug to update (endpoint "articles")) ;; and another to insert :headers (auth-header db) ;; get and pass user token obtained during login :params {:article (:article params)} :format (json-request-format) ;; make sure we are doing request format wiht json :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:upsert-article-success] ;; trigger :upsert-article-success event :on-failure [:api-request-error {:request-type :upsert-article ;; trigger :api-request-error event with request type :upsert-article :loading :article}]}})) (reg-event-fx :upsert-article-success (fn [{:keys [db]} [_ {article :article}]] {:db (-> db (assoc-in [:loading :article] false) (dissoc :comments) ;; clean up any comments that we might have in db (dissoc :errors) ;; clean up any errors that we might have in db (assoc :active-page :article :active-article (:slug article))) :dispatch-n [[:get-article {:slug (:slug article)}] ;; when the users clicks save we fetch the new version [:get-article-comments {:slug (:slug article)}]] ;; of the article and comments from the server :set-url {:url (str "/article/" (:slug article))}})) ;; -- DELETE Article @ /api/articles/:slug ------------------------------------ ;; (reg-event-fx ;; usage (dispatch [:delete-article slug]) :delete-article ;; triggered when a user deletes an article (fn [{:keys [db]} [_ slug]] ;; slug = {:slug "article-slug"} {:db (assoc-in db [:loading :article] true) :http-xhrio {:method :delete :uri (endpoint "articles" slug) ;; evaluates to "api/articles/:slug" :headers (auth-header db) ;; get and pass user token obtained during login :params slug ;; pass the article slug to delete :format (json-request-format) ;; make sure we are doing request format wiht json :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:delete-article-success] ;; trigger :delete-article-success event :on-failure [:api-request-error {:request-type :delete-article ;; trigger :api-request-error event with request type :delete-article :loading :article}]}})) (reg-event-fx :delete-article-success (fn [{:keys [db]} _] {:db (-> db (update :articles dissoc (:active-article db)) (assoc-in [:loading :article] false)) :dispatch [:set-active-page {:page :home}]})) ;; -- GET Feed Articles @ /api/articles/feed ---------------------------------- ;; (reg-event-fx ;; usage (dispatch [:get-feed-articles {:limit 10 :offset 0 ...}]) :get-feed-articles ;; triggered when Your Feed tab is loaded (fn [{:keys [db]} [_ params]] ;; params = {:offset 0 :limit 10} {:http-xhrio {:method :get :uri (endpoint "articles" "feed") ;; evaluates to "api/articles/feed" :params params ;; include params in the request :headers (auth-header db) ;; get and pass user token obtained during login :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:get-feed-articles-success] ;; trigger :get-feed-articles-success event :on-failure [:api-request-error {:request-type :get-feed-articles ;; trigger :api-request-error event with request type :get-feed-articles :loading :articles}]} :db (-> db (assoc-in [:loading :articles] true) (assoc-in [:filter :offset] (:offset params)) (assoc-in [:filter :tag] nil) ;; with feed-articles, we turn off almost all (assoc-in [:filter :author] nil) ;; filters to make sure everything on the (assoc-in [:filter :favorites] nil) ;; client is displayed correctly. (assoc-in [:filter :feed] true))})) ;; This is the only one we need (reg-event-db :get-feed-articles-success (fn [db [_ {articles :articles, articles-count :articlesCount}]] (-> db (assoc-in [:loading :articles] false) (assoc :articles-count articles-count :articles (index-by :slug articles))))) ;; -- GET Tags @ /api/tags ---------------------------------------------------- ;; (reg-event-fx ;; usage (dispatch [:get-tags]) :get-tags ;; triggered when the home page is loaded (fn [{:keys [db]} _] ;; second parameter is not important, therefore _ {:db (assoc-in db [:loading :tags] true) :http-xhrio {:method :get :uri (endpoint "tags") ;; evaluates to "api/tags" :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:get-tags-success] ;; trigger :get-tags-success event event :on-failure [:api-request-error {:request-type :get-tags ;; trigger :api-request-error event with request type :get-tags :loading :tags}]}})) (reg-event-db :get-tags-success (fn [db [_ {tags :tags}]] (-> db (assoc-in [:loading :tags] false) (assoc :tags tags)))) ;; -- GET Comments @ /api/articles/:slug/comments ----------------------------- ;; (reg-event-fx ;; usage (dispatch [:get-article-comments {:slug "article-slug"}]) :get-article-comments ;; triggered when the article page is loaded (fn [{:keys [db]} [_ params]] ;; params = {:slug "article-slug"} {:db (assoc-in db [:loading :comments] true) :http-xhrio {:method :get :uri (endpoint "articles" (:slug params) "comments") ;; evaluates to "api/articles/:slug/comments" :headers (auth-header db) ;; get and pass user token obtained during login :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:get-article-comments-success] ;; trigger :get-article-comments-success event :on-failure [:api-request-error {:request-type :get-article-comments ;; trigger :api-request-error event with request type :get-article-comments :loading :comments}]}})) (reg-event-db :get-article-comments-success (fn [db [_ {comments :comments}]] (-> db (assoc-in [:loading :comments] false) (assoc :comments (index-by :id comments))))) ;; index comments by id ;; -- POST Comments @ /api/articles/:slug/comments ---------------------------- ;; (reg-event-fx ;; usage (dispatch [:post-comment comment]) :post-comment ;; triggered when a user submits a comment (fn [{:keys [db]} [_ body]] ;; body = {:body "body" } {:db (assoc-in db [:loading :comments] true) :http-xhrio {:method :post :uri (endpoint "articles" (:active-article db) "comments") ;; evaluates to "api/articles/:slug/comments" :headers (auth-header db) ;; get and pass user token obtained during login :params {:comment body} :format (json-request-format) ;; make sure we are doing request format wiht json :response-format (json-response-format {:keywords? true}) ;; json response with keyword keys :on-success [:post-comment-success] ;; trigger :post-comment-success event :on-failure [:api-request-error {:request-type :post-comment ;; trigger :api-request-error event :loading :comment}]}})) (reg-event-fx :post-comment-success (fn [{:keys [db]} [_ comment]] {:db (-> db (assoc-in [:loading :comments] false) (assoc-in [:articles (:active-article db) :comments] comment) (update :errors dissoc :comments)) ;; clean up errors, if any :dispatch [:get-article-comments {:slug (:active-article db)}]})) ;; -- DELETE Comments @ /api/articles/:slug/comments/:comment-id ---------------------- ;; (reg-event-fx ;; usage (dispatch [:delete-comment comment-id]) :delete-comment ;; triggered when a user deletes an article (fn [{:keys [db]} [_ comment-id]] ;; comment-id = 1234 {:db (-> db (assoc-in [:loading :comments] true) (assoc :active-comment comment-id)) :http-xhrio {:method :delete :uri (endpoint "articles" (:active-article db) "comments" comment-id) ;; evaluates to "api/articles/:slug/comments/:comment-id" :headers (auth-header db) ;; get and pass user token obtained during login :format (json-request-format) ;; ensure json request format :response-format (json-response-format {:keywords? true}) ;; json response with keywords keys :on-success [:delete-comment-success] ;; trigger delete-comment-success :on-failure [:api-request-error {:request-type :delete-comment ;; trigger :api-request-error event :loading :comments}]}})) (reg-event-db :delete-comment-success (fn [db _] (-> db (update :comments dissoc (:active-comment db)) ;; we could do another fetch of comments (dissoc :active-comment) ;; but instead we just remove it from app-db, (assoc-in [:loading :comments] false)))) ;; which gives us much snappier ui. ;; -- GET Profile @ /api/profiles/:username ----------------------------------- ;; (reg-event-fx ;; usage (dispatch [:get-user-profile {:profile "profile"}]) :get-user-profile ;; triggered when the profile page is loaded (fn [{:keys [db]} [_ params]] ;; params = {:profile "profile"} {:db (assoc-in db [:loading :profile] true) :http-xhrio {:method :get :uri (endpoint "profiles" (:profile params)) ;; evaluates to "api/profiles/:profile" :headers (auth-header db) ;; get and pass user token obtained during login :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:get-user-profile-success] ;; trigger :get-user-profile-success event :on-failure [:api-request-error {:request-type :get-user-profile ;; trigger :api-request-error event :loading :profile}]}})) (reg-event-db :get-user-profile-success (fn [db [_ {profile :profile}]] (-> db (assoc-in [:loading :profile] false) (assoc :profile profile)))) ;; -- POST Login @ /api/users/login ------------------------------------------- ;; (reg-event-fx ;; usage (dispatch [:login user]) :login ;; triggered when a user submits login form (fn [{:keys [db]} [_ credentials]] ;; credentials = {:email ... :password ...} {:db (assoc-in db [:loading :login] true) :http-xhrio {:method :post :uri (endpoint "users" "login") ;; evaluates to "api/users/login" :params {:user credentials} ;; {:user {:email ... :password ...}} :format (json-request-format) ;; make sure it's json :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:login-success] ;; trigger :login-success event :on-failure [:api-request-error {:request-type :login}]}})) ;; trigger :api-request-error event (reg-event-fx :login-success ;; The standard set of interceptors, defined above, which we ;; use for all user-modifying event handlers. Looks after ;; writing user to localStorage. ;; NOTE: this chain includes `path` and `trim-v` set-user-interceptor ;; The event handler function. ;; The "path" interceptor in `set-user-interceptor` means 1st parameter is the ;; value at `:user` path within `db`, rather than the full `db`. ;; And, further, it means the event handler returns just the value to be ;; put into `:user` path, and not the entire `db`. ;; So, a path interceptor makes the event handler act more like clojure's `update-in` (fn [{user :db} [{props :user}]] {:db (-> (merge user props) (assoc-in [:loading :login] false)) :dispatch-n [[:get-feed-articles {:tag nil :author nil :offset 0 :limit 10}] [:set-active-page {:page :home}]]})) ;; -- POST Registration @ /api/users ------------------------------------------ ;; (reg-event-fx ;; usage (dispatch [:register-user registration]) :register-user ;; triggered when a user submits registration form (fn [{:keys [db]} [_ registration]] ;; registration = {:username ... :email ... :password ...} {:db (assoc-in db [:loading :register-user] true) :http-xhrio {:method :post :uri (endpoint "users") ;; evaluates to "api/users" :params {:user registration} ;; {:user {:username ... :email ... :password ...}} :format (json-request-format) ;; make sure it's json :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:register-user-success] ;; trigger :register-user-success event :on-failure [:api-request-error {:request-type :register-user}]}})) ;; trigger :api-request-error event (reg-event-fx :register-user-success ;; The standard set of interceptors, defined above, which we ;; use for all user-modifying event handlers. Looks after ;; writing user to LocalStore. ;; NOTE: this chain includes `path` and `trim-v` set-user-interceptor ;; The event handler function. ;; The "path" interceptor in `set-user-interceptor` means 1st parameter is the ;; value at `:user` path within `db`, rather than the full `db`. ;; And, further, it means the event handler returns just the value to be ;; put into `:user` path, and not the entire `db`. ;; So, a path interceptor makes the event handler act more like clojure's `update-in` (fn [{user :db} [{props :user}]] {:db (-> (merge user props) (assoc-in [:loading :register-user] false)) :dispatch [:set-active-page {:page :home}]})) ;; -- PUT Update User @ /api/user --------------------------------------------- ;; (reg-event-fx ;; usage (dispatch [:update-user user]) :update-user ;; triggered when a user updates settings (fn [{:keys [db]} [_ user]] ;; user = {:img ... :username ... :bio ... :email ... :password ...} {:db (assoc-in db [:loading :update-user] true) :http-xhrio {:method :put :uri (endpoint "user") ;; evaluates to "api/user" :params {:user user} ;; {:user {:img ... :username ... :bio ... :email ... :password ...}} :headers (auth-header db) ;; get and pass user token obtained during login :format (json-request-format) ;; make sure our request is json :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:update-user-success] ;; trigger :update-user-success event :on-failure [:api-request-error {:request-type :update-user}]}})) ;; trigger :api-request-error event (reg-event-fx :update-user-success ;; The standard set of interceptors, defined above, which we ;; use for all user-modifying event handlers. Looks after ;; writing user to LocalStore. ;; NOTE: this chain includes `path` and `trim-v` set-user-interceptor ;; The event handler function. ;; The "path" interceptor in `set-user-interceptor` means 1st parameter is the ;; value at `:user` path within `db`, rather than the full `db`. ;; And, further, it means the event handler returns just the value to be ;; put into `:user` path, and not the entire `db`. ;; So, a path interceptor makes the event handler act more like clojure's `update-in` (fn [{user :db} [{props :user}]] {:db (-> (merge user props) (assoc-in [:loading :update-user] false))})) ;; -- Toggle follow user @ /api/profiles/:username/follow --------------------- ;; (reg-event-fx ;; usage (dispatch [:toggle-follow-user username]) :toggle-follow-user ;; triggered when user clicks follow/unfollow button on profile page (fn [{:keys [db]} [_ username]] ;; username = :username {:db (assoc-in db [:loading :toggle-follow-user] true) :http-xhrio {:method (if (get-in db [:profile :following]) :delete :post) ;; check if we follow if yes DELETE, no POST :uri (endpoint "profiles" username "follow") ;; evaluates to "api/profiles/:username/follow" :headers (auth-header db) ;; get and pass user token obtained during login :format (json-request-format) ;; make sure it's json :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:toggle-follow-user-success] ;; trigger :toggle-follow-user-success event :on-failure [:api-request-error {:request-type :toggle-follow-user}]}})) ;; trigger :api-request-error event (reg-event-db ;; usage: (dispatch [:toggle-follow-user-success]) :toggle-follow-user-success (fn [db [_ {profile :profile}]] (-> db (assoc-in [:loading :toggle-follow-user] false) (assoc-in [:profile :following] (:following profile))))) ;; -- Toggle favorite article @ /api/articles/:slug/favorite ------------------ ;; (reg-event-fx ;; usage (dispatch [:toggle-favorite-article slug]) :toggle-favorite-article ;; triggered when user clicks favorite/unfavorite button on profile page (fn [{:keys [db]} [_ slug]] ;; slug = :slug {:db (assoc-in db [:loading :toggle-favorite-article] true) :http-xhrio {:method (if (get-in db [:articles slug :favorited]) :delete :post) ;; check if article is already favorite: yes DELETE, no POST :uri (endpoint "articles" slug "favorite") ;; evaluates to "api/articles/:slug/favorite" :headers (auth-header db) ;; get and pass user token obtained during login :format (json-request-format) ;; make sure it's json :response-format (json-response-format {:keywords? true}) ;; json response and all keys to keywords :on-success [:toggle-favorite-article-success] ;; trigger :toggle-favorite-article-success event :on-failure [:api-request-error :toggle-favorite-article]}})) ;; trigger :api-request-error event (reg-event-db ;; usage: (dispatch [:toggle-favorite-article-success]) :toggle-favorite-article-success (fn [db [_ {article :article}]] (let [slug (:slug article) favorited (:favorited article) favorites-count (:favoritesCount article)] (-> db (assoc-in [:loading :toggle-favorite-article] false) (assoc-in [:articles slug :favorited] favorited) (assoc-in [:articles slug :favoritesCount] (if favorited (inc favorites-count) (dec favorites-count))))))) ;; -- Logout ------------------------------------------------------------------ ;; (reg-event-fx ;; usage (dispatch [:logout]) :logout ;; This interceptor, defined above, makes sure ;; that we clean up localStorage after logging-out ;; the user. remove-user-interceptor ;; The event handler function removes the user from ;; app-state = :db and sets the url to "/". (fn [{:keys [db]} _] {:db (dissoc db :user) ;; remove user from db :dispatch [:set-active-page {:page :home}]})) ;; -- Request Handlers ----------------------------------------------------------- ;; (reg-event-db ;; usage (dispatch [:api-request-error {:request-type , :loading }]) ;; :loading is optional and defaults to the :request-type input. :api-request-error ;; triggered when we get request-error from the server (fn [db [_ {:keys [request-type loading]} response]] ;; `response` is implicitly conj'ed as the last entry by :http-xhrio event. (-> db ;; when we complete a request we need to clean so that our ui is nice and tidy (assoc-in [:errors request-type] (get-in response [:response :errors])) (assoc-in [:loading (or loading request-type)] false)))) ================================================ FILE: src/conduit/router.cljs ================================================ (ns conduit.router (:require [bidi.bidi :as bidi] [pushy.core :as pushy] [re-frame.core :refer [dispatch]])) ;; The routes setup is inspired by J. Pablo Fernández ;; source: https://pupeno.com/2015/08/26/no-hashes-bidirectional-routing-in-re-frame-with-bidi-and-pushy/ ;; -- Routes ------------------------------------------------------------------ ;; Define routes so that when we enter specific path the router knows what to ;; show us. A route is simply a data structure--a vector--with a pattern and ;; a result. (def routes ["/" {"" :home "login" :login "logout" :logout "register" :register "settings" :settings "editor/" {[:slug] :editor} "article/" {[:slug] :article} "profile/" {[:user-id] {"" :profile "/favorites" :favorited}}}]) ;; -- History ----------------------------------------------------------------- ;; we need to know the history of our routes so that we can navigate back and ;; forward. For that we'll use `pushy/pushy`, to which we need to provide a dispatch ;; function (what happens on dispatch) and match (what routes should we match). (def history (let [dispatch #(dispatch [:set-active-page {:page (:handler %) :slug (get-in % [:route-params :slug]) :profile (get-in % [:route-params :user-id]) :favorited (get-in % [:route-params :user-id])}]) match #(bidi/match-route routes %)] (pushy/pushy dispatch match))) ;; -- Router Start ------------------------------------------------------------ ;; (defn start! [] ;; pushy is here to take care of nice looking urls. Normally we would have to ;; deal with #. By using pushy we can have '/about' instead of '/#/about'. ;; pushy takes three arguments: ;; dispatch-fn - which dispatches when a match is found ;; match-fn - which checks if a route exist ;; identity-fn (optional) - extract the route from value returned by match-fn (pushy/start! history)) ;; -- url-for ----------------------------------------------------------------- ;; To dispatch routes in our UI (view) we will use url-for and then pass a ;; keyword to which route we want to direct the user. ;; usage: (url-for :home) (def url-for (partial bidi/path-for routes)) ;; -- set-token! -------------------------------------------------------------- ;; To change route after some actions we will need to set url and for that we ;; will use set-token!, taking the history and a token. (defn set-token! [token] (pushy/set-token! history token)) ================================================ FILE: src/conduit/subs.cljs ================================================ (ns conduit.subs (:require [re-frame.core :refer [reg-sub]])) (defn reverse-cmp ;; https://clojure.org/guides/comparators "Sort numbers in decreasing order, i.e.: calls compare with the arguments in the opposite order" [a b] (compare b a)) (reg-sub :active-page ;; usage: (subscribe [:active-page]) (fn [db _] ;; db is the (map) value stored in the app-db atom (:active-page db))) ;; extract a value from the application state (reg-sub :articles ;; usage: (subscribe [:articles]) (fn [db _] ;; db is the (map) value stored in the app-db atom (->> (:articles db) ;; ->> is a thread-last macro - pass articles as last arg of (vals) ;; vals, just as we would write (vals articles), then pass the result to (sort-by :epoch reverse-cmp)))) ;; sort-by epoch in reverse order (reg-sub :articles-count ;; usage: (subscribe [:articles-count]) (fn [db _] (:articles-count db))) (reg-sub :active-article ;; usage (subscribe [:active-article]) (fn [db _] (get-in db [:articles (:active-article db)]))) (reg-sub :tags ;; usage: (subscribe [:tags]) (fn [db _] (:tags db))) (reg-sub :comments ;; usage: (subscribe [:comments]) (fn [db _] (->> (:comments db) (vals) (sort-by :epoch reverse-cmp)))) (reg-sub :profile ;; usage: (subscribe [:profile]) (fn [db _] (:profile db))) (reg-sub :loading ;; usage: (subscribe [:loading]) (fn [db _] (:loading db))) (reg-sub :filter ;; usage: (subscribe [:filter]) (fn [db _] (:filter db))) (reg-sub :errors ;; usage: (subscribe [:errors]) (fn [db _] (:errors db))) (reg-sub :user ;; usage: (subscribe [:user]) (fn [db _] (:user db))) ================================================ FILE: src/conduit/views.cljs ================================================ (ns conduit.views (:require [reagent.core :as reagent] [conduit.router :refer [url-for]] [re-frame.core :refer [subscribe dispatch]] [clojure.string :as str :refer [trim split join]])) ;; -- Helpers ----------------------------------------------------------------- ;; (defn format-date [date] (.toDateString (js/Date. date))) (defn tags-list [tags-list] [:ul.tag-list (for [tag tags-list] [:li.tag-default.tag-pill.tag-outline {:key tag} tag])]) (defn article-meta [{:keys [author createdAt favoritesCount favorited slug] :or {slug "" author {:username ""}}}] (let [loading @(subscribe [:loading]) user @(subscribe [:user]) profile @(subscribe [:profile]) username (:username author)] [:div.article-meta [:a {:href (url-for :profile :user-id username)} [:img {:src (:image author) :alt "user image"}] " "] [:div.info [:a.author {:href (url-for :profile :user-id username)} username] [:span.date (format-date createdAt)]] (if (= (:username user) username) [:span [:a.btn.btn-sm.btn-outline-secondary {:href (url-for :editor :slug slug)} [:i.ion-edit] [:span " Edit Article "]] " " [:a.btn.btn-outline-danger.btn-sm {:href (url-for :home) :on-click #(dispatch [:delete-article slug])} [:i.ion-trash-a] [:span " Delete Article "]]] (when (seq user) [:span [:button.btn.btn-sm.action-btn.btn-outline-secondary {:on-click #(dispatch [:toggle-follow-user username]) :class (when (:toggle-follow-user loading) "disabled")} [:i {:class (if (:following profile) "ion-minus-round" "ion-plus-round")}] [:span (if (:following profile) (str " Unfollow " username) (str " Follow " username))]] " " [:button.btn.btn-sm.btn-primary {:on-click #(dispatch [:toggle-favorite-article slug]) :class (cond (not favorited) "btn-outline-primary" (:toggle-favorite-article loading) "disabled")} [:i.ion-heart] [:span (if favorited " Unfavorite Post " " Favorite Post ")] [:span.counter "(" favoritesCount ")"]]]))])) (defn articles-preview [{:keys [description slug createdAt title author favoritesCount favorited tagList] :or {slug "" author {:username ""}}}] (let [loading @(subscribe [:loading]) user @(subscribe [:user]) username (:username author)] [:div.article-preview [:div.article-meta [:a {:href (url-for :profile :user-id username)} [:img {:src (:image author) :alt "user image"}]] [:div.info [:a.author {:href (url-for :profile :user-id username)} username] [:span.date (format-date createdAt)]] (when (seq user) [:button.btn.btn-primary.btn-sm.pull-xs-right {:on-click #(dispatch [:toggle-favorite-article slug]) :class (cond (not favorited) "btn-outline-primary" (:toggle-favorite-article loading) "disabled")} [:i.ion-heart " "] [:span favoritesCount]])] [:a.preview-link {:href (url-for :article :slug slug)} [:h1 title] [:p description] [:span "Read more ..."] [tags-list tagList]]])) ;; defined in Helpers section (defn articles-list [articles loading-articles] [:div (if loading-articles [:div.article-preview [:p "Loading articles ..."]] (if (empty? articles) [:div.article-preview [:p "No articles are here... yet."]] (for [article articles] ^{:key (:slug article)} [articles-preview article])))]) (defn errors-list [errors] [:ul.error-messages (for [[k [v]] errors] ^{:key k} [:li (str (name k) " " v)])]) ;; -- Header ------------------------------------------------------------------ ;; (defn header [] (let [user @(subscribe [:user]) active-page @(subscribe [:active-page])] [:nav.navbar.navbar-light [:div.container [:a.navbar-brand {:href (url-for :home)} "conduit"] (if (empty? user) [:ul.nav.navbar-nav.pull-xs-right [:li.nav-item [:a.nav-link {:href (url-for :home) :class (when (= active-page :home) "active")} "Home"]] [:li.nav-item [:a.nav-link {:href (url-for :login) :class (when (= active-page :login) "active")} "Sign in"]] [:li.nav-item [:a.nav-link {:href (url-for :register) :class (when (= active-page :register) "active")} "Sign up"]]] [:ul.nav.navbar-nav.pull-xs-right [:li.nav-item [:a.nav-link {:href (url-for :home) :class (when (= active-page :home) "active")} "Home"]] [:li.nav-item [:a.nav-link {:href (url-for :editor :slug "new") :class (when (= active-page :editor) "active")} [:i.ion-compose "New Article"]]] [:li.nav-item [:a.nav-link {:href (url-for :settings) :class (when (= active-page :settings) "active")} [:i.ion-gear-a "Settings"]]] [:li.nav-item [:a.nav-link {:href (url-for :profile :user-id (:username user)) :class (when (= active-page :profile) "active")} (:username user) [:img.user-pic {:src (:image user) :alt "user image"}]]]])]])) ;; -- Footer ------------------------------------------------------------------ ;; (defn footer [] [:footer [:div.container [:a.logo-font {:href (url-for :home)} "conduit"] [:span.attribution "An interactive learning project from " [:a {:href "https://thinkster.io"} "Thinkster"] ". Code & design licensed under MIT."]]]) ;; -- Home -------------------------------------------------------------------- ;; (defn home [] (let [filter @(subscribe [:filter]) tags @(subscribe [:tags]) loading @(subscribe [:loading]) articles @(subscribe [:articles]) articles-count @(subscribe [:articles-count]) user @(subscribe [:user]) get-articles (fn [event params] (.preventDefault event) (dispatch [:get-articles params])) get-feed-articles (fn [event params] (.preventDefault event) (dispatch [:get-feed-articles params]))] [:div.home-page (when (empty? user) [:div.banner [:div.container [:h1.logo-font "conduit"] [:p "A place to share your knowledge."]]]) [:div.container.page [:div.row [:div.col-md-9 [:div.feed-toggle [:ul.nav.nav-pills.outline-active (when (seq user) [:li.nav-item [:a.nav-link {:href (url-for :home) :class (when (:feed filter) "active") :on-click #(get-feed-articles % {:offset 0 :limit 10})} "Your Feed"]]) [:li.nav-item [:a.nav-link {:href (url-for :home) :class (when-not (or (:tag filter) (:feed filter)) "active") :on-click #(get-articles % {:offset 0 :limit 10})} "Global Feed"]] ;; first argument: % is browser event, second: map of filter params (when (:tag filter) [:li.nav-item [:a.nav-link.active [:i.ion-pound] (str " " (:tag filter))]])]] [articles-list articles (:articles loading)] (when-not (or (:articles loading) (< articles-count 10)) [:ul.pagination (for [offset (range (/ articles-count 10))] ^{:key offset} [:li.page-item {:class (when (= (* offset 10) (:offset filter)) "active") :on-click #(get-articles % (if (:tag filter) {:offset (* offset 10) :tag (:tag filter) :limit 10} {:offset (* offset 10) :limit 10}))} [:a.page-link {:href (url-for :home)} (inc offset)]])])] [:div.col-md-3 [:div.sidebar [:p "Popular Tags"] (if (:tags loading) [:p "Loading tags ..."] [:div.tag-list (for [tag tags] ^{:key tag} [:a.tag-pill.tag-default {:href (url-for :home) :on-click #(get-articles % {:tag tag :limit 10 :offset 0})} tag])])]]]]])) ;; -- Login ------------------------------------------------------------------- ;; (defn login [] (let [default {:email "" :password ""} credentials (reagent/atom default)] (fn [] (let [{:keys [email password]} @credentials loading @(subscribe [:loading]) errors @(subscribe [:errors]) login-user (fn [event credentials] (.preventDefault event) (dispatch [:login credentials]))] [:div.auth-page [:div.container.page [:div.row [:div.col-md-6.offset-md-3.col-xs-12 [:h1.text-xs-center "Sign in"] [:p.text-xs-center [:a {:href (url-for :register)} "Need an account?"]] (when (:login errors) [errors-list (:login errors)]) [:form {:on-submit #(login-user % @credentials)} [:fieldset.form-group [:input.form-control.form-control-lg {:type "text" :placeholder "Email" :value email :on-change #(swap! credentials assoc :email (-> % .-target .-value)) :disabled (:login loading)}]] [:fieldset.form-group [:input.form-control.form-control-lg {:type "password" :placeholder "Password" :value password :on-change #(swap! credentials assoc :password (-> % .-target .-value)) :disabled (:login loading)}]] [:button.btn.btn-lg.btn-primary.pull-xs-right {:class (when (:login loading) "disabled")} "Sign in"]]]]]])))) ;; -- Register ---------------------------------------------------------------- ;; (defn register [] (let [default {:username "" :email "" :password ""} registration (reagent/atom default)] (fn [] (let [{:keys [username email password]} @registration loading @(subscribe [:loading]) errors @(subscribe [:errors]) register-user (fn [event registration] (.preventDefault event) (dispatch [:register-user registration]))] [:div.auth-page [:div.container.page [:div.row [:div.col-md-6.offset-md-3.col-xs-12 [:h1.text-xs-center "Sign up"] [:p.text-xs-center [:a {:href (url-for :login)} "Have an account?"]] (when (:register-user errors) [errors-list (:register-user errors)]) [:form {:on-submit #(register-user % @registration)} [:fieldset.form-group [:input.form-control.form-control-lg {:type "text" :placeholder "Your Name" :value username :on-change #(swap! registration assoc :username (-> % .-target .-value)) :disabled (:register-user loading)}]] [:fieldset.form-group [:input.form-control.form-control-lg {:type "text" :placeholder "Email" :value email :on-change #(swap! registration assoc :email (-> % .-target .-value)) :disabled (:register-user loading)}]] [:fieldset.form-group [:input.form-control.form-control-lg {:type "password" :placeholder "Password" :value password :on-change #(swap! registration assoc :password (-> % .-target .-value)) :disabled (:register-user loading)}]] [:button.btn.btn-lg.btn-primary.pull-xs-right {:class (when (:register-user loading) "disabled")} "Sign up"]]]]]])))) ;; -- Profile ----------------------------------------------------------------- ;; (defn profile [] (let [{:keys [image username bio following] :or {username ""}} @(subscribe [:profile]) {:keys [author favorites]} @(subscribe [:filter]) loading @(subscribe [:loading]) articles @(subscribe [:articles]) user @(subscribe [:user])] [:div.profile-page [:div.user-info [:div.container [:div.row [:div.col-xs-12.col-md-10.offset-md-1 [:img.user-img {:src image :alt "user image"}] [:h4 username] [:p bio] (if (= (:username user) username) [:a.btn.btn-sm.btn-outline-secondary.action-btn {:href (url-for :settings)} [:i.ion-gear-a] " Edit Profile Settings"] [:button.btn.btn-sm.action-btn.btn-outline-secondary {:on-click #(dispatch [:toggle-follow-user username]) :class (when (:toggle-follow-user loading) "disabled")} [:i {:class (if following "ion-minus-round" "ion-plus-round")}] [:span (if following (str " Unfollow " username) (str " Follow " username))]])]]]] [:div.container [:div.row [:div.col-xs-12.col-md-10.offset-md-1 [:div.articles-toggle [:ul.nav.nav-pills.outline-active [:li.nav-item [:a.nav-link {:href (url-for :profile :user-id username) :class (when author " active")} "My Articles"]] [:li.nav-item [:a.nav-link {:href (url-for :favorited :user-id username) :class (when favorites "active")} "Favorited Articles"]]]] [articles-list articles (:articles loading)]]]]])) ;; -- Settings ---------------------------------------------------------------- ;; (defn settings [] (let [{:keys [bio email image username] :as user} @(subscribe [:user]) default {:bio bio :email email :image image :username username} loading @(subscribe [:loading]) user-update (reagent/atom default) logout-user (fn [event] (.preventDefault event) (dispatch [:logout])) update-user (fn [event updated-user] (.preventDefault event) (dispatch [:update-user updated-user]))] [:div.settings-page [:div.container.page [:div.row [:div.col-md-6.offset-md-3.col-xs-12 [:h1.text-xs-center "Your Settings"] [:form [:fieldset [:fieldset.form-group [:input.form-control {:type "text" :placeholder "URL of profile picture" :default-value (:image user) :on-change #(swap! user-update assoc :image (-> % .-target .-value))}]] [:fieldset.form-group [:input.form-control.form-control-lg {:type "text" :placeholder "Your Name" :default-value (:username user) :on-change #(swap! user-update assoc :username (-> % .-target .-value)) :disabled (:update-user loading)}]] [:fieldset.form-group [:textarea.form-control.form-control-lg {:rows "8" :placeholder "Short bio about you" :default-value (:bio user) :on-change #(swap! user-update assoc :bio (-> % .-target .-value)) :disabled (:update-user loading)}]] [:fieldset.form-group [:input.form-control.form-control-lg {:type "text" :placeholder "Email" :default-value (:email user) :on-change #(swap! user-update assoc :email (-> % .-target .-value)) :disabled (:update-user loading)}]] [:fieldset.form-group [:input.form-control.form-control-lg {:type "password" :placeholder "Password" :default-value "" :on-change #(swap! user-update assoc :password (-> % .-target .-value)) :disabled (:update-user loading)}]] [:button.btn.btn-lg.btn-primary.pull-xs-right {:on-click #(update-user % @user-update) :class (when (:update-user loading) "disabled")} "Update Settings"]]] [:hr] [:button.btn.btn-outline-danger {:on-click #(logout-user %)} "Or click here to logout."]]]]])) ;; -- Editor ------------------------------------------------------------------ ;; (defn editor [] (let [{:keys [title description body tagList slug] :as active-article} @(subscribe [:active-article]) tagList (join " " tagList) default {:title title :description description :body body :tagList tagList} content (reagent/atom default) upsert-article (fn [event content slug] (.preventDefault event) (dispatch [:upsert-article {:slug slug :article {:title (trim (or (:title content) "")) :description (trim (or (:description content) "")) :body (trim (or (:body content) "")) :tagList (split (:tagList content) #" ")}}]))] (fn [] (let [errors @(subscribe [:errors])] [:div.editor-page [:div.container.page [:div.row [:div.col-md-10.offset-md-1.col-xs-12 (when (:upsert-article errors) [errors-list (:upsert-article errors)]) [:form [:fieldset [:fieldset.form-group [:input.form-control.form-control-lg {:type "text" :placeholder "Article Title" :default-value title :on-change #(swap! content assoc :title (-> % .-target .-value))}]] [:fieldset.form-group [:input.form-control {:type "text" :placeholder "What's this article about?" :default-value description :on-change #(swap! content assoc :description (-> % .-target .-value))}]] [:fieldset.form-group [:textarea.form-control {:rows "8" :placeholder "Write your article (in markdown)" :default-value body :on-change #(swap! content assoc :body (-> % .-target .-value))}]] [:fieldset.form-group [:input.form-control {:type "text" :placeholder "Enter tags" :default-value tagList :on-change #(swap! content assoc :tagList (-> % .-target .-value))}] [:div.tag-list]] [:button.btn.btn-lg.btn-primary.pull-xs-right {:on-click #(upsert-article % @content slug)} (if active-article "Update Article" "Publish Article")]]]]]]])))) ;; -- Article ----------------------------------------------------------------- ;; (defn article [] (let [default {:body ""} comment (reagent/atom default) post-comment (fn [event default] (.preventDefault event) (dispatch [:post-comment {:body (get @comment :body)}]) (reset! comment default))] (fn [] (let [active-article @(subscribe [:active-article]) user @(subscribe [:user]) comments @(subscribe [:comments]) errors @(subscribe [:errors]) loading @(subscribe [:loading])] [:div.article-page [:div.banner [:div.container [:h1 (:title active-article)] [article-meta active-article]]] ;; defined in Helpers section [:div.container.page [:div.row.article-content [:div.col-md-12 [:p (:body active-article)]]] [tags-list (:tagList active-article)] ;; defined in Helpers section [:hr] [:div.article-actions [article-meta active-article]] ;; defined in Helpers section [:div.row [:div.col-xs-12.col-md-8.offset-md-2 (when (:comments errors) [errors-list (:comments errors)]) ;; defined in Helpers section (if-not (empty? user) [:form.card.comment-form [:div.card-block [:textarea.form-control {:placeholder "Write a comment..." :rows "3" :value (:body @comment) :on-change #(swap! comment assoc :body (-> % .-target .-value))}]] [:div.card-footer [:img.comment-author-img {:src (:image user) :alt "user image"}] [:button.btn.btn-sm.btn-primary {:class (when (:comments loading) "disabled") :on-click #(post-comment % default)} "Post Comment"]]] [:p [:a {:href (url-for :register)} "Sign up"] " or " [:a {:href (url-for :login)} "Sign in"] " to add comments on this article."]) (if (:comments loading) [:div [:p "Loading comments ..."]] (if (empty? comments) [:div] (for [{:keys [id createdAt body author]} comments] ^{:key id} [:div.card [:div.card-block [:p.card-text body]] [:div.card-footer [:a.comment-author {:href (url-for :profile :user-id (:username author))} [:img.comment-author-img {:src (:image author) :alt "user image"}]] " " [:a.comment-author {:href (url-for :profile :user-id (:username author))} (:username author)] [:span.date-posted (format-date createdAt)] (when (= (:username user) (:username author)) [:span.mod-options {:on-click #(dispatch [:delete-comment id])} [:i.ion-trash-a]])]])))]]]])))) (defn pages [page-name] (case page-name :home [home] :login [login] :register [register] :profile [profile] :settings [settings] :editor [editor] :article [article] [home])) (defn conduit-app [] (let [active-page @(subscribe [:active-page])] [:div [header] [pages active-page] [footer]])) ================================================ FILE: test/core_test.cljs ================================================ (ns test.core-test (:require [cljs.test :refer-macros [deftest testing is]] [test.core :as core])) ;; Working on it ... ;; (deftest one-is-one (testing "if one equals one" (is (= 1 1)))) ================================================ FILE: test/runner.cljs ================================================ (ns test.runner (:require [doo.runner :refer-macros [doo-tests]] [test.core-test])) (doo-tests 'test.core-test)