@@ -137,7 +137,9 @@ restarts), so switch it off when you're done.
|
|||||||
`ui/` is a [re-frame](https://github.com/day8/re-frame) app (ClojureScript, React 19 via
|
`ui/` is a [re-frame](https://github.com/day8/re-frame) app (ClojureScript, React 19 via
|
||||||
[Reagent](https://reagent-project.github.io/)) built with [shadow-cljs](https://github.com/thheller/shadow-cljs).
|
[Reagent](https://reagent-project.github.io/)) built with [shadow-cljs](https://github.com/thheller/shadow-cljs).
|
||||||
It's a small calculator form that calls `GET /api/add` and shows the sum (or the server's
|
It's a small calculator form that calls `GET /api/add` and shows the sum (or the server's
|
||||||
validation error).
|
validation error). Below it, once you're logged in, is a todo list (add, tick off, delete) backed by
|
||||||
|
`/api/todos`; it stays hidden when you're not. A "Log in" / "Log out" link sits in the footer row
|
||||||
|
next to the API docs links.
|
||||||
|
|
||||||
shadow-cljs compiles it into `resources/public/js/` (git-ignored). The server serves that
|
shadow-cljs compiles it into `resources/public/js/` (git-ignored). The server serves that
|
||||||
directory at `/js`, and serves `resources/public/index.html`, the shell that mounts the app, at `/`.
|
directory at `/js`, and serves `resources/public/index.html`, the shell that mounts the app, at `/`.
|
||||||
|
|||||||
+12
-1
@@ -6,4 +6,15 @@
|
|||||||
;; {:status :loading}
|
;; {:status :loading}
|
||||||
;; {:status :ok :x "2" :y "3" :sum 5}
|
;; {:status :ok :x "2" :y "3" :sum 5}
|
||||||
;; {:status :error :message "..."}
|
;; {:status :error :message "..."}
|
||||||
:result {:status :idle}}})
|
:result {:status :idle}}
|
||||||
|
;; {:status :unknown} (asking /auth/me)
|
||||||
|
;; {:status :anonymous} (not logged in)
|
||||||
|
;; {:status :logged-in :login "alice"}
|
||||||
|
;; {:status :error :message "..."}
|
||||||
|
:user {:status :unknown}
|
||||||
|
;; :status is the list load: :idle, :loading, :ok or :error. :error is the message of the
|
||||||
|
;; last failed request (a load or a change); it is cleared when the next request starts.
|
||||||
|
:todos {:status :idle
|
||||||
|
:items []
|
||||||
|
:new-title ""
|
||||||
|
:error nil}})
|
||||||
|
|||||||
+121
-5
@@ -4,12 +4,15 @@
|
|||||||
[re-frame.core :as rf]
|
[re-frame.core :as rf]
|
||||||
[tbd.ui.db :as db]))
|
[tbd.ui.db :as db]))
|
||||||
|
|
||||||
;; Effect: GET `uri`, then dispatch `(conj on-response {:ok? bool :status int-or-nil :body string})`.
|
;; Effect: request `uri` (GET unless `method` is given), then dispatch
|
||||||
|
;; `(conj on-response {:ok? bool :status int-or-nil :body string})`. A `body` is sent as EDN.
|
||||||
;; Network failures come back as `{:ok? false :status nil :body <message>}`.
|
;; Network failures come back as `{:ok? false :status nil :body <message>}`.
|
||||||
(rf/reg-fx
|
(rf/reg-fx
|
||||||
::fetch
|
::fetch
|
||||||
(fn [{:keys [uri on-response]}]
|
(fn [{:keys [uri method body on-response]}]
|
||||||
(-> (js/fetch uri)
|
(-> (js/fetch uri (clj->js (cond-> {:method (or method "GET")}
|
||||||
|
body (assoc :headers {"Content-Type" "application/edn"}
|
||||||
|
:body (pr-str body)))))
|
||||||
(.then (fn [resp]
|
(.then (fn [resp]
|
||||||
(.then (.text resp)
|
(.then (.text resp)
|
||||||
#(rf/dispatch (conj on-response {:ok? (.-ok resp)
|
#(rf/dispatch (conj on-response {:ok? (.-ok resp)
|
||||||
@@ -17,9 +20,11 @@
|
|||||||
:body %})))))
|
:body %})))))
|
||||||
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))))
|
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))))
|
||||||
|
|
||||||
(rf/reg-event-db
|
(rf/reg-event-fx
|
||||||
::initialize
|
::initialize
|
||||||
(fn [_ _] db/default-db))
|
(fn [_ _]
|
||||||
|
{:db db/default-db
|
||||||
|
:dispatch [::load-user]}))
|
||||||
|
|
||||||
(rf/reg-event-db
|
(rf/reg-event-db
|
||||||
::set-operand
|
::set-operand
|
||||||
@@ -51,3 +56,114 @@
|
|||||||
(if ok?
|
(if ok?
|
||||||
{:status :ok :x x :y y :sum (:result (reader/read-string body))}
|
{:status :ok :x x :y y :sum (:result (reader/read-string body))}
|
||||||
{:status :error :message (error-message resp)}))))
|
{:status :error :message (error-message resp)}))))
|
||||||
|
|
||||||
|
;; --- Login state ---
|
||||||
|
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::load-user
|
||||||
|
(fn [_ _]
|
||||||
|
{::fetch {:uri "/auth/me" :on-response [::user-response]}}))
|
||||||
|
|
||||||
|
;; 401 just means "not logged in"; anything else is a real failure.
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::user-response
|
||||||
|
(fn [{:keys [db]} [_ {:keys [ok? status] :as resp}]]
|
||||||
|
(cond
|
||||||
|
ok? {:db (assoc db :user {:status :logged-in
|
||||||
|
:login (:login (reader/read-string (:body resp)))})
|
||||||
|
:dispatch [::load-todos]}
|
||||||
|
(= 401 status) {:db (assoc db :user {:status :anonymous})}
|
||||||
|
:else {:db (assoc db :user {:status :error :message (error-message resp)})})))
|
||||||
|
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::logout
|
||||||
|
(fn [_ _]
|
||||||
|
{::fetch {:uri "/auth/logout" :method "POST" :on-response [::logout-response]}}))
|
||||||
|
|
||||||
|
(rf/reg-event-db
|
||||||
|
::logout-response
|
||||||
|
(fn [db [_ {:keys [ok?] :as resp}]]
|
||||||
|
(if ok?
|
||||||
|
(assoc db :user {:status :anonymous} :todos (:todos db/default-db))
|
||||||
|
(assoc-in db [:todos :error] (error-message resp)))))
|
||||||
|
|
||||||
|
;; --- Todos ---
|
||||||
|
|
||||||
|
(defn- start-request [db]
|
||||||
|
(assoc-in db [:todos :error] nil))
|
||||||
|
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::load-todos
|
||||||
|
(fn [{:keys [db]} _]
|
||||||
|
{:db (-> db start-request (assoc-in [:todos :status] :loading))
|
||||||
|
::fetch {:uri "/api/todos" :on-response [::todos-response]}}))
|
||||||
|
|
||||||
|
(rf/reg-event-db
|
||||||
|
::todos-response
|
||||||
|
(fn [db [_ {:keys [ok? body] :as resp}]]
|
||||||
|
(if ok?
|
||||||
|
(update db :todos assoc :status :ok :items (:todos (reader/read-string body)))
|
||||||
|
(update db :todos assoc :status :error :error (error-message resp)))))
|
||||||
|
|
||||||
|
;; A failed change keeps the list as it is and shows the message. A 401 means the session
|
||||||
|
;; expired, so fall back to the logged-out view (which hides the todos).
|
||||||
|
(rf/reg-event-db
|
||||||
|
::todo-failed
|
||||||
|
(fn [db [_ {:keys [status] :as resp}]]
|
||||||
|
(if (= 401 status)
|
||||||
|
(assoc db :user {:status :anonymous} :todos (:todos db/default-db))
|
||||||
|
(assoc-in db [:todos :error] (error-message resp)))))
|
||||||
|
|
||||||
|
(rf/reg-event-db
|
||||||
|
::set-new-title
|
||||||
|
(fn [db [_ title]]
|
||||||
|
(assoc-in db [:todos :new-title] title)))
|
||||||
|
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::add-todo
|
||||||
|
(fn [{:keys [db]} _]
|
||||||
|
{:db (start-request db)
|
||||||
|
::fetch {:uri "/api/todos" :method "POST"
|
||||||
|
:body {:title (get-in db [:todos :new-title])}
|
||||||
|
:on-response [::todo-added]}}))
|
||||||
|
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::todo-added
|
||||||
|
(fn [{:keys [db]} [_ {:keys [ok? body] :as resp}]]
|
||||||
|
(if ok?
|
||||||
|
{:db (update db :todos (fn [todos]
|
||||||
|
(-> todos
|
||||||
|
(update :items conj (reader/read-string body))
|
||||||
|
(assoc :new-title ""))))}
|
||||||
|
{:dispatch [::todo-failed resp]})))
|
||||||
|
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::set-done
|
||||||
|
(fn [{:keys [db]} [_ id done?]]
|
||||||
|
{:db (start-request db)
|
||||||
|
::fetch {:uri (str "/api/todos/" id) :method "PATCH"
|
||||||
|
:body {:done done?}
|
||||||
|
:on-response [::todo-changed]}}))
|
||||||
|
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::todo-changed
|
||||||
|
(fn [{:keys [db]} [_ {:keys [ok? body] :as resp}]]
|
||||||
|
(if ok?
|
||||||
|
(let [changed (reader/read-string body)]
|
||||||
|
{:db (update-in db [:todos :items]
|
||||||
|
(fn [items] (mapv #(if (= (:id %) (:id changed)) changed %) items)))})
|
||||||
|
{:dispatch [::todo-failed resp]})))
|
||||||
|
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::delete-todo
|
||||||
|
(fn [{:keys [db]} [_ id]]
|
||||||
|
{:db (start-request db)
|
||||||
|
::fetch {:uri (str "/api/todos/" id) :method "DELETE"
|
||||||
|
:on-response [::todo-deleted id]}}))
|
||||||
|
|
||||||
|
(rf/reg-event-fx
|
||||||
|
::todo-deleted
|
||||||
|
(fn [{:keys [db]} [_ id {:keys [ok?] :as resp}]]
|
||||||
|
(if ok?
|
||||||
|
{:db (update-in db [:todos :items] (fn [items] (filterv #(not= id (:id %)) items)))}
|
||||||
|
{:dispatch [::todo-failed resp]})))
|
||||||
|
|||||||
@@ -3,3 +3,5 @@
|
|||||||
|
|
||||||
(rf/reg-sub ::operand (fn [db [_ k]] (get-in db [:calc k])))
|
(rf/reg-sub ::operand (fn [db [_ k]] (get-in db [:calc k])))
|
||||||
(rf/reg-sub ::result (fn [db _] (get-in db [:calc :result])))
|
(rf/reg-sub ::result (fn [db _] (get-in db [:calc :result])))
|
||||||
|
(rf/reg-sub ::user (fn [db _] (:user db)))
|
||||||
|
(rf/reg-sub ::todos (fn [db _] (:todos db)))
|
||||||
|
|||||||
@@ -31,8 +31,67 @@
|
|||||||
[:button {:type "submit" :disabled (= :loading (:status result))} "Add"]]
|
[:button {:type "submit" :disabled (= :loading (:status result))} "Add"]]
|
||||||
[result-line result]]))
|
[result-line result]]))
|
||||||
|
|
||||||
|
(defn- todo-item [{:keys [id title done]}]
|
||||||
|
[:li {:style {:display "flex" :gap "0.5rem" :align-items "center" :margin "0.25rem 0"}}
|
||||||
|
[:label {:style {:flex 1 :text-decoration (when done "line-through") :opacity (when done 0.6)}}
|
||||||
|
[:input {:type "checkbox" :checked (boolean done)
|
||||||
|
:on-change #(rf/dispatch [::events/set-done id (.. % -target -checked)])}]
|
||||||
|
" " title]
|
||||||
|
[:button {:type "button" :aria-label (str "Delete " title)
|
||||||
|
:on-click #(rf/dispatch [::events/delete-todo id])}
|
||||||
|
"Delete"]])
|
||||||
|
|
||||||
|
(defn- todo-list [{:keys [status items]}]
|
||||||
|
(cond
|
||||||
|
(and (= :loading status) (empty? items)) [:p "Loading…"]
|
||||||
|
(and (= :ok status) (empty? items)) [:p "Nothing to do."]
|
||||||
|
:else [:ul {:style {:list-style "none" :padding 0}}
|
||||||
|
(for [todo items]
|
||||||
|
^{:key (:id todo)} [todo-item todo])]))
|
||||||
|
|
||||||
|
(defn todos
|
||||||
|
"Your todos (GET/POST /api/todos, PATCH/DELETE /api/todos/:id). Only shown when logged in."
|
||||||
|
[]
|
||||||
|
(let [{:keys [new-title error] :as state} @(rf/subscribe [::subs/todos])]
|
||||||
|
[:section
|
||||||
|
[:h2 "Todos"]
|
||||||
|
[:form {:on-submit (fn [e]
|
||||||
|
(.preventDefault e)
|
||||||
|
(rf/dispatch [::events/add-todo]))}
|
||||||
|
[:input {:type "text" :name "title" :required true :placeholder "What needs doing?"
|
||||||
|
:aria-label "New todo"
|
||||||
|
:style {:width "20rem"}
|
||||||
|
:value new-title
|
||||||
|
:on-change #(rf/dispatch [::events/set-new-title (.. % -target -value)])}]
|
||||||
|
" "
|
||||||
|
[:button {:type "submit"} "Add"]]
|
||||||
|
(when error [:p {:role "alert" :style {:color "crimson"}} error])
|
||||||
|
[todo-list state]]))
|
||||||
|
|
||||||
|
(defn- link-button [props label]
|
||||||
|
[:button (merge {:type "button"
|
||||||
|
:style {:background "none" :border 0 :padding 0 :font "inherit"
|
||||||
|
:color "LinkText" :text-decoration "underline" :cursor "pointer"}}
|
||||||
|
props)
|
||||||
|
label])
|
||||||
|
|
||||||
|
(defn- auth-link
|
||||||
|
"Log in (a plain navigation to the OAuth flow) or log out (a POST), or nothing until we know."
|
||||||
|
[{:keys [status login]}]
|
||||||
|
(case status
|
||||||
|
:anonymous [:a {:href "/auth/login"} "Log in"]
|
||||||
|
:logged-in [link-button {:title (str "Signed in as " login)
|
||||||
|
:on-click #(rf/dispatch [::events/logout])}
|
||||||
|
"Log out"]
|
||||||
|
nil))
|
||||||
|
|
||||||
(defn app []
|
(defn app []
|
||||||
[:main {:style {:font-family "system-ui, sans-serif" :max-width "40rem" :margin "2rem auto"}}
|
(let [user @(rf/subscribe [::subs/user])]
|
||||||
[:h1 "tbd"]
|
[:main {:style {:font-family "system-ui, sans-serif" :max-width "40rem" :margin "2rem auto"}}
|
||||||
[calculator]
|
[:h1 "tbd"]
|
||||||
[:p [:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]]])
|
[calculator]
|
||||||
|
(when (= :logged-in (:status user))
|
||||||
|
[todos])
|
||||||
|
[:p [:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]
|
||||||
|
(when-let [link (auth-link user)]
|
||||||
|
[:<> " · " link])]]))
|
||||||
|
|||||||
Reference in New Issue
Block a user