From 30808ea4b66c4276e6dcb089d54b41aeda407dff Mon Sep 17 00:00:00 2001 From: Aldo Solorzano Date: Wed, 5 Mar 2025 15:41:14 +0100 Subject: [PATCH 1/5] draft todo-app part-3 --- todo-app/part-3/todo-app/README.md | 764 +++++++++++++++++++ todo-app/part-3/todo-app/assets/new-list.png | Bin 0 -> 29369 bytes todo-app/part-3/todo-app/deps.edn | 5 + todo-app/part-3/todo-app/src/server.clj | 76 ++ todo-app/part-3/todo-app/src/todo_db.clj | 61 ++ 5 files changed, 906 insertions(+) create mode 100644 todo-app/part-3/todo-app/README.md create mode 100644 todo-app/part-3/todo-app/assets/new-list.png create mode 100644 todo-app/part-3/todo-app/deps.edn create mode 100644 todo-app/part-3/todo-app/src/server.clj create mode 100644 todo-app/part-3/todo-app/src/todo_db.clj diff --git a/todo-app/part-3/todo-app/README.md b/todo-app/part-3/todo-app/README.md new file mode 100644 index 0000000..d70ae36 --- /dev/null +++ b/todo-app/part-3/todo-app/README.md @@ -0,0 +1,764 @@ +# Building a TODO List App with Clojure + Datomic Pro - [Part 3] + +In [Part 2](../../part-2/todo-app/README.md) we completed the following: + +- Serve a website with Pedestal and Hiccup +- Create a query to load the List and Items + +Part 3 is about CRUD, you will explore the t/transact API to create, update and delete entities (List, Items). These are the actions that we want to support. + +- Create list +- Delete list +- Add item to list +- Delete item +- Update item status + +### Building the app + + +#### Create and Delete Lists + +To start with, run the REPL in your editor or in the terminal. + +```shell +clj +``` + +```shell +;; REPL +user> +``` + +As we are using native HTML we will user `forms` to make requests to the server. The form does a POST to `/lists` and it will include `new-list` argument with the name of the list + +```clojure +(def new-list-form + [:form {:action "/lists" :method "POST" :class "row row-cols-md-auto form-inline"} + [:div {:class "col"} [:input {:type "text" :name "new-list" :class "form-control"}]] + [:div {:class "col"} [:input {:type "submit" :value "new list" :class "btn btn-secondary btn-s"}]]]) +``` + +The form does a POST to `/lists` and it will include `new-list` argument with the name of the list. Add the function to `src/server.clj` + +```clojure +(ns server + (:require + [datomic.api :as d] ;; new + [hiccup.page :as hp] + [hiccup2.core :as h] + [io.pedestal.http :as http] + [io.pedestal.http.route :as route] + [todo-db :as todo-db])) ;; new + +(defn gen-page-head + "Include bootstrap css" + [title] + [:head + [:title title] + (hp/include-css "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css")]) + +(def new-list-form ;; new + [:form {:action "/lists" :method "POST" :class "row row-cols-md-auto form-inline"} + [:div {:class "col"} [:input {:type "text" :name "new-list" :class "form-control"}]] + [:div {:class "col"} [:input {:type "submit" :value "new list" :class "btn btn-secondary btn-s"}]]]) + +(defn all-lists-page + "Renders the TODO list page" + [_request] + (str + (h/html + (gen-page-head "TODO App with Clojure + Datomic") + [:div {:class "container"} + [:div {:class "bg-light rounded-3" :style "padding: 20px"} + [:h1 {:style "color: #1cb14a"} "Lists"] + new-list-form] ;; new + [:div {:class "row row-cols-2"} + (for [list (todo-db/lists-page (d/db todo-db/conn)) ;; Important line + :let [list-name (:list/name list)]] + [:div {:class "col card"} + [:div {:class "card-body"} + [:h4 {:class "card-title"} list-name] + [:table {:class "table mb-4"} + [:thead + [:tr + [:th {:scope "col"} "Item"] + [:th {:scope "col"} "Status"]]] + [:tbody + (for [item (:list/items list) + :let [item-text (get item :item/text) + item-status (get-in item [:item/status :db/ident])]] ;; new + [:tr + [:td item-text] + [:td + [:span {:class "badge text-bg-light"} item-status]]])]]]])]]))) + +(defn html-200 [body] + {:status 200 + :headers {"Content-Type" "text/html; charset=utf-8"} + :body body}) + +(def routes + (route/expand-routes + #{["/" :get (comp html-200 all-lists-page) :route-name :home]})) + +(defn create-server [] + (http/create-server + {::http/routes routes + ::http/secure-headers {:content-security-policy-settings "object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:;"} + ::http/type :jetty + ::http/port 8890 + ::http/join? false})) + +(defonce server (atom nil)) + +(defn start-server [] + (reset! server (-> (create-server) http/start))) + +(defn stop-server [] + (swap! server http/stop)) + +(defn restart-server [] + (stop-server) + (start-server)) +``` + +Load the file to the REPL and `(start-server)` + +```clojure +;; Result +(start-server) +``` + +navigate to http://localhost:8890/ in the browser, you should see the new list text box area. + +![](assets/new-list.png) + +Receive the request in Pedestal routes and create a new list with the name received by the form. Add `[io.pedestal.http.body-params :as body-params]`in the `:require`, that namespace contains a function to parse `form-params` and it's used inside the routes. + +```clojure +(def routes + (route/expand-routes + #{["/" :get (comp html-200 all-lists-page) :route-name :home] + ["/lists" :post [(body-params/body-params) new-list] :route-name :new-list]})) ;; new +``` + +*learn more about [Pedestal interceptors](https://pedestal.io/pedestal/0.7/guides/defining-routes.html#_interceptors)* + +Interceptors are functions that can be composed, they receive the request context and the last function is a handler that will return the final response. In this case it's `new-list` , let's create it. + +```clojure +(def html-302-response + {:status 302 + :headers {"Content-Type" "text/html; charset=utf-8" "Location" "/"} + :body ""}) + +(defn new-list [{:keys [form-params] :as _request}] + (let [list-name (:new-list form-params)] + (d/transact todo-db/conn [(todo-db/new-list (d/db todo-db/conn) list-name)]) + html-302-response)) +``` + +the function receives the the context from the Pedestal request, it access to the form-params and reads the `:new-list` key that contains the name inputted in the UI. Then it proceeds to transact to Datomic. The 302 status is returned because we want to comeback to the initial page and not to the path where the POST was made. + +Add the function to `src/server.clj` + +```clojure +(ns server + (:require + [datomic.api :as d] + [hiccup.page :as hp] + [hiccup2.core :as h] + [io.pedestal.http.body-params :as body-params] ;; new + [io.pedestal.http :as http] + [io.pedestal.http.route :as route] + [todo-db :as todo-db])) + +(defn gen-page-head + "Include bootstrap css" + [title] + [:head + [:title title] + (hp/include-css "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css")]) + +(def new-list-form + [:form {:action "/lists" :method "POST" :class "row row-cols-md-auto form-inline"} + [:div {:class "col"} [:input {:type "text" :name "new-list" :class "form-control"}]] + [:div {:class "col"} [:input {:type "submit" :value "new list" :class "btn btn-secondary btn-s"}]]]) + +(defn all-lists-page + "Renders the TODO list page" + [_request] + (str + (h/html + (gen-page-head "TODO App with Clojure + Datomic") + [:div {:class "container"} + [:div {:class "bg-light rounded-3" :style "padding: 20px"} + [:h1 {:style "color: #1cb14a"} "Lists"] + new-list-form] + [:div {:class "row row-cols-2"} + (for [list (todo-db/lists-page (d/db todo-db/conn)) ;; Important line + :let [list-name (:list/name list)]] + [:div {:class "col card"} + [:div {:class "card-body"} + [:h4 {:class "card-title"} list-name] + [:table {:class "table mb-4"} + [:thead + [:tr + [:th {:scope "col"} "Item"] + [:th {:scope "col"} "Status"]]] + [:tbody + (for [item (:list/items list) + :let [item-text (get item :item/text) + item-status (get-in item [:item/status :db/ident])]] ;; new + [:tr + [:td item-text] + [:td + [:span {:class "badge text-bg-light"} item-status]]])]]]])]]))) + +(def html-302-response ;; new + {:status 302 + :headers {"Content-Type" "text/html; charset=utf-8" "Location" "/"} + :body ""}) + +(defn html-200 [body] + {:status 200 + :headers {"Content-Type" "text/html; charset=utf-8"} + :body body}) + +(defn new-list [{:keys [form-params] :as _request}] ;;new + (let [list-name (:new-list form-params)] + (d/transact todo-db/conn [(todo-db/new-list list-name)]) + html-302-response)) + +(def routes + (route/expand-routes + #{["/" :get (comp html-200 all-lists-page) :route-name :home] + ["/lists" :post [(body-params/body-params) new-list] :route-name :new-list]})) ;; new + +(defn create-server [] + (http/create-server + {::http/routes routes + ::http/secure-headers {:content-security-policy-settings "object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:;"} + ::http/type :jetty + ::http/port 8890 + ::http/join? false})) + +(defonce server (atom nil)) + +(defn start-server [] + (reset! server (-> (create-server) http/start))) + +(defn stop-server [] + (swap! server http/stop)) + +(defn restart-server [] + (stop-server) + (start-server)) +``` + +load the file to the REPL and `(restart-server)` + +```clojure +;; REPL +(restart-server) +``` + +now let's got to http://localhost:8890/ , we should see something like this. + +GIF of creating a new list + +Great, let's move on to `retraction` to represent the deletion of a list. For that we will [:retractEntity](https://docs.datomic.com/transactions/transaction-functions.html#dbfn-retractentity) function, it will receive the listeid and itt retracts all the attribute values where the given entity id is either the entity or value, effectively retracting the entity's own data and any references to the entity as well. + +```clojure +(defn retract-list [listeid] + [:db/retractEntity listeid]) +``` +it's all about Datoms, we need to pass it to `d/transact` + +```clojure +(defn retract-list [{:keys [path-params] :as _request}] + (let [listeid (Long/parseLong (:list-id path-params))] ;; Datomic expects a Long and the path-params are received as string + (d/transact todo-db/conn [(todo-db/retract-list listeid)]) + html-302-response)) +``` + +then add the route and call the function + +```clojure +(def routes + (route/expand-routes + #{["/" :get (comp html-200 all-lists-page) :route-name :home] + ["/lists" :post [(body-params/body-params) new-list] :route-name :new-list] + ["/lists/:list-id/retract" :post [(body-params/body-params) retract-list] :route-name :retract-list]})) ;; new +``` + +### Create and Delete Items + +It's the same idea for the items, you need to add a transaction to add the new Datoms and a retraction to remove the item. Here is a list of the things that we will do + +- Create a `new-item` function in the server.clj file. +- Add the `lists/:list-id/items/:item-id` route and call `new-item` handler function. +- Add Hiccup form to create a new item. +- Create a `retract-item` function in the server.clj file. +- Add the `lists/:list-id/items/:item-id/delete` route and call `retract-item` handler function. + +Let's start with the creation of new items + +```clojure +(defn new-item [{:keys [path-params form-params] :as _request}] + (let [listeid (Long/parseLong (:list-id path-params)) + item-text (:new-item form-params)] + (d/transact todo-db/conn [(todo-db/new-item (d/db todo-db/conn) listeid item-text)]) + html-302-response)) +``` + +In the `path-params` we have access to the `listeid` and the current `todo-db/new-item` function expects the name, go to `src/todo_db.clj` and modify the new-item function to receive the `listeid` instead of the name + +from + +```clojure +(defn new-item [db list-name item-text] + (let [minify (clojure.string/replace item-text #" " "-")] + {:db/id (d/entid db [:list/name list-name]) + :list/items [{:db/id (str "item.temp." minify) ;; IMPORTANT line + :item/text item-text + :item/status :item.status/waiting}]})) +``` + +to + +```clojure +(defn new-item [db listeid item-text] ;; list-name -> listeid + (let [minify (clojure.string/replace item-text #" " "-")] + {:db/id listeid ;; new + :list/items [{:db/id (str "item.temp." minify) ;; IMPORTANT line + :item/text item-text + :item/status :item.status/waiting}]})) +``` + +make sure to load the changes of `todo_db.clj` to the REPL. Now let's move to `src/server.clj` and add the route + +```clojure +(def routes + (route/expand-routes + #{["/" :get (comp html-200 all-lists-page) :route-name :home] + ["/lists" :post [(body-params/body-params) new-list] :route-name :new-list] + ["/lists/:list-id/retract" :post [(body-params/body-params) retract-list] :route-name :retract-list] + ["/lists/:list-id/items" :post [(body-params/body-params) new-item] :route-name :new-item]})) ;; new +``` + +to finish the creation of the item, add the new item form + +```clojure +(defn new-item-form [list-id] + [:form {:action (str "/lists/" list-id "/items") + :method "POST" + :class "row row-cols-sm-auto form-inline"} + [:div {:class "col"} [:input {:type "text" :name "new-item" :class "form-control"}]] + [:div {:class "col"} [:input {:type "submit" :value "add" :class "btn btn-light btn-s"}]]]) +``` + +and place it below the `[:h4 {:class "card-title"} list-name]` + +```clojure +(defn all-lists-page + "Renders the TODO list page" + [_request] + (str + (h/html + (gen-page-head "TODO App with Clojure + Datomic") + [:div {:class "container"} + [:div {:class "bg-light rounded-3" :style "padding: 20px"} + [:h1 {:style "color: #1cb14a"} "Lists"] + new-list-form] + [:div {:class "row row-cols-2"} + (for [list (todo-db/lists-page (d/db todo-db/conn)) ;; Important line + :let [list-name (:list/name list) + list-id (:db/id list)]] + [:div {:class "col card"} + [:div {:class "card-body"} + [:form {:action (str "/lists/" list-id "/retract") :method "POST" :class ""} + [:input {:type "submit" :value "" :class "btn-close"}]] + [:h4 {:class "card-title"} list-name] + (new-item-form list-id) ;; new + [:table {:class "table mb-4"} + [:thead + [:tr + [:th {:scope "col"} "Item"] + [:th {:scope "col"} "Status"]]] + [:tbody + (for [item (:list/items list) + :let [item-text (get item :item/text) + item-status (get-in item [:item/status :db/ident])]] ;; new + [:tr + [:td item-text] + [:td + [:span {:class "badge text-bg-light"} item-status]]])]]]])]]))) +``` + +load the file to the REPL and `(restart-server)` + +```clojure +;; REPL +(restart-server) +``` + +go to http://localhost:8890/ + +ADD GIF adding a new item + +##### Retract item + +Go to `src/todo_db.clj` and add the `retract-item` function + +```clojure +(defn retract-item [itemeid] + [:db/retractEntity itemeid]) +``` + +And call it from `src/server.clj` + +```clojure +(defn retract-item [{:keys [path-params] :as _request}] + (let [itemeid (Long/parseLong (:item-id path-params))] + (d/transact todo-db/conn [(todo-db/retract-item itemeid)]) + html-302-response)) +``` + +Now add the function call in the routes + +```clojure +(def routes + (route/expand-routes + #{["/" :get (comp html-200 all-lists-page) :route-name :home] + ["/lists" :post [(body-params/body-params) new-list] :route-name :new-list] + ["/lists/:list-id/retract" :post [(body-params/body-params) retract-list] :route-name :retract-list] + ["/lists/:list-id/items" :post [(body-params/body-params) new-item] :route-name :new-item] + ["/lists/:list-id/items/:item-id/retract" :post [(body-params/body-params) retract-item] :route-name :retract-item]})) +``` + +finally add the Hiccup form to retract. For that add one more column to the table named `actions` here we will place the retract button and the transition to a different status. + +```clojure +(defn all-lists-page + "Renders the TODO list page" + [_request] + (str + (h/html + (gen-page-head "TODO App with Clojure + Datomic") + [:div {:class "container"} + [:div {:class "bg-light rounded-3" :style "padding: 20px"} + [:h1 {:style "color: #1cb14a"} "Lists"] + new-list-form] + [:div {:class "row row-cols-2"} + (for [list (todo-db/lists-page (d/db todo-db/conn)) + :let [list-name (:list/name list) + list-id (:db/id list)]] + [:div {:class "col card"} + [:div {:class "card-body"} + [:form {:action (str "/lists/" list-id "/retract") :method "POST" :class ""} + [:input {:type "submit" :value "" :class "btn-close"}]] + [:h4 {:class "card-title"} list-name] + (new-item-form list-id) + [:table {:class "table mb-4"} + [:thead + [:tr + [:th {:width "40%" :scope "col"} "Item"] + [:th {:width "20%" :scope "col"} "Status"] + [:th {:width "40%" :scope "col"} "Actions"]]] + [:tbody + (for [item (:list/items list) + :let [item-text (get item :item/text) + item-status (get-in item [:item/status :db/ident]) + item-id (get item :db/id)]] ;;;;;;;;;; new ;;;;;;;;;; + [:tr + [:td item-text] + [:td + [:span {:class "badge text-bg-light"} item-status]] + [:td ;;;;;;;;;;;;; new ;;;;;;;;;;;;;;;; + [:div {:class "row"} + [:div {:class "col-sm-2 align-items-right"} + [:form {:action (str "/lists/" list-id "/items/" item-id "/retract") :method "POST"} + [:input {:type "submit" :value "X" :class "btn btn-sm btn-danger"}]]]]]])]]]])]]))) +``` + +load the file to the REPL and `(restart-server)` + +```clojure +;; REPL +(restart-server) +``` + +go to http://localhost:8890/ and refresh + +ADD GIF DELETING ITEM + +#### Update Item status + +This section is about the transition of the Item status, we have three options `waiting`, `doing` ,`done`. We will show a dropdown button that allows you to choose the desired status and then make the request to the server. The server will receive the request then call the handler then make the transaction to the database and return 302 to make the refresh of the main page. + +Go to `src/todo_db.clj` and add the `transition-item` function + +```clojure +(def namespace-status (comp keyword (partial str "item.status/"))) + +(defn transition-item [itemeid to-status] + [:db/add itemeid :item/status (namespace-status to-status)]) +``` + +when we receive the status in the HTTP request it comes as string and without the `item.status` namespace, to adapt it so that it conveys with our defined schema enums we need to add the `item.status` namespace and convert it to a keyword. `namespace-status` function does that. + +Load the file to the REPL and move to `src/server.clj`. Now create the `transition-item` handler function + +```clojure +(defn transition-item [{:keys [path-params form-params] :as _request}] + (let [itemeid (Long/parseLong (:item-id path-params)) + new-status (:new-status form-params)] + (d/transact todo-db/conn [(todo-db/transition-item itemeid new-status)]) + html-302-response)) +``` + +add the Pedestal route + +```clojure +(def routes + (route/expand-routes + #{["/" :get (comp html-200 all-lists-page) :route-name :home] + ["/lists" :post [(body-params/body-params) new-list] :route-name :new-list] + ["/lists/:list-id/retract" :post [(body-params/body-params) retract-list] :route-name :retract-list] + ["/lists/:list-id/items" :post [(body-params/body-params) new-item] :route-name :new-item] + ["/lists/:list-id/items/:item-id/retract" :post [(body-params/body-params) retract-item] :route-name :retract-item] + ["/lists/:list-id/items/:item-id/transition" :post [(body-params/body-params) transition-item] :route-name :transition-item]})) +``` + +finally add the form in the Hiccup code + +```clojure +(defn todo-statuses-form [list-name todo] + [:form {:action (str "/lists/" list-name "/todos/" (:db/id todo) "/transition") + :method "POST" + :class "row row-cols-lg-auto g-3 align-items-center"} + [:div {:class "col"} + [:select {:class "form-select" :id "floatingSelectGrid" :name "new-status"} + [:option {:selected true} (get-in todo [:todo/status :db/ident])] + [:option {:value "waiting"} "waiting"] + [:option {:value "doing"} "doing"] + [:option {:value "done"} "done"]]] + [:div {:class "col"} + [:input {:type "submit" :value "ok" :class "btn btn-sm btn-secondary "}]]]) +``` + +then add it ito `all-lists-page` + +```clojure +(defn all-lists-page + "Renders the TODO list page" + [_request] + (str + (h/html + (gen-page-head "TODO App with Clojure + Datomic") + [:div {:class "container"} + [:div {:class "bg-light rounded-3" :style "padding: 20px"} + [:h1 {:style "color: #1cb14a"} "Lists"] + new-list-form] + [:div {:class "row row-cols-2"} + (for [list (todo-db/lists-page (d/db todo-db/conn)) + :let [list-name (:list/name list) + list-id (:db/id list)]] + [:div {:class "col card"} + [:div {:class "card-body"} + [:form {:action (str "/lists/" list-id "/retract") :method "POST" :class ""} + [:input {:type "submit" :value "" :class "btn-close"}]] + [:h4 {:class "card-title"} list-name] + (new-item-form list-id) + [:table {:class "table mb-4"} + [:thead + [:tr + [:th {:width "40%" :scope "col"} "Item"] + [:th {:width "20%" :scope "col"} "Status"] + [:th {:width "40%" :scope "col"} "Actions"]]] + [:tbody + (for [item (:list/items list) + :let [item-text (get item :item/text) + item-status (get-in item [:item/status :db/ident]) + item-id (get item :db/id)]] + [:tr + [:td item-text] + [:td + [:span {:class "badge text-bg-light"} item-status]] + [:td + [:div {:class "row"} + [:div {:class "col-sm"} ;;;;;;;;;; new ;;;;;;;;;;; + (transition-item-form list-id item)] + [:div {:class "col-sm-2 align-items-right"} + [:form {:action (str "/lists/" list-id "/items/" item-id "/retract") :method "POST"} + [:input {:type "submit" :value "X" :class "btn btn-sm btn-danger"}]]]]]])]]]])]]))) +``` + +load the file to the REPL and `(restart-server)` then go to http://localhost:8890/ and refresh + +ADD GIF of UPDATE + +### Final Code + +You can see the code in [src/server.clj](src/server.clj) and [src/todo_db](src/todo_db). Also here is the final version of both files after all the code blocks seen in the tutorial + +```clojure +(ns server + (:require + [datomic.api :as d] + [hiccup.page :as hp] + [hiccup2.core :as h] + [io.pedestal.http.body-params :as body-params] + [io.pedestal.http :as http] + [io.pedestal.http.route :as route] + [todo-db :as todo-db])) + +(defn gen-page-head + "Include bootstrap css" + [title] + [:head + [:title title] + (hp/include-css "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css")]) + +(def new-list-form + [:form {:action "/lists" :method "POST" :class "row row-cols-md-auto form-inline"} + [:div {:class "col"} [:input {:type "text" :name "new-list" :class "form-control"}]] + [:div {:class "col"} [:input {:type "submit" :value "new list" :class "btn btn-secondary btn-s"}]]]) + +(defn new-item-form [list-id] + [:form {:action (str "/lists/" list-id "/items") :method "POST" :class "row row-cols-sm-auto form-inline"} + [:div {:class "col"} [:input {:type "text" :name "new-item" :class "form-control"}]] + [:div {:class "col"} [:input {:type "submit" :value "add" :class "btn btn-light btn-s"}]]]) + +(defn transition-item-form [list-id item] + [:form {:action (str "/lists/" list-id "/items/" (:db/id item) "/transition") + :method "POST" + :class "row row-cols-lg-auto g-3 align-items-center"} + [:div {:class "col"} + [:select {:class "form-select" :id "floatingSelectGrid" :name "new-status"} + [:option {:selected true} (get-in item [:item/status :db/ident])] + [:option {:value "waiting"} "waiting"] + [:option {:value "doing"} "doing"] + [:option {:value "done"} "done"]]] + [:div {:class "col"} + [:input {:type "submit" :value "ok" :class "btn btn-sm btn-secondary "}]]]) + +(defn all-lists-page + "Renders the TODO list page" + [_request] + (str + (h/html + (gen-page-head "TODO App with Clojure + Datomic") + [:div {:class "container"} + [:div {:class "bg-light rounded-3" :style "padding: 20px"} + [:h1 {:style "color: #1cb14a"} "Lists"] + new-list-form] + [:div {:class "row row-cols-2"} + (for [list (todo-db/lists-page (d/db todo-db/conn)) + :let [list-name (:list/name list) + list-id (:db/id list)]] + [:div {:class "col card"} + [:div {:class "card-body"} + [:form {:action (str "/lists/" list-id "/retract") :method "POST" :class ""} + [:input {:type "submit" :value "" :class "btn-close"}]] + [:h4 {:class "card-title"} list-name] + (new-item-form list-id) + [:table {:class "table mb-4"} + [:thead + [:tr + [:th {:width "40%" :scope "col"} "Item"] + [:th {:width "20%" :scope "col"} "Status"] + [:th {:width "40%" :scope "col"} "Actions"]]] + [:tbody + (for [item (:list/items list) + :let [item-text (get item :item/text) + item-status (get-in item [:item/status :db/ident]) + item-id (get item :db/id)]] + [:tr + [:td item-text] + [:td + [:span {:class "badge text-bg-light"} item-status]] + [:td + [:div {:class "row"} + [:div {:class "col-sm"} + (transition-item-form list-id item)] + [:div {:class "col-sm-2 align-items-right"} + [:form {:action (str "/lists/" list-id "/items/" item-id "/retract") :method "POST"} + [:input {:type "submit" :value "X" :class "btn btn-sm btn-danger"}]]]]]])]]]])]]))) + +(def html-302-response + {:status 302 + :headers {"Content-Type" "text/html; charset=utf-8" "Location" "/"} + :body ""}) + +(defn html-200 [body] + {:status 200 + :headers {"Content-Type" "text/html; charset=utf-8"} + :body body}) + +(defn new-list [{:keys [form-params] :as _request}] + (let [list-name (:new-list form-params)] + (d/transact todo-db/conn [(todo-db/new-list list-name)]) + html-302-response)) + +(defn retract-list [{:keys [path-params] :as _request}] + (let [listeid (Long/parseLong (:list-id path-params))] + (d/transact todo-db/conn [(todo-db/retract-list listeid)]) + html-302-response)) + +(defn new-item [{:keys [path-params form-params] :as _request}] + (let [listeid (Long/parseLong (:list-id path-params)) + item-text (:new-item form-params)] + (d/transact todo-db/conn [(todo-db/new-item (d/db todo-db/conn) listeid item-text)]) + html-302-response)) + +(defn retract-item [{:keys [path-params] :as _request}] + (let [itemeid (Long/parseLong (:item-id path-params))] + (d/transact todo-db/conn [(todo-db/retract-item itemeid)]) + html-302-response)) + +(defn transition-item [{:keys [path-params form-params] :as _request}] + (let [itemeid (Long/parseLong (:item-id path-params)) + new-status (:new-status form-params)] + (d/transact todo-db/conn [(todo-db/transition-item itemeid new-status)]) + html-302-response)) + +(def routes + (route/expand-routes + #{["/" :get (comp html-200 all-lists-page) :route-name :home] + ["/lists" :post [(body-params/body-params) new-list] :route-name :new-list] + ["/lists/:list-id/retract" :post [(body-params/body-params) retract-list] :route-name :retract-list] + ["/lists/:list-id/items" :post [(body-params/body-params) new-item] :route-name :new-item] + ["/lists/:list-id/items/:item-id/retract" :post [(body-params/body-params) retract-item] :route-name :retract-item] + ["/lists/:list-id/items/:item-id/transition" :post [(body-params/body-params) transition-item] :route-name :transition-item]})) + +(defn create-server [] + (http/create-server + {::http/routes routes + ::http/secure-headers {:content-security-policy-settings "object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:;"} + ::http/type :jetty + ::http/port 8890 + ::http/join? false})) + +(defonce server (atom nil)) + +(defn start-server [] + (reset! server (-> (create-server) http/start))) + +(defn stop-server [] + (swap! server http/stop)) + +(defn restart-server [] + (stop-server) + (start-server)) +``` + +#### Retract Item + +### Resources + +- [Pedestal routes](http://pedestal.io/pedestal/0.7/guides/defining-routes.html) +- [Hiccup basic syntax](https://github.com/weavejester/hiccup/wiki/Syntax) +- [Datomic - :db/ident](https://docs.datomic.com/schema/identity.html#idents) +- [Datomic - pull](https://docs.datomic.com/query/query-pull.html) \ No newline at end of file diff --git a/todo-app/part-3/todo-app/assets/new-list.png b/todo-app/part-3/todo-app/assets/new-list.png new file mode 100644 index 0000000000000000000000000000000000000000..8b83d3d19ecc71aede6ab8ab36f660b105151463 GIT binary patch literal 29369 zcmd42cUY6z_b(d9QGO#L<0v8^prRms=r!Poib$0zHPWRE0U|Z11B@adp?B$o7CMHK z*yvSyO;AdJ1OfySLPBz1obS2kuY2x!o_o)8P9Dme{jPWIwbx#IueCpGy{}D-bh*y( zp8!S_n&-Sc#Bj(LR18{q-^P zbl`*2K_;&kZ$>r0$$Rg_b>7fvc};G%ST3tF?}k#d2Y<3?yr^N~Z0SUXfLz&WgQait za+7b;&2{0A-b%{?s(|?2i9AF)txTSL5UuIx+-^MF#0H)2LM^b~U$>Eb> zz@=r%!xGcb2Ub?%xdJMs9L6UA(Lf&^stOCQr=H|+`xx~Gr{QjV3NO&sIy!@{6DvKI z9rQjQS#>4j#mUoqedy3-DSP{y1uDnwAlY@&d`3+TZbqy{^f@%R*`r4^zU^?DdaT% zC)G&$um4`8@cieV>%HxtY;Rki4?lTKV`BNgkEEm?#W~Gr&!Fqq45z1SW!=kVxH5Yh zC@Q)b$qO8`Ap4(#ASJvpMVfC6{iZSLw>GpMYR@2JvOE%1+*T~R?EWg4`8N0H%y73ajkZJcVzPF*;> z7hdeYzpHulV^_1`dz76P{V=@B%IMrN3lpJV@fw$N4pkgdh`8sE$oZiQMd*;3qY$@# zPs$sd>`#k(k8ODU)3QMSqnT7`Az2a|>Nj@=Y>(_LYv6L50?8FGUMYl|_}o7ym@Gm= z4fni=FtN1q$_sJyzf=>UB{_vM)=uP^4K)HReJ^+5W%3#{VKSMiWaiel0SK0r+HG;x=%}_x!DsItePHd-3RTjm zIR$Ju6EhS;`B^y@>ez!S8==$S38x*Ec_HoEn)7(PGal$Hq+ArP#8U?bQOs1bPf= zvJu+-v7?W?4TDvl)Tmx1t8b8Z>vDY-j~-5HWrfYPEHt<uCDKXI(olaIC{y+`hgu@#+r!d9xjEI`vkQKiA8M!ms!+&yO#VcsIWNLq!?3 z|0N$%VzIko}A{sjSp_yiB%dNdwKcMOPu*;7PZ`pIYRL#`W zQXtpo1dz)p(eC_|ffxtLpSJnl>c23MvWlZJLQHZ^e-MXowhoNy>l-dZlVMom7?s?F zj$`B6HyyEKm-a)6dig^u9OLc>MnZnFEKxjsqcSs?y+Svj_>FFz&NvqEcINNlu-Lzx zcZdA*`*X!0=|p}}_aBS7Z^fHRcVNd>j_Nm_q8eFSFbs#fMeXy$jG2L2lrELBNOyLN zid1hkmL7*B6`WSjYC3|`z z5RVI$a}m-C50KGXtwhsOh?Lwqc$V224N*!z5)%O=@7ewr$f zOy=Oc+WKdMN}Tf3t>-%l^R?|k-2@Al*!S(WTs6bu&NBTlBbJ*l*y&*x_dNBxyX8P% zwJg=;r&u7c@%BQ|=`vOn6#8W+eI<9!1y3A1`1~W#Tf04At@#toLx(jobJEpf-0jD; zbOOVVK6b^XZl9i3>_1`jOUSXdq9X5ZF9@753)uSSs9A0N+fKNpueR?qA1Z$JwpSW< z8meC#o0ya5F)=3;7MZ*F(~{Tlzv4TbBGiNH%V_83vd*esjrD~-i{Q7SE@S2df~zW> z%1-_i@Q~{hjkrAkAMVYquGa9iuFp-NYV02IHru1Y?X6m0T7)0_KNaP|2WxLrQyX?u zc~I?V+9C#_bElU2q2vadJ8O0yWol6$PEUlo`x8Z8DTMaCKpkrS6uiUQ6~a4WpT?$+ zsgYVxM^3aRi9dFs)55hmRn8?W4)zu~Rd@nMCOv`@ZIBWD!wzT8G z?ou-tp^?uvO!I4~55?vLES}duC#^(ghA-CfvBH=79fQ}=^M{ah*YEKBoR+CccbAn# z7iX`uYI_7-Rx>SxxIxKG#%5_tdJAEx-v;c$&G@rVyDvxTE~luptLbj3sEbrm^)2op zYIqw0HWe~ypLNg_+FbPN$%Gzpk(LF-&-MQvE1tdBHpL@6%p9yN(NIrY9^%mtAiP-! z+vw45{WE=XQ}cYB zo4tos;X8Hnhd-RrK`j;hIj>{ewaHTLhY$YMSZ1{m)kK&0WAFsU%M23ch|zKk-GF#} zjFLk?|G`l3*_Py*#>H7rH#1A{t4EQ&LZsg?5K*DM_3#K%MsC%epBum$r0M{tW6|Wk ztPp#)FLziwtE)*C)2D@hWv0!XZl^3lp}pB%qz^4LDwI1nPr#^Zc%T#C56iPfq@pg? za+eQ&O)?9>oscE>M?5}?Mz;sTBhjAzm#X2Z5g18o|G{}NLI)RNbzZw7BDLaJaL{2a z5VtbIPUXTyEJ9Ctr$X7P$+e~tO_-`FzL!k7b3J@_{+t@PJ+D|TS(MYZ=DX#nW55#^A5XJYZJa5E%-e5WWMpQO7A zPD|J)GM1Su#h%-XS5($B+8I>}hpiU@=1*kD$Kr$;7TXJ$!_L6qpc3D^J`xfVp(M@Q z_*vp;fvSiviIHr1Awwma_n80i{-vJ9+T>c}G5|TrbG-(J|*xTkLNbmEa(B(oK@cX$wm?XDcgIF6uF9 ztqHmr*Nz29Zu=2c{&T({o%^3UXxyF=U*_V7*E?cG*dMRX+xXwZPo`u$cCWrn%*k(2 zF~ELN%p}I@CNU3!(B0}#(1z5? z3n0ex=~&9TUjJ@9imA%kLV6(o#C+)RfCD<;fFGXFwn1>&uBtz^(PAXkS?}$`dlS>J zp_#7|==tbFW&wT03X_hmOg5ifl^z=!u)ff>%=JYgc}OY5vG*h$ix0cn-=?fhc;`X? ztZgu-spHNfazKV)tD87W@#BF+=xuXttDohUQ@?Podf=rn>uS6-vEK1^&_-8&28_K< zr&EGdRv|)97Zhtly@!q#>6x0_z4P>Yi~$r>#VCNUP)1@a!8Ao4zq+Ei4zFJiNpa^B=v|H z8seqSDPqRfCGOstvV4_>Pe-xvv5+%lmG)bE$EJPA@k!+)aiU3G+E`kTvSEmofw00) zckC>w(WI9sF&siYoO&BZ>e9*Vr}ws)1mWeOH0dQ9h4L*pfpnppK!_uh9L?PH*isdB z=0*XI@613Yw_nd1S7(*R`)kgz3+ZXvH+o0jf&aG>)Ud_v)okB%eBc`4o?a4oKUb|sfwzb@iI`bsdjD2IAvt@@yF~|k z<%PSs%5S`XTVqha)hiWVs<0m$Ke&BE&$Ua)-(*rI{3@qIxO11V!}MO`hMZbJpnINO z`^2OeO3ytn{6}vicBj}b0H+tCZ~r~0-4f_6sv{9FH}Df3U3XJ!^!jy;Cy!&S#9nQb zuJp;~H4lF1`z!kF>_{u!$!_4~J?voHu$HFoxiHmuV%Ks{Ofg-In}7`nXEFb&Lq;yw z1qx{?bDfOL&S#f?=-zLVJ%H0U;>1>}kZ^=Hd)dRoFH#ChW+u*K0|$xECxG5TVZapJ z=AN$IcbKuSYzVe@SJs5WSadOCH^zdVvbSA2b&z9937jT|_YcO&M?Mvze>r$Nh+wYu zI^_)@yffsqbS-83Vs@P6*D@h)pW;xcE^3)j4+;29Ja;)?+%G?^K{muM22Px#)BTbI zJ7F)62OX(jbz)>`a=CXkRc~Q!yxKN1jnHxw@;zEf*3WwPN3!*{xqFuoCGCoeqxbH( zvvs4^OzlCP6zNLxZx<4Jg|TYZ9~OJ7!a6BIq9=pd(7~W#*}h!u4;pdG34PN#u|Dd} zzV@eiV%$Dp{{XtXt$kw@w05drfZQ)a>|Sk;W4o6c@@K~!K0<0gHNXWhi{CE5iAkNz zDUrBQ+_L~2*jMM0R%?hbdFW<{%eU6I@VPmK$fINryT&*lqseL`Mr+@=u)o1Z?4~9N zl^+;TYIHgIur&!8wv=%N)>o`(Uai*;txOIqMTb~V#y{6DH5AMTdgBWlFE-F}dP?iK zcC34FkEIx>!*RcGfHldz`?)b0I-f}(R5_;Vxy#8mO7+g;2m6!Z&qLN?#Ds@LDn(sw zgp8aiY4MNLDa$C~e$MXvoT08*$}*Kv;$2PYoj)Qcu6))8OQthDY-?EG5Synj*0alF z4Dtej5mm{rlMEOYc(?DvzP*W;e6pT@afZ21t`(>ei`kj|c*{(#N-MW-9g?FTriB&( z=|196h2|2et}z(OQ0 zR)lWaF|Lzkfj&p(4h^@#&$r)K*;|o_@=*@8!S-^mIJh^ODDi3s?;SLzFg=XpBidf` zF+ZeCam4;CfZYLZa_z4YQ}NBpDYpkf1ye(6E5#Q~m7YIC<@!O6|Ciu!raYwj_NxlgCV}P`lcVqot;*rb&E+Cu8vJo5 zuy;R3>ObM})L#E$*hkQR)Tc2ez!v}jw(9f@EhrBU53;mZ0xTAF>rxzj(+{5Q_$8)eyqH*d0<_pPV-4wO3r4rQ3}z_- z{(v{2=UVL3p%b7dDm-VRuK?eU8|&Q$`6HBo;lJ2qY)m<&OJdTcZPS3L84wV&bseCF zP?(hAvZ5jpy*T?RrQM&Z|K9+eALHWBEd;Dq)*-{g z?PY1jOWT~gxC=4+LHB?Ski7;da5|QBB#Ft-Nlmohd(GTW$E4+J?!;~i+2wYj>{FJ+ z3QZ8MhMaS^`~2VHA#)-5Jxw&!Rx~d#%CHyqR6u_NisOnkhTE zufotzerhVzhJ6l0(@@kE%9&@lAS&e4HbyFZgnY3?;a&P*0c;{;4BE>u8h*6@#cbkR zp6hV%a_wsijog1%1*;McET8TbGZnUGH?HLtA3VT_uWkQTC6t^k4ni0!Wid69p~M6gqGwftPij_4~`d@6^qeagLIsbLBhlAB=edeEb? zQoEzDs{(1em0W>HO~j?;&$nGo)Y>t~vu@QKZmD_5n7v`~MZSJ(#2_oc5=Z;Q8Qy;Z z6qUY5S!`o8Y9BU*?>XZQoQadVR_-$wXoOXi2n7%7sk3IV+xDK(~iy$MQaS zcD`h86S&JFh_Ae?+^xrstS131BZMXnMbdZ=XdO%%)5Cfm^~w@F7nvW_iO!;9-0Gnk&6rw5r`_5uTScf$e z>(80Z%;ny=U1S|!vNhZGACI ztbwp_y|gCQHNhEej<=VSDx?|Fus|2Fwm?{1g-*B2-pA$jctLA)fGcA2RT!}IDlx(V zfFxi=U*)?8^K;5u`sbQ%SQT!(w#^kdj;;I%+W#t$PKZs1UxkNt?{2=_obZ6m=;PDo z{SNdUBf8_jJgAePk^Jcm7b42`TzFG{g}D#>l^~#TvV*R1I|;(xC>aS`))OAmUf89~ z)3%vX9$ERPX!ta2<|wEB{nM5mjep)Fae;0-b;4p2ryJFa!4_&Ok~Bh_JHzG%2*gMO zlV4k{;KD9>pUKu8>ap@WxNF2$KhYkv6foWM>AWt`{an?7koGlQ%bR+-s|!%PK4j-t zz~Z~|s9y!FR_S;@7VU*|z}h?egwvoXhXxu4-Xc03)BItVR@n+?wYIF-cl)X9FtBW> z7MtQQ8w(g*=vd9+26*!|$GQF!wwGerHR5qq$DU2Lj^-C+N-G_=y2&{u+V9J-!Dv!N z)X2+A(8oTo+$VR37~{ITUvx2PAztZ(vh?AeLJeFJELZbdZ2aZCiooKCiCs-O_V-3% zm<21c>z^%kIlwCNfQ>$E_QI6=F8gx2=4?tMOEVwq?ZVn-jVY{;oJUp|sL#UFqos+A z?RzHy*FDR4MZP9RfahA|YrsEfdPJz=>6Nh!+ZC}2#CG5LxUl(H=#q8|p$Hygw%SxP zGzJ?SYJv^o9H+PA<90jw%tkp|2q!>MTl=07`$E*Ou^luhdT%Q(;L8O;Yx;;Gofzx8 z(J=*tSEwgsZj6FdS@=#HeXATk)B>lPtRB_86XD)u37910?55MzE^6u-YLgCDvZs^`O zU78#CI9V(BJiw9XFn7ZSao7B(pH_Dpw z7HR;}!_V=VYW}-CnPcc!!g_)lsP8p~KWd@Go#XIDA4cZAZlJR3&|r(ZQQb8z;$iD$ zP_XUXyj}1{Ci?s9AC}!VqkH)+W$Uk(ztW71zl_}IUv-<5hK`z+>+SG}h4C!Naok3a zS)Hr}gEdBsQ{1IRmk=Ws+Y_iL_2!klaNq8GB`9H>rdp#Rv@tL5My2O4}*e&JiBw#II;3#(zX#n zrJDr;0W+uESHHeqQgCw#s;I0~@ci-CE=1pXHIngWAVX8M=}Uzf*fZRSF)}a!X>k4x zM8d3=UGlN%^`BNl|BxN(UH)xJe&=tb8UYSTCNmfI#=Y5pD>W!MkE{}z@8!^>Jj2*H zc8HH#d(c=@n-kEw$5AslpO%{wSO!%j%(hsP4_cNI^i6FaCB2#DgfjHuva_QHvgyl> zMY<);t?CmZ-7d^=PUshr$zqV}T?p4lw}pv@VeHQ59A$rl`&@J54O2pqPBKj9rU%Yi zpxT{-`VoWv(bk}*Fsvb$&Lpk;TKwU;uN!;tRVJ3yta-3+$h$GwWt|zKzoZN)^RRvv zR)blj+I8%Qdu7Sf=F(m66Uqk|TShXwAX3`w`65K)4P$&B(XaK-B`sC|#2RFDkYUJn zL23G(bSHhYRb1dbA#Vvc97-$Of8k&Xwazve9>>lyTMFfSD^?YcD&Fyk2jcZWRLeup zs0bt2KFL~H8_KJQ-Se6oi_bhN;Z%SR%F2^lkqfhi#2H7tIZhHIDG;zeA0+N`tE90Y zB_v-U{e}mcUT?S4kXGy#ptV1C&%?()DFokfl`FMVaUsyv_qkJBNWuq2>{Ev%9z$%8 zJG*yx{N6WtLD?e-<7nB2Ag)e?J%;yMB==;!;pl^Tl)oyzSDqknBS0`?$ zA4X0+k&>$;(^C}|nN*2WkPAxHoBN@L{=Un{2R{H{&n0Fqe#9-?i|MAcB)<=TtH1D1 z*7&%h%Pt4RcPKl3(5$YpHqkWVPvHa$lBWPAxJrYPS$|`s%`D18+h` z4)OxOsg+rB*5PaP;x4B`-MivywZY0@zl4P%_t=ODt>44_${N%RjeT0rs<8V#kTuiw zR^K!1{Eg-GTb6>_WukRT2@MT|5<0St zk~^G}{8|K2m6e|g}J)PJ}+63`MI2Ghc^=PI#z1DpuKx~vI~p?>bRFYydQrO z*nzmH>%+~S30YYexKT5Xx@C`a$)2!^ToSv{7#jxy7d`74w_~m#(Fe{ggwHKQJ){z8 z^idk5coWP^4(0yTtcE7-@nxbF#m5Z?UYDs9i%QC~^U_Fd7<1p$E9V?d&4V0mo98%3GpcE5i zK02AgVb!!5-g!{@t>L7`qr7TYS4YZ}sI8fu*?_WPkgEg-#b)bkdv!gMivAIM^&oIcaBDuIhy4pwYzPA!)hstycK@^NZkC>3nOHAHDuDiURB}eMGAPyu0-1S6g57b>7Hzs-lc9kiS_~1S^U)z$eFBnog3w zywPsE?v5e%%|9c~E6O|tBIB+B^v)YQ=x`~|mFSq5w+|7AF^cA*;#wIqDno`)ef=!n zfk9?T2wh}hXi`xY-^ow5A56+J$QpTf!-M{<2~DjjZLIRtgX$B{jVUhphplRTLo_>? zsxE&u$XE3oJ6SC~6yU-Kkm=(_ylL@7o*Ge8Yx^P=k68k(qX_ZmcvS z6EV79=BM3kk`=yyFh%b3mc(&~&>uVAwFhvMQuFe8mS3mLDv;kSDXWn+6x?~(WM|Uq zqk}Y`{S&n}QxvRRYLaz;4vE+n`X#KWTPVx`QjI!&>%ziV1Iml(?P2Fgsq4Fg1QIpo zDuuP{w9n(C`-3G`xkcs@>obnR6q~S(UAvuJmmm1~G;6pT_yUnr&-I$p{so2o+APDc zdpx46X4(;dwnS{bGyqq~MBhOaD0!%Q29#oh=4G(_CvO`gdtayH-&JaJrZVFqYew;_f)ng5EE48k~a|J2E+TXfj;-)+#LTP zy;+;l(KJI!JL9tfI9z4*0pg3yppSN&R(hV6OpuNW*x~ZGcm84q!>4$2(d)zv@nXl_ zJp;`u(dfP#(OKhyN=m$Ndh-CC`PCtU1`2%x1UBhG#={P(-z%6usFeYq)|X6G6g|8= z^lbep_(W)#`@DXbI}mt)tsY6FK1A&;E+uN`6(wr5st|!Vx_qwU`JAnCyAobTyna0N<#&FmOKY}Ek@nrIt~Z9r8jfWozm z0s(R@s_y@vv;YtR0@?9W0Pc*pw---sHI>*8Qd%f0ypAlt{0=CsbpdK`F){!QO3eVg zUiNR86Vt>%-}#Jqfwsfew?dC3ZkCu@wYIj(KU<74KX+w%>L!5rd6fb&O>ndNi6`a=i-rcj*Zw`6_QuI3FC)Ly0?1K+#8p>THV!L}*m}=Tr`FWS zA3EGJnS7&9aA%D=0cnHV!lMl*kD?dbR2moZ;>3)Hn<$Ld-=jL_x@saSU*Mb|5ev8B zV>tr2Fd(Y39P@&E5}0;J-9_@NIxS_@49=79c{eLNh?v9H0&ODu6U^4R=4iwf2vH%t zzkDQGNNi>gE(14TL_7f2K&(n}L5*lMrsh=Clq+P@o;ESXb!5%v#!LslF|;a=UsW=kBL zUL#OT^E#f>%AlXK&-KmV?Pizu*JcXX>udn!Zoe;B8AtyT=OZ30+B+J))Ly4{V9ZkH zhWRR$H)J#`bynFr%i02`ai6xa240Q1(dN<&r&wS{9)YZmQWRvdV8d)wYG<2F zf4O7XO3cb!Zl$V9`rDepx7jUHa