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
+3
View File
@@ -1,3 +1,6 @@
target
.cpcache
.git
node_modules
.shadow-cljs
resources/public/js
+4 -1
View File
@@ -1,3 +1,6 @@
.cpcache
target
node_modules
.shadow-cljs
# Compiled UI (script/ui-build)
resources/public/js
+21
View File
@@ -1,3 +1,23 @@
# Stage 1: compile the re-frame UI (ui/) to JS. shadow-cljs needs Node and a JVM. It writes
# to ../resources/public/js (see ui/shadow-cljs.edn), i.e. /build/resources/public/js here.
# trixie, not bookworm: its default-jre is Java 21, which shadow-cljs's Closure Compiler
# requires (bookworm's is 17 -> UnsupportedClassVersionError).
FROM node:22-trixie-slim AS ui
RUN apt-get update \
&& apt-get install -y --no-install-recommends default-jre-headless \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build/ui
COPY ui/package.json ui/package-lock.json ./
RUN npm ci
# Resolve the ClojureScript deps in their own layer so source edits don't re-download them.
COPY ui/shadow-cljs.edn ./
RUN npx shadow-cljs classpath > /dev/null
COPY ui/src ./src
RUN npx shadow-cljs release app
# Stage 2: the server.
FROM clojure:openjdk-11-tools-deps
RUN sed -i \
@@ -17,6 +37,7 @@ RUN clojure -P -X:${DEPS_ALIASES} \
&& echo "[build] cached jars: $(find /root/.m2 -name '*.jar' | wc -l)"
COPY . .
COPY --from=ui /build/resources/public/js resources/public/js
EXPOSE 3000
+29 -6
View File
@@ -1,17 +1,19 @@
# tbd
A small Clojure HTTP API built on [Ring](https://github.com/ring-clojure/ring) (Jetty) and
[reitit](https://github.com/metosin/reitit), with Swagger UI docs, unit tests, dockerized
integration tests, and joint line coverage.
[reitit](https://github.com/metosin/reitit), with a ClojureScript
[re-frame](https://github.com/day8/re-frame) front end (`ui/`), Swagger UI docs, unit tests,
dockerized integration tests, and joint line coverage.
## Requirements
- Docker, for the integration tests and the `script/docker-*` tasks (all CI needs)
- For running tasks natively (optional): [Clojure CLI](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 11+, and [Babashka](https://babashka.org/) for `script/coverage-report`
- For running tasks natively (optional): [Clojure CLI](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 11+, [Node](https://nodejs.org/) 20+ (with a JDK) for the UI build, and [Babashka](https://babashka.org/) for `script/coverage-report`
## Running
```bash
script/ui-build # compile the UI (once, and after UI changes)
script/server [port]
```
@@ -23,7 +25,9 @@ script/server 8080
PORT=8080 script/server
```
This is a thin wrapper around `clojure -X:run`, which also honors `PORT`.
This is a thin wrapper around `clojure -X:run`, which also honors `PORT`. Then open
http://localhost:3000 for the UI. Without `script/ui-build` the server still starts, but `/`
shows a placeholder.
Or build and run the container:
@@ -36,7 +40,7 @@ docker run --rm -p 3000:3000 tbd
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/` | Plain-text landing message |
| `GET` | `/` | The UI: an HTML shell that loads the compiled ClojureScript from `/js/main.js` |
| `GET` | `/api/ping` | Health check, returns `pong` |
| `GET` | `/api/add?x=2&y=3` | Adds two numbers, returns EDN: `{:result 5}` (400 if either is missing or not a number) |
| `GET` | `/api-docs` | Swagger UI |
@@ -47,6 +51,23 @@ curl localhost:3000/api/ping # pong
curl 'localhost:3000/api/add?x=2&y=3' # {:result 5}
```
## UI
`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).
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 `/`.
| Task | What it does |
|------|--------------|
| `script/ui-build` | Optimized release build into `resources/public/js` |
| `script/ui-watch` | Rebuilds on every change, with hot reload; run `script/server` alongside and refresh the page |
The Docker image builds the UI in its own stage, so `docker build` needs no local Node.
## Development
Every task is a bash script in `script/`. Start an nREPL server (with CIDER middleware) on the port in `.nrepl-port`, or `3000` if that
@@ -88,9 +109,11 @@ src/tbd/
core.clj Router, Ring app, and server startup
<group>/routes.clj Each route group (health, math, ...) owns its route subtree
<group>/core.clj Business logic for the group, with no HTTP concerns
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,core}.cljs
resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored)
test/ Unit tests
integration-test/ Dockerized integration tests and coverage harness
script/ Bash entry points (test, coverage, repl, ...) and coverage helpers
script/ Bash entry points (server, ui-build, test, coverage, repl, ...) and coverage helpers
```
To add a new group, create `tbd.<group>.routes` with a `routes` fn that returns a reitit route
+11 -3
View File
@@ -52,10 +52,18 @@ bb integration-test/integration_test.bb http://localhost:3000 --watch --interval
| # | Test | Checks |
|---|------|--------|
| 1 | `GET /` returns 200 | HTTP status code |
| 2 | `Content-Type` header | `text/plain` |
| 3 | Response body | Contains expected Clojure/Ring content |
| 4 | Not HTML | Confirms plain text response |
| 2 | `Content-Type` header | `text/html` |
| 3 | Response body | The HTML shell: mount point and `/js/main.js` script tag |
| 4 | `GET /js/main.js` | 200, JavaScript, non-empty, contains the UI's `/api/add` call |
| 5 | `HEAD /` works | HTTP 200 |
| 6 | `GET /api/ping` | 200, body `pong` |
| 7 | `GET /swagger.json` | 200, JSON, references `/api/ping` |
| 8 | `GET /api-docs` | 200, `text/html` |
| 9 | `GET /api/add?x=2&y=3` | 200, EDN `{:result 5}` |
| 10 | `GET /api/add` with bad params | 400 for a missing or non-numeric param |
Test 4 needs the compiled UI, which the Docker image includes (`script/integration-test`
builds it). Against a locally running server, run `script/ui-build` first.
## Line Coverage
+12 -13
View File
@@ -68,23 +68,22 @@
(assert-eq "HTTP status" 200 (:status (get* base-url)))
(println)
(println "Test 2: Content-Type is text/plain")
(assert-contains "Content-Type" "text/plain" (get (:headers (get* base-url)) "content-type" ""))
(println "Test 2: Content-Type is text/html")
(assert-contains "Content-Type" "text/html" (get (:headers (get* base-url)) "content-type" ""))
(println)
(println "Test 3: Root response points at the API docs")
(println "Test 3: Root serves the HTML shell for the UI")
(let [body (:body (get* base-url))]
(assert-contains "Body mentions /api-docs" "/api-docs" body)
(assert-not-empty "Body is non-empty" body)
(println)
(assert-contains "Body has the mount point" "<div id=\"app\"" body)
(assert-contains "Body loads the compiled UI" "<script src=\"/js/main.js\"" body))
(println)
(println "Test 4: Response is plain text, not HTML")
(if (and (not (str/includes? body "<!DOCTYPE"))
(not (str/includes? body "<html")))
(do (println " ✓ Response is not HTML") (swap! pass inc))
(do (println " ✗ Response appears to be HTML")
(swap! fail inc)
(swap! errors conj "Response was HTML, expected plain text"))))
(println "Test 4: GET /js/main.js serves the compiled UI")
(let [resp (get* (str base-url "/js/main.js"))]
(assert-eq "HTTP status" 200 (:status resp))
(assert-contains "Content-Type" "javascript" (get (:headers resp) "content-type" ""))
(assert-not-empty "Body is non-empty" (:body resp))
(assert-contains "Bundle calls the add API" "/api/add" (:body resp)))
(println)
(println "Test 5: HEAD / returns 200")
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>tbd</title>
</head>
<body>
<!-- The re-frame app (ui/) mounts here and replaces this placeholder. -->
<div id="app">Loading… (if this never goes away, the UI hasn't been built: run script/ui-build)</div>
<script src="/js/main.js"></script>
</body>
</html>
+4
View File
@@ -10,4 +10,8 @@ if [[ ! $port =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
exit 2
fi
if [[ ! -f resources/public/js/main.js ]]; then
echo "note: the UI isn't built, so / will show a placeholder. Run script/ui-build first." >&2
fi
PORT=$port exec clojure -X:run
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Compile the re-frame UI (ui/) to resources/public/js, which the server serves at /js.
# Needs Node and a JDK. Run this before script/server; restart isn't required afterwards.
set -euo pipefail
cd "$(dirname "$0")/../ui"
[[ -d node_modules ]] || npm ci
exec npx shadow-cljs release app
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Rebuild the UI on every change (hot reload, also serves the shadow-cljs dashboard on :9630).
# The output lands in resources/public/js, so run script/server alongside and refresh the page.
set -euo pipefail
cd "$(dirname "$0")/../ui"
[[ -d node_modules ]] || npm ci
exec npx shadow-cljs watch app
+10 -4
View File
@@ -12,6 +12,7 @@
[reitit.ring.middleware.muuntaja :as muuntaja-middleware]
[reitit.ring.middleware.parameters :as parameters]
[muuntaja.core :as m]
[ring.util.response :as response]
[tbd.health.routes :as health]
[tbd.math.routes :as math]))
@@ -34,6 +35,12 @@
:body (pr-str {:error "invalid request parameters"
:details (:humanized (reitit.coercion/encode-error (ex-data e)))})})
(defn- index-handler
"The HTML shell for the re-frame UI (ui/); it loads the compiled JS from /js."
[_req]
(-> (response/resource-response "public/index.html")
(response/content-type "text/html; charset=utf-8")))
(def ^:private exception-middleware
(exception/create-exception-middleware
(assoc exception/default-handlers
@@ -48,10 +55,7 @@
:swagger {:info {:title "tbd API" :version "1.0.0"}}
:handler (swagger-handler)}}]
["/" {:get {:no-doc true
:handler (fn [_req]
{:status 200
:headers {"Content-Type" "text/plain; charset=utf-8"}
:body "tbd API — see /api-docs for documentation, /swagger.json for the spec"})}}]
:handler index-handler}}]
(health/routes)
(math/routes)]
{:data {:muuntaja m/instance
@@ -66,6 +70,8 @@
router
(ring/routes
(swagger-ui/create-swagger-ui-handler {:path "/api-docs"})
;; Compiled UI (script/ui-build writes resources/public/js).
(ring/create-resource-handler {:path "/js" :root "public/js"})
(ring/create-default-handler)))
wrap-head))
+11 -3
View File
@@ -12,13 +12,21 @@
(handler {:request-method method :uri uri :headers {}}))
(deftest root-test
(let [resp (request :get "/")]
(let [resp (request :get "/")
body (slurp (:body resp))]
(is (= 200 (:status resp)))
(is (= "text/plain; charset=utf-8" (get-in resp [:headers "Content-Type"])))
(is (re-find #"/api-docs" (:body resp))))
(is (= "text/html; charset=utf-8" (get-in resp [:headers "Content-Type"])))
(testing "serves the HTML shell that mounts the re-frame UI"
(is (re-find #"<div id=\"app\"" body))
(is (re-find #"<script src=\"/js/main\.js\"" body))))
(testing "HEAD is served like GET, without a body"
(is (= 200 (:status (request :head "/"))))))
(deftest js-assets-test
;; main.js only exists once script/ui-build has run, so only the miss is asserted here;
;; the integration suite (which runs against the built image) checks the hit.
(is (= 404 (:status (request :get "/js/nope.js")))))
(deftest swagger-spec-test
(let [resp (request :get "/swagger.json")]
(is (= 200 (:status resp)))
+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"]]])