From f8def7d7977de46d283cbab323dc2549e0bf0cec Mon Sep 17 00:00:00 2001 From: Michael Salihi Date: Fri, 21 Jan 2022 16:29:59 +0100 Subject: [PATCH] add single page app example with Babashka + htmx (#1148) [skip ci] --- examples/README.md | 6 + examples/htmx_todoapp.clj | 240 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 examples/htmx_todoapp.clj diff --git a/examples/README.md b/examples/README.md index 22fd6d97..0f851994 100644 --- a/examples/README.md +++ b/examples/README.md @@ -549,3 +549,9 @@ $ bb db_who.clj | fred@192.168.1.2 | workbench | | jane@192.168.1.3 | Toad for mySQL | ``` +## Single page application with Babashka + htmx + +Example of a todo list SPA using Babashka and htmx +See [htmx_todoapp.clj](htmx_todoapp.clj) + +Contributed by [@prestancedesign](https://github.com/prestancedesign). diff --git a/examples/htmx_todoapp.clj b/examples/htmx_todoapp.clj new file mode 100644 index 00000000..35c5004f --- /dev/null +++ b/examples/htmx_todoapp.clj @@ -0,0 +1,240 @@ +#!/usr/bin/env bb +;; Source: https://github.com/prestancedesign/babashka-htmx-todoapp + +(require '[org.httpkit.server :as srv] + '[clojure.java.browse :as browse] + '[clojure.core.match :refer [match]] + '[clojure.pprint :refer [cl-format]] + '[clojure.string :as str] + '[hiccup.core :as h]) + +(import '[java.net URLDecoder]) + +;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Config +;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def port 3000) + +;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Mimic DB (in-memory) +;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def todos (atom (sorted-map 1 {:id 1 :name "Taste htmx with Babashka" :done true} + 2 {:id 2 :name "Buy a unicorn" :done false}))) + +(def todos-id (atom (count @todos))) + +;;;;;;;;;;;;;;;;;;;;;;;;;; +;; "DB" queries +;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn add-todo! [name] + (let [id (swap! todos-id inc)] + (swap! todos assoc id {:id id :name name :done false}))) + +(defn toggle-todo! [id] + (swap! todos update-in [(Integer. id) :done] not)) + +(defn remove-todo! [id] + (swap! todos dissoc (Integer. id))) + +(defn filtered-todo [filter-name todos] + (case filter-name + "active" (remove #(:done (val %)) todos) + "completed" (filter #(:done (val %)) todos) + "all" todos + todos)) + +(defn get-items-left [] + (count (remove #(:done (val %)) @todos))) + +(defn todos-completed [] + (count (filter #(:done (val %)) @todos))) + +(defn remove-all-completed-todo [] + (reset! todos (into {} (remove #(:done (val %)) @todos)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Template and components +;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn todo-item [{:keys [id name done]}] + [:li {:id (str "todo-" id) + :class (when done "completed")} + [:div.view + [:input.toggle {:hx-patch (str "/todos/" id) + :type "checkbox" + :checked done + :hx-target (str "#todo-" id) + :hx-swap "outerHTML"}] + [:label {:hx-get (str "/todos/edit/" id) + :hx-target (str "#todo-" id) + :hx-swap "outerHTML"} name] + [:button.destroy {:hx-delete (str "/todos/" id) + :_ (str "on htmx:afterOnLoad remove #todo-" id)}]]]) + +(defn todo-list [todos] + (for [todo todos] + (todo-item (val todo)))) + +(defn todo-edit [id name] + [:form {:hx-post (str "/todos/update/" id)} + [:input.edit {:type "text" + :name "name" + :value name}]]) + +(defn item-count [] + (let [items-left (get-items-left)] + [:span#todo-count.todo-count {:hx-swap-oob "true"} + [:strong items-left] (cl-format nil " item~p " items-left) "left"])) + +(defn todo-filters [filter] + [:ul#filters.filters {:hx-swap-oob "true"} + [:li [:a {:hx-get "/?filter=all" + :hx-push-url "true" + :hx-target "#todo-list" + :class (when (= filter "all") "selected")} "All"]] + [:li [:a {:hx-get "/?filter=active" + :hx-push-url "true" + :hx-target "#todo-list" + :class (when (= filter "active") "selected")} "Active"]] + [:li [:a {:hx-get "/?filter=completed" + :hx-push-url "true" + :hx-target "#todo-list" + :class (when (= filter "completed") "selected")} "Completed"]]]) + +(defn clear-completed-button [] + [:button#clear-completed.clear-completed + {:hx-delete "/todos" + :hx-target "#todo-list" + :hx-swap-oob "true" + :hx-push-url "/" + :class (when-not (pos? (todos-completed)) "hidden")} + "Clear completed"]) + +(defn template [filter] + (str + "" + (h/html + [:head + [:meta {:charset "UTF-8"}] + [:title "Htmx + Babashka"] + [:link {:href "https://unpkg.com/todomvc-app-css@2.4.1/index.css" :rel "stylesheet"}] + [:script {:src "https://unpkg.com/htmx.org@1.5.0/dist/htmx.min.js" :defer true}] + [:script {:src "https://unpkg.com/hyperscript.org@0.8.1/dist/_hyperscript.min.js" :defer true}]] + [:body + [:section.todoapp + [:header.header + [:h1 "todos"] + [:form + {:hx-post "/todos" + :hx-target "#todo-list" + :hx-swap "beforeend" + :_ "on htmx:afterOnLoad set #txtTodo.value to ''"} + [:input#txtTodo.new-todo + {:name "todo" + :placeholder "What needs to be done?" + :autofocus ""}]]] + [:section.main + [:input#toggle-all.toggle-all {:type "checkbox"}] + [:label {:for "toggle-all"} "Mark all as complete"]] + [:ul#todo-list.todo-list + (todo-list (filtered-todo filter @todos))] + [:footer.footer + (item-count) + (todo-filters filter) + (clear-completed-button)]] + [:footer.info + [:p "Click to edit a todo"] + [:p "Created by " + [:a {:href "https://twitter.com/PrestanceDesign"} "Michaël Sλlihi"]] + [:p "Part of " + [:a {:href "http://todomvc.com"} "TodoMVC"]]]]))) + +;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Helpers +;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn parse-body [body] + (-> body + slurp + (str/split #"=") + second + URLDecoder/decode)) + +(defn parse-query-string [query-string] + (when query-string + (-> query-string + (str/split #"=") + second))) + +;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Handlers +;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn app-index [{:keys [query-string headers]}] + (let [filter (parse-query-string query-string) + ajax-request? (get headers "hx-request")] + (if (and filter ajax-request?) + (h/html (todo-list (filtered-todo filter @todos)) + (todo-filters filter)) + (template filter)))) + +(defn add-item [{body :body}] + (let [name (parse-body body) + todo (add-todo! name)] + (h/html (todo-item (val (last todo))) + (item-count)))) + +(defn edit-item [id] + (let [{:keys [id name]} (get @todos (Integer. id))] + (h/html (todo-edit id name)))) + +(defn update-item [{body :body} id] + (let [name (parse-body body) + todo (swap! todos assoc-in [(Integer. id) :name] name)] + (h/html (todo-item (get todo (Integer. id)))))) + +(defn patch-item [id] + (let [todo (toggle-todo! id)] + (h/html (todo-item (get todo (Integer. id))) + (item-count) + (clear-completed-button)))) + +(defn delete-item [id] + (remove-todo! id) + (h/html (item-count))) + +(defn clear-completed [] + (remove-all-completed-todo) + (h/html (todo-list @todos) + (item-count) + (clear-completed-button))) + +;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Routes +;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn routes [{:keys [request-method uri] :as req}] + (let [path (vec (rest (str/split uri #"/")))] + (match [request-method path] + [:get []] {:body (app-index req)} + [:get ["todos" "edit" id]] {:body (edit-item id)} + [:post ["todos"]] {:body (add-item req)} + [:post ["todos" "update" id]] {:body (update-item req id)} + [:patch ["todos" id]] {:body (patch-item id)} + [:delete ["todos" id]] {:body (delete-item id)} + [:delete ["todos"]] {:body (clear-completed)} + :else {:status 404 :body "Error 404: Page not found"}))) + +;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Server +;;;;;;;;;;;;;;;;;;;;;;;;;; + +(when (= *file* (System/getProperty "babashka.file")) + (let [url (str "http://localhost:" port "/")] + (srv/run-server #'routes {:port port}) + (println "serving" url) + (browse/browse-url url) + @(promise)))