diff --git a/README.md b/README.md index 1e35124..9a51ebc 100644 --- a/README.md +++ b/README.md @@ -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 `/`. diff --git a/ui/src/tbd/ui/db.cljs b/ui/src/tbd/ui/db.cljs index feba76a..7e00ce5 100644 --- a/ui/src/tbd/ui/db.cljs +++ b/ui/src/tbd/ui/db.cljs @@ -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}}) diff --git a/ui/src/tbd/ui/events.cljs b/ui/src/tbd/ui/events.cljs index 9345e81..0af3a60 100644 --- a/ui/src/tbd/ui/events.cljs +++ b/ui/src/tbd/ui/events.cljs @@ -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 }`. (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]}))) diff --git a/ui/src/tbd/ui/subs.cljs b/ui/src/tbd/ui/subs.cljs index 20a0ca9..ac8f757 100644 --- a/ui/src/tbd/ui/subs.cljs +++ b/ui/src/tbd/ui/subs.cljs @@ -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))) diff --git a/ui/src/tbd/ui/views.cljs b/ui/src/tbd/ui/views.cljs index ce0f0a4..9dc0f19 100644 --- a/ui/src/tbd/ui/views.cljs +++ b/ui/src/tbd/ui/views.cljs @@ -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])]]))