add ui
CI / Test + coverage (push) Successful in 1m15s

This commit is contained in:
2026-09-19 06:49:28 -09:00
parent 0e3ea5a714
commit a46428a757
20 changed files with 529 additions and 30 deletions
+238
View File
@@ -0,0 +1,238 @@
{
"name": "tbd-ui",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tbd-ui",
"version": "0.0.0",
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"shadow-cljs": "^3.5.3"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^19.3.0"
}
},
"node_modules/readline-sync": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz",
"integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/scheduler": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT"
},
"node_modules/shadow-cljs": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/shadow-cljs/-/shadow-cljs-3.5.3.tgz",
"integrity": "sha512-tDb/lTtHYTCP6VflT575u5oFqx242DgBu/pMqUF7ctA4VbA01X8pJE+9TAPY+D9pfQqWpqGrZLgmDWsmrabYVQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"buffer": "^6.0.3",
"process": "^0.11.10",
"readline-sync": "^1.4.10",
"shadow-cljs-jar": "1.3.4",
"source-map-support": "^0.5.21",
"which": "^5.0.0",
"ws": "^8.18.1"
},
"bin": {
"shadow-cljs": "cli/runner.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/shadow-cljs-jar": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/shadow-cljs-jar/-/shadow-cljs-jar-1.3.4.tgz",
"integrity": "sha512-cZB2pzVXBnhpJ6PQdsjO+j/MksR28mv4QD/hP/2y1fsIa9Z9RutYgh3N34FZ8Ktl4puAXaIGlct+gMCJ5BmwmA==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/which": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^3.1.1"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "tbd-ui",
"private": true,
"version": "0.0.0",
"description": "ClojureScript (re-frame) front end for the tbd server",
"scripts": {
"watch": "shadow-cljs watch app",
"release": "shadow-cljs release app"
},
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"shadow-cljs": "^3.5.3"
}
}
+14
View File
@@ -0,0 +1,14 @@
;; The UI compiles straight into the server's resources dir, so the server picks it up from
;; the classpath (see `tbd.core`, which serves `resources/public` at `/js`).
{:source-paths ["src"]
:dependencies
[[reagent "2.0.1"]
[re-frame "1.4.7"]]
:builds
{:app {:target :browser
:output-dir "../resources/public/js"
:asset-path "/js"
:modules {:main {:init-fn tbd.ui.core/init}}
:devtools {:after-load tbd.ui.core/reload}}}}
+21
View File
@@ -0,0 +1,21 @@
(ns tbd.ui.core
(:require [re-frame.core :as rf]
[reagent.dom.client :as rdc]
[tbd.ui.events :as events]
[tbd.ui.views :as views]))
(defonce root (atom nil))
(defn- render []
(rdc/render @root [views/app]))
(defn reload
"Called by shadow-cljs after each hot reload."
[]
(rf/clear-subscription-cache!)
(render))
(defn init []
(rf/dispatch-sync [::events/initialize])
(reset! root (rdc/create-root (js/document.getElementById "app")))
(render))
+9
View File
@@ -0,0 +1,9 @@
(ns tbd.ui.db)
(def default-db
{:calc {:x "" :y ""
;; {:status :idle}
;; {:status :loading}
;; {:status :ok :x "2" :y "3" :sum 5}
;; {:status :error :message "..."}
:result {:status :idle}}})
+53
View File
@@ -0,0 +1,53 @@
(ns tbd.ui.events
(:require [cljs.reader :as reader]
[clojure.string :as str]
[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})`.
;; Network failures come back as `{:ok? false :status nil :body <message>}`.
(rf/reg-fx
::fetch
(fn [{:keys [uri on-response]}]
(-> (js/fetch uri)
(.then (fn [resp]
(.then (.text resp)
#(rf/dispatch (conj on-response {:ok? (.-ok resp)
:status (.-status resp)
:body %})))))
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))))
(rf/reg-event-db
::initialize
(fn [_ _] db/default-db))
(rf/reg-event-db
::set-operand
(fn [db [_ k value]]
(assoc-in db [:calc k] value)))
(rf/reg-event-fx
::add
(fn [{:keys [db]} _]
(let [{:keys [x y]} (:calc db)]
{:db (assoc-in db [:calc :result] {:status :loading})
::fetch {:uri (str "/api/add?x=" (js/encodeURIComponent x)
"&y=" (js/encodeURIComponent y))
:on-response [::add-response x y]}})))
(defn- error-message
"The API answers bad input with EDN like {:error \"...\" :details {:x [...]}}."
[{:keys [status body]}]
(let [{:keys [error details]} (try (reader/read-string body) (catch :default _ nil))]
(cond
error (str error (when (seq details) (str " (" (str/join ", " (map name (keys details))) ")")))
status (str "HTTP " status)
:else body)))
(rf/reg-event-db
::add-response
(fn [db [_ x y {:keys [ok? body] :as resp}]]
(assoc-in db [:calc :result]
(if ok?
{:status :ok :x x :y y :sum (:result (reader/read-string body))}
{:status :error :message (error-message resp)}))))
+5
View File
@@ -0,0 +1,5 @@
(ns tbd.ui.subs
(:require [re-frame.core :as rf]))
(rf/reg-sub ::operand (fn [db [_ k]] (get-in db [:calc k])))
(rf/reg-sub ::result (fn [db _] (get-in db [:calc :result])))
+38
View File
@@ -0,0 +1,38 @@
(ns tbd.ui.views
(:require [re-frame.core :as rf]
[tbd.ui.events :as events]
[tbd.ui.subs :as subs]))
(defn- operand-input [k label]
[:label label " "
[:input {:type "number" :step "any" :required true :name (name k)
:style {:width "6rem"}
:value @(rf/subscribe [::subs/operand k])
:on-change #(rf/dispatch [::events/set-operand k (.. % -target -value)])}]])
(defn- result-line [{:keys [status x y sum message]}]
(case status
:idle nil
:loading [:p "Adding…"]
:ok [:p {:aria-live "polite"} x " + " y " = " [:strong sum]]
:error [:p {:role "alert" :style {:color "crimson"}} message]))
(defn calculator
"Sends GET /api/add?x=..&y=.. on submit and shows the sum the server returns."
[]
(let [result @(rf/subscribe [::subs/result])]
[:form {:on-submit (fn [e]
(.preventDefault e)
(rf/dispatch [::events/add]))}
[:div {:style {:display "flex" :gap "1rem" :align-items "center"}}
[operand-input :x "x"]
"+"
[operand-input :y "y"]
[:button {:type "submit" :disabled (= :loading (:status result))} "Add"]]
[result-line result]]))
(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"]]])