add todo ui
CI / Test + coverage (push) Successful in 30s

This commit is contained in:
2026-09-19 07:57:24 -09:00
parent 31e03d552b
commit 64d1a35da2
5 changed files with 201 additions and 11 deletions
+3 -1
View File
@@ -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
[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
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
directory at `/js`, and serves `resources/public/index.html`, the shell that mounts the app, at `/`.
+12 -1
View File
@@ -6,4 +6,15 @@
;; {:status :loading}
;; {:status :ok :x "2" :y "3" :sum 5}
;; {: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
View File
@@ -4,12 +4,15 @@
[re-frame.core :as rf]
[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>}`.
(rf/reg-fx
::fetch
(fn [{:keys [uri on-response]}]
(-> (js/fetch uri)
(fn [{:keys [uri method body on-response]}]
(-> (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 (.text resp)
#(rf/dispatch (conj on-response {:ok? (.-ok resp)
@@ -17,9 +20,11 @@
:body %})))))
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))))
(rf/reg-event-db
(rf/reg-event-fx
::initialize
(fn [_ _] db/default-db))
(fn [_ _]
{:db db/default-db
:dispatch [::load-user]}))
(rf/reg-event-db
::set-operand
@@ -51,3 +56,114 @@
(if ok?
{:status :ok :x x :y y :sum (:result (reader/read-string body))}
{: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]})))
+2
View File
@@ -3,3 +3,5 @@
(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 ::user (fn [db _] (:user db)))
(rf/reg-sub ::todos (fn [db _] (:todos db)))
+63 -4
View File
@@ -31,8 +31,67 @@
[:button {:type "submit" :disabled (= :loading (:status result))} "Add"]]
[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 []
[:main {:style {:font-family "system-ui, sans-serif" :max-width "40rem" :margin "2rem auto"}}
[:h1 "tbd"]
[calculator]
[:p [:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]]])
(let [user @(rf/subscribe [::subs/user])]
[:main {:style {:font-family "system-ui, sans-serif" :max-width "40rem" :margin "2rem auto"}}
[:h1 "tbd"]
[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])]]))