diff --git a/.dockerignore b/.dockerignore index a0267a4..6af5f1c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,6 @@ target .cpcache .git +node_modules +.shadow-cljs +resources/public/js diff --git a/.gitignore b/.gitignore index 56b967f..2d42fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .cpcache target - +node_modules +.shadow-cljs +# Compiled UI (script/ui-build) +resources/public/js diff --git a/Dockerfile b/Dockerfile index 7152e7f..fa23f7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 424efba..eea086e 100644 --- a/README.md +++ b/README.md @@ -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 /routes.clj Each route group (health, math, ...) owns its route subtree /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..routes` with a `routes` fn that returns a reitit route diff --git a/integration-test/README.md b/integration-test/README.md index 9855395..f724294 100644 --- a/integration-test/README.md +++ b/integration-test/README.md @@ -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 diff --git a/integration-test/integration_test.bb b/integration-test/integration_test.bb index 8b6a498..701d0d3 100755 --- a/integration-test/integration_test.bb +++ b/integration-test/integration_test.bb @@ -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" "
+ + + + + tbd + + + +
Loading… (if this never goes away, the UI hasn't been built: run script/ui-build)
+ + + diff --git a/script/server b/script/server index 8b83ebc..416b0d2 100755 --- a/script/server +++ b/script/server @@ -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 diff --git a/script/ui-build b/script/ui-build new file mode 100755 index 0000000..d09a03a --- /dev/null +++ b/script/ui-build @@ -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 diff --git a/script/ui-watch b/script/ui-watch new file mode 100755 index 0000000..f7a3e90 --- /dev/null +++ b/script/ui-watch @@ -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 diff --git a/src/tbd/core.clj b/src/tbd/core.clj index ca4711c..944b577 100644 --- a/src/tbd/core.clj +++ b/src/tbd/core.clj @@ -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)) diff --git a/test/tbd/core_test.clj b/test/tbd/core_test.clj index 1ec5680..b4c8b7f 100644 --- a/test/tbd/core_test.clj +++ b/test/tbd/core_test.clj @@ -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 #"
=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 + } + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..992c91e --- /dev/null +++ b/ui/package.json @@ -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" + } +} diff --git a/ui/shadow-cljs.edn b/ui/shadow-cljs.edn new file mode 100644 index 0000000..68485e8 --- /dev/null +++ b/ui/shadow-cljs.edn @@ -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}}}} diff --git a/ui/src/tbd/ui/core.cljs b/ui/src/tbd/ui/core.cljs new file mode 100644 index 0000000..be996d5 --- /dev/null +++ b/ui/src/tbd/ui/core.cljs @@ -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)) diff --git a/ui/src/tbd/ui/db.cljs b/ui/src/tbd/ui/db.cljs new file mode 100644 index 0000000..feba76a --- /dev/null +++ b/ui/src/tbd/ui/db.cljs @@ -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}}}) diff --git a/ui/src/tbd/ui/events.cljs b/ui/src/tbd/ui/events.cljs new file mode 100644 index 0000000..9345e81 --- /dev/null +++ b/ui/src/tbd/ui/events.cljs @@ -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 }`. +(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)})))) diff --git a/ui/src/tbd/ui/subs.cljs b/ui/src/tbd/ui/subs.cljs new file mode 100644 index 0000000..20a0ca9 --- /dev/null +++ b/ui/src/tbd/ui/subs.cljs @@ -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]))) diff --git a/ui/src/tbd/ui/views.cljs b/ui/src/tbd/ui/views.cljs new file mode 100644 index 0000000..ce0f0a4 --- /dev/null +++ b/ui/src/tbd/ui/views.cljs @@ -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"]]])