From 016859830c84cf4fe9c915689288289cf90985b9 Mon Sep 17 00:00:00 2001 From: John Bledsoe <59550+johnbcodes@users.noreply.github.com> Date: Fri, 8 Dec 2023 11:14:08 -0500 Subject: [PATCH 1/2] feat: Completed code changes to htmx with full functionality --- Cargo.lock | 32 ++---- Cargo.toml | 1 - README.md | 1 + package-lock.json | 89 ++++++++++++++++ package.json | 4 +- src/layout.rs | 11 +- src/line_item_dates/controller.rs | 72 ++++++------- src/line_item_dates/view.rs | 160 +++++++++++++++++++---------- src/line_items/controller.rs | 72 ++++++------- src/line_items/view.rs | 141 ++++++++++++++++++++------ src/main.rs | 8 +- src/quotes/controller.rs | 76 +++++++------- src/quotes/model.rs | 6 +- src/quotes/view.rs | 162 +++++++++++++++++++++--------- ui/src/app.js | 35 +++---- 15 files changed, 576 insertions(+), 294 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 15e8568..c7d817b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,15 +233,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "cpufeatures" version = "0.2.11" @@ -842,9 +833,9 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" dependencies = [ "libc", "wasi", @@ -882,9 +873,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "overload" @@ -1159,7 +1150,6 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", - "convert_case", "currency_rs", "diesel", "diesel_migrations", @@ -1677,9 +1667,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" +checksum = "6f2528f27a9eb2b21e69c95319b30bd0efd85d09c379741b0f78ea1d86be2416" [[package]] name = "unicode-ident" @@ -1696,12 +1686,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-segmentation" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" - [[package]] name = "url" version = "2.5.0" @@ -1903,9 +1887,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "winnow" -version = "0.5.24" +version = "0.5.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0383266b19108dfc6314a56047aa545a1b4d1be60e799b4dbdd407b56402704b" +checksum = "b67b5f0a4e7a27a64c651977932b9dc5667ca7fc31ac44b03ed37a0cf42fdfff" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 16326cc..1d8972b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ lto = true [dependencies] anyhow = "1.0" axum = "0.7" -convert_case = "0.6" currency_rs = { git = "https://github.com/johnbcodes/currency_rs", branch = "feature/db-diesel2-sqlite", version = "1.1", features = [ "db-diesel2-sqlite" ] } diesel = { version = "2.1", features = ["r2d2", "sqlite", "time"] } diesel_migrations = "2.1" diff --git a/README.md b/README.md index 21db164..a9b9e52 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Additionally, there were some other features and integral parts of Rails that ha * "to_sentence" on ValidationErrors struct for flash message * Only add border color to fields with errors * Labels for input fields +* Delete confirmation * Probably a few others ## Getting Started diff --git a/package-lock.json b/package-lock.json index 82bd7a6..f8bd690 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "devDependencies": { "@hotwired/stimulus": "3.2.2", "@hotwired/turbo": "7.3.0", + "htmx.org": "1.9.9", + "hyperscript.org": "0.9.12", "npm-run-all": "4.1.5", "parcel": "2.10.2", "tailwindcss": "3.3.5" @@ -110,6 +112,16 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -2471,6 +2483,18 @@ "integrity": "sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ==", "dev": true }, + "node_modules/acorn": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ansi-styles": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", @@ -2644,6 +2668,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "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 + }, "node_modules/call-bind": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", @@ -3648,6 +3678,25 @@ "entities": "^3.0.1" } }, + "node_modules/htmx.org": { + "version": "1.9.9", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.9.tgz", + "integrity": "sha512-PDEZU1me7UGLzQk98LyfLvwFgdtn9mrCVMmAxv1/UjshUnxsc+rouu+Ot2QfFZxsY4mBCoOed5nK7m9Nj2Tu7g==", + "dev": true + }, + "node_modules/hyperscript.org": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/hyperscript.org/-/hyperscript.org-0.9.12.tgz", + "integrity": "sha512-fLtS+FVayzLUdgC4w00oozg0uickzUyJIbyKg0fB7vSGkUfsY2itGl/XsTcVFahufp41oN5OFFmRXMxoIZ97Og==", + "dev": true, + "dependencies": { + "markdown-it-deflist": "^2.1.0", + "terser": "^5.14.1" + }, + "bin": { + "_hyperscript": "src/bin/node-hyperscript.js" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -4297,6 +4346,12 @@ "node": ">=10" } }, + "node_modules/markdown-it-deflist": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/markdown-it-deflist/-/markdown-it-deflist-2.1.0.tgz", + "integrity": "sha512-3OuqoRUlSxJiuQYu0cWTLHNhhq2xtoSFqsZK8plANg91+RJQU1ziQ6lA2LzmFAEes18uPBsHZpcX6We5l76Nzg==", + "dev": true + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -5327,6 +5382,16 @@ "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, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -5580,6 +5645,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/terser": { + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.25.0.tgz", + "integrity": "sha512-we0I9SIsfvNUMP77zC9HG+MylwYYsGFSBG8qm+13oud2Yh+O104y614FRbyjpxys16jZwot72Fpi827YvGzuqg==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", diff --git a/package.json b/package.json index bf8a837..908a578 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "parcel:prephtml": "cp ui/src/base.html ui/target/build/" }, "devDependencies": { - "@hotwired/turbo": "7.3.0", - "@hotwired/stimulus": "3.2.2", + "htmx.org": "1.9.9", + "hyperscript.org": "0.9.12", "npm-run-all": "4.1.5", "parcel": "2.10.2", "tailwindcss": "3.3.5" diff --git a/src/layout.rs b/src/layout.rs index eb21d33..ad68f98 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -16,19 +16,16 @@ markup::define! { div[class = "font-bold ml-auto mr-3 text-header"] { "Accountant" } a[class = "button button-dark", href = "#"] { "Sign out" } } - div[id = "flash", class = "fixed top-20 left-1/2 -translate-x-1/2 flex flex-col items-center gap-3 m-w-full w-max px-4 py-0"] { - @Flash{ message: None } - } + div[id = "flash", class = "fixed top-20 left-1/2 -translate-x-1/2 flex flex-col items-center gap-3 m-w-full w-max px-4 py-0"] {} @body } } } - Flash<'a>(message: Option<&'a str>) { - @if let Some(message) = message { + Flash<'a>(message: &'a str) { + div[id = "flash", "hx-swap-oob" = "innerHTML"] { div[class = "text-[0.875rem] text-white px-4 py-2 bg-dark rounded-full animate-appear-then-fade", - "data-controller" = "removals", - "data-action" = "animationend->removals#remove"] { + "_" = "on animationend remove me"] { @message } } diff --git a/src/line_item_dates/controller.rs b/src/line_item_dates/controller.rs index 3b7a68c..2939d6d 100644 --- a/src/line_item_dates/controller.rs +++ b/src/line_item_dates/controller.rs @@ -2,23 +2,36 @@ use crate::{ line_item_dates::{ self, model::{DeleteForm, LineItemDateForm, LineItemDatePresenter}, - view::{Create, Destroy, Form, Update}, + view::{Create, Destroy, EditForm, LineItemDateInfo, NewForm, Update}, }, line_items::{self, model::LineItemPresenter}, quotes, Result, }; use axum::{ extract::{Path, State}, - http::StatusCode, response::{Html, IntoResponse}, }; use diesel::prelude::SqliteConnection; use diesel::r2d2::{ConnectionManager, Pool}; -use hotwire_turbo_axum::TurboStream; use std::time::Instant; use tracing::info; use validator::Validate; +pub(crate) async fn line_item_date( + State(pool): State>>, + Path(id): Path, +) -> Result { + let start = Instant::now(); + let record = line_item_dates::query::read(&pool, id).await?; + let duration = start.elapsed().as_micros(); + info!("lid - read duration: {duration} μs"); + + let template = LineItemDateInfo { + line_item_date: &record.into(), + }; + Ok(Html(template.to_string())) +} + pub(crate) async fn new( State(pool): State>>, Path(quote_id): Path, @@ -31,10 +44,9 @@ pub(crate) async fn new( let duration = start.elapsed().as_micros(); info!("lid - read duration: {duration} μs"); Ok(Html( - Form { + NewForm { dom_id: &line_item_date.dom_id(), line_item_date: &line_item_date, - action: "create", error_message: None, } .to_string(), @@ -52,7 +64,7 @@ pub(crate) async fn create( let line_item_date = line_item_dates::query::insert(&pool, &form).await?; let duration = start.elapsed().as_micros(); info!("lid - insert duration: {duration} μs"); - Ok(TurboStream( + Ok(Html( Create { line_item_date: &line_item_date.into(), line_items: &Vec::new(), @@ -66,19 +78,15 @@ pub(crate) async fn create( info!("ValidationErrors:\n{:?}", errors); let error_message = String::from("Test"); let line_item_date: &LineItemDatePresenter = &form.into(); - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Html( - Form { - dom_id: &line_item_date.dom_id(), - line_item_date, - action: "create", - error_message: Some(error_message), - } - .to_string(), - ), + Ok(Html( + NewForm { + dom_id: &line_item_date.dom_id(), + line_item_date, + error_message: Some(error_message), + } + .to_string(), ) - .into_response()) + .into_response()) } } } @@ -93,10 +101,9 @@ pub(crate) async fn edit( info!("lid - read duration: {duration} μs"); let line_item_date: &LineItemDatePresenter = &record.into(); Ok(Html( - Form { + EditForm { dom_id: &line_item_date.edit_dom_id(), line_item_date, - action: "update", error_message: None, } .to_string(), @@ -122,7 +129,7 @@ pub(crate) async fn update( .collect::>(); let duration = start.elapsed().as_micros(); info!("li - read all duration: {duration} μs"); - Ok(TurboStream( + Ok(Html( Update { line_item_date: &line_item_date.into(), line_items: &line_items, @@ -136,19 +143,15 @@ pub(crate) async fn update( info!("ValidationErrors:\n{:?}", errors); let error_message = String::from("Test"); let line_item_date: &LineItemDatePresenter = &form.into(); - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Html( - Form { - dom_id: &line_item_date.edit_dom_id(), - line_item_date, - action: "update", - error_message: Some(error_message), - } - .to_string(), - ), + Ok(Html( + EditForm { + dom_id: &line_item_date.edit_dom_id(), + line_item_date, + error_message: Some(error_message), + } + .to_string(), ) - .into_response()) + .into_response()) } } } @@ -165,9 +168,8 @@ pub(crate) async fn delete( let quote = quotes::query::read(&pool, &line_item_date.quote_id).await?; let duration = start.elapsed().as_micros(); info!("quo - read duration: {duration} μs"); - Ok(TurboStream( + Ok(Html( Destroy { - line_item_date: &line_item_date.into(), quote: "e.into(), message: "Date was successfully destroyed.", } diff --git a/src/line_item_dates/view.rs b/src/line_item_dates/view.rs index 39a886c..e9e6e22 100644 --- a/src/line_item_dates/view.rs +++ b/src/line_item_dates/view.rs @@ -2,71 +2,130 @@ use crate::{ layout::Flash, line_item_dates::model::LineItemDatePresenter, line_items::{model::LineItemPresenter, view::LineItem}, - quotes::{model::QuotePresenter, view::Footer}, + quotes::{model::QuotePresenter, view::SwapFooter}, }; -use convert_case::{Case, Casing}; markup::define! { LineItemDate<'a>(line_item_date: &'a LineItemDatePresenter, line_items: &'a Vec) { - $"turbo-frame"[id = &line_item_date.dom_id()] { - // line-item-date + div[id = &line_item_date.dom_id()] { div[class = "mt-8 mb-1.5"] { - $"turbo-frame"[id = &line_item_date.edit_dom_id()] { - // line-item-date__header - div[class= "flex items-center justify-between gap-2"] { - h2[class = "text-[1.5rem] font-bold"] { - @line_item_date.date_long_form() - } - div["data-turbo-confirm" = "Are you sure?", class = "flex gap-2"] { - form[method = "post", action = "/line_item_dates/delete"] { - input[id = "line_item_date_id", - name = "id", - "type" = "hidden", - value = &line_item_date.id()] {} - button[class = "button button-light", "type" = "submit"] {"Delete"} - } - a[class = "button button-light", href = {format!("/line_item_dates/edit/{}", &line_item_date.id())}] { "Edit" } - } - } - } - // line-item-date__body + @LineItemDateInfo{ line_item_date } + + // line-item body div[class = "bg-white rounded-md mt-2 p-4 shadow-[1px_3px_6px_hsl(0,0%,0%,0.1)]"] { - // line-item line-item--header + // header div[class = "flex flex-wrap items-start bg-light gap-2 mb-3 p-2 rounded-md"] { - // line-item__name + // name div[class = "flex-1 font-bold text-[0.875rem] tracking-[1px] uppercase"] { "Article" } - // line-item__quantity + // quantity div[class = "display-[revert] flex-[0_0_7rem] font-bold text-[0.875rem] tracking-[1px] uppercase"] { "Quantity" } - // line-item__price + // price div[class = "display-[revert] flex-[0_0_9rem] font-bold text-[0.875rem] tracking-[1px] uppercase"] { "Price" } - // line-item__actions + // actions div[class = "flex flex-[0_0_10rem] order-[revert] gap-2 font-bold text-[0.875rem] tracking-[1px] uppercase"] {} } - $"turbo-frame"[id = {format!("line_item_date_{}_line_items", &line_item_date.id())}] { + div[id = {format!("line_item_date_{}_line_items", &line_item_date.id())}] { @for line_item in *line_items { @LineItem { line_item } } } @let line_item_new_id = format!("line_item_date_{}_line_item_new", &line_item_date.id()); - $"turbo-frame"[id = &line_item_new_id] {} + div[id = &line_item_new_id] {} div[class = "p-4 text-center border-2 border-dashed border-[hsl(0,6%,93%)] rounded-md"] { - a[class = "button button-prime", "data-turbo-frame" = &line_item_new_id, href = {format!("/line_items/new/{}", &line_item_date.id())}] { - "Add item" - } + @let target = format!("#line_item_date_{}_line_item_new", &line_item_date.id()); + a[class = "button button-prime", + "hx-get" = {format!("/line_items/new/{}", &line_item_date.id())}, + "hx-target" = &target, + "hx-trigger" = "click", + "hx-swap" = "innerHTML"] { "Add item" } + } + } + } + } + } + + LineItemDateInfo<'a>(line_item_date: &'a LineItemDatePresenter) { + div[id = &line_item_date.edit_dom_id()] { + div[class= "flex items-center justify-between gap-2"] { + h2[class = "text-[1.5rem] font-bold"] { + @line_item_date.date_long_form() + } + div[class = "flex gap-2"] { + form["hx-post" = "/line_item_dates/delete", + "hx-target" = {format!("#{}", &line_item_date.dom_id())}, + "hx-swap" = "delete"] { + + input[id = "line_item_date_id", + name = "id", + "type" = "hidden", + value = &line_item_date.id()] {} + button[class = "button button-light", "hx-confirm" = "Are you sure?", "type" = "submit"] {"Delete"} } + a[class = "button button-light", + "hx-get" = {format!("/line_item_dates/edit/{}", &line_item_date.id())}, + "hx-target" = {format!("#{}", &line_item_date.edit_dom_id())}, + "hx-trigger" = "click"] { "Edit" } + } + } + } + } + + EditForm<'a>(dom_id: &'a String, line_item_date: &'a LineItemDatePresenter, error_message: Option) { + div[id = dom_id] { + form[id = {format!("form_{}", dom_id)}, + "hx-post" = "/line_item_dates/update", + "hx-target" = {format!("#{}", &line_item_date.dom_id())}, + "hx-swap" = "outerHTML", + class = "flex flex-wrap justify-between items-center gap-2 mt-8 mb-1.5", + autocomplete = "off", + "accept-charset" = "UTF-8"] { + @let form_input_class = if error_message.is_some() { "form-input border-primary" } else { "form-input" }; + @if let Some(message) = error_message { + div[class = "w-full text-primary bg-primary-bg p-2 rounded-md"] { @message } + } + @if let Some(id) = &line_item_date.id { + input[id = "line_item_date_id", + name = "id", + "type" = "hidden", + value = id] {} + } + input[id = "quote_id", + name = "quote_id", + "type" = "hidden", + value = &line_item_date.quote_id] {} + div[class = "[flex:1]"] { + label[class = "visually-hidden", "for" = "line_item_date_date"] { "Date" } + input[id = "line_item_date_date", + name = "date", + class = form_input_class, + autofocus = "autofocus", + required, + "type" = "date", + value = line_item_date.date_short_form()] {} } + a[class = "button button-light", + "hx-get" = {format!("/line_item_dates/{}", &line_item_date.id())}, + "hx-target" = {format!("#{}", &line_item_date.edit_dom_id())}, + "hx-trigger" = "click", + "hx-swap" = "outerHTML"] { "Cancel" } + input[name = "commit", + "type" = "submit", + value = "Update date", + class = "button button-secondary", + "_" = "on click add { pointer-events: none }"] {} } } } - Form<'a>(dom_id: &'a String, line_item_date: &'a LineItemDatePresenter, action: &'a str, error_message: Option) { - $"turbo-frame"[id = dom_id] { - form[id = dom_id, - action = {format!("/line_item_dates/{}", action.to_case(Case::Flat))}, - method = "post", + NewForm<'a>(dom_id: &'a String, line_item_date: &'a LineItemDatePresenter, error_message: Option) { + div[id = dom_id] { + form[id = "form_new", + "hx-post" = "/line_item_dates/create", + "hx-target" = "#line_item_dates", + "hx-swap" = "afterbegin", class = "flex flex-wrap justify-between items-center gap-2 mt-8 mb-1.5", autocomplete = "off", "accept-charset" = "UTF-8"] { @@ -94,13 +153,13 @@ markup::define! { "type" = "date", value = line_item_date.date_short_form()] {} } - a[class = "button button-light", href = {format!("/quotes/show/{}", &line_item_date.quote_id)}] { "Cancel" } - @let button_text = format!("{} date", action.to_case(Case::Title)); + a[class = "button button-light", + "_" = "on click remove #form_new"] { "Cancel" } input[name = "commit", "type" = "submit", - value = &button_text, + value = "Create date", class = "button button-secondary", - "data-disable-with" = &button_text] {} + "_" = "on click add { pointer-events: none }"] {} } } } @@ -108,21 +167,20 @@ markup::define! { Create<'a>(line_item_date: &'a LineItemDatePresenter, line_items: &'a Vec, message: &'a str) { - @markup::raw(hotwire_turbo::stream::prepend("line_item_dates", LineItemDate{ line_item_date, line_items }.to_string())) - @markup::raw(hotwire_turbo::stream::update("line_item_date_new", "")) - @markup::raw(hotwire_turbo::stream::prepend("flash", Flash{ message: Some(message) }.to_string())) + @LineItemDate{ line_item_date, line_items } + div[id = "line_item_date_new", "hx-swap-oob"="innerHTML"]{} + @Flash{ message } } Update<'a>(line_item_date: &'a LineItemDatePresenter, line_items: &'a Vec, message: &'a str) { - @markup::raw(hotwire_turbo::stream::replace(&line_item_date.dom_id(), LineItemDate{ line_item_date, line_items }.to_string())) - @markup::raw(hotwire_turbo::stream::prepend("flash", Flash{ message: Some(message) }.to_string())) + @LineItemDate{ line_item_date, line_items } + @Flash{ message } } - Destroy<'a>(line_item_date: &'a LineItemDatePresenter, quote: &'a QuotePresenter, message: &'a str) { - @markup::raw(hotwire_turbo::stream::remove(line_item_date.dom_id())) - @markup::raw(hotwire_turbo::stream::prepend("flash", Flash{ message: Some(message) }.to_string())) - @markup::raw(hotwire_turbo::stream::update("e.total_dom_id(), Footer{ quote }.to_string())) + Destroy<'a>(quote: &'a QuotePresenter, message: &'a str) { + @Flash{ message } + @SwapFooter{ quote } } } diff --git a/src/line_items/controller.rs b/src/line_items/controller.rs index 85d6190..08c638a 100644 --- a/src/line_items/controller.rs +++ b/src/line_items/controller.rs @@ -3,22 +3,35 @@ use crate::{ line_items::{ self, model::{DeleteForm, LineItemForm, LineItemPresenter}, - view::{Create, Destroy, Form, Update}, + view::{Create, Destroy, EditForm, LineItem, NewForm, Update}, }, quotes, Result, }; use axum::{ extract::{Path, State}, - http::StatusCode, response::{Html, IntoResponse}, }; use diesel::prelude::SqliteConnection; use diesel::r2d2::{ConnectionManager, Pool}; -use hotwire_turbo_axum::TurboStream; use std::time::Instant; use tracing::info; use validator::Validate; +pub(crate) async fn line_item( + State(pool): State>>, + Path(id): Path, +) -> Result { + let start = Instant::now(); + let line_item = line_items::query::read(&pool, id).await?; + let duration = start.elapsed().as_micros(); + info!("li - read duration: {duration} μs"); + + let template = LineItem { + line_item: &line_item.into(), + }; + Ok(Html(template.to_string())) +} + pub(crate) async fn new( State(pool): State>>, Path(line_item_date_id): Path, @@ -28,10 +41,9 @@ pub(crate) async fn new( let duration = start.elapsed().as_micros(); info!("lid - read duration: {duration} μs"); Ok(Html( - Form { + NewForm { line_item: &LineItemPresenter::from_line_item_date(line_item_date_id), quote: "e.into(), - action: "create", error_message: None, } .to_string(), @@ -53,7 +65,7 @@ pub(crate) async fn create( let quote = quotes::query::read(&pool, &form.quote_id).await?; let duration = start.elapsed().as_micros(); info!("quo - read duration: {duration} μs"); - Ok(TurboStream( + Ok(Html( Create { line_item: &line_item.into(), quote: "e.into(), @@ -70,19 +82,15 @@ pub(crate) async fn create( let duration = start.elapsed().as_micros(); info!("quo - read duration: {duration} μs"); let error_message = String::from("Test"); - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Html( - Form { - line_item: &form.into(), - quote: "e.into(), - action: "create", - error_message: Some(error_message), - } - .to_string(), - ), + Ok(Html( + NewForm { + line_item: &form.into(), + quote: "e.into(), + error_message: Some(error_message), + } + .to_string(), ) - .into_response()) + .into_response()) } } } @@ -104,10 +112,9 @@ pub(crate) async fn edit( let duration = start.elapsed().as_micros(); info!("quo - read duration: {duration} μs"); Ok(Html( - Form { + EditForm { line_item: &line_item.into(), quote: "e.into(), - action: "update", error_message: None, } .to_string(), @@ -131,7 +138,7 @@ pub(crate) async fn update( info!("Quote total after update: {}", quote.total); let duration = start.elapsed().as_micros(); info!("quo - read duration: {duration} μs"); - Ok(TurboStream( + Ok(Html( Update { line_item: &line_item.into(), quote: "e.into(), @@ -149,19 +156,15 @@ pub(crate) async fn update( let duration = start.elapsed().as_micros(); info!("quo - read duration: {duration} μs"); let error_message = String::from("Test"); - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Html( - Form { - line_item: &form.into(), - quote: "e.into(), - action: "update", - error_message: Some(error_message), - } - .to_string(), - ), + Ok(Html( + EditForm { + line_item: &form.into(), + quote: "e.into(), + error_message: Some(error_message), + } + .to_string(), ) - .into_response()) + .into_response()) } } } @@ -178,9 +181,8 @@ pub(crate) async fn delete( let quote = quotes::query::from_line_item_date_id(&pool, &line_item.line_item_date_id).await?; let duration = start.elapsed().as_micros(); info!("quo - read duration: {duration} μs"); - Ok(TurboStream( + Ok(Html( Destroy { - line_item: &line_item.into(), quote: "e.into(), message: "Item was successfully destroyed.", } diff --git a/src/line_items/view.rs b/src/line_items/view.rs index 4dd2f38..7c5dd9b 100644 --- a/src/line_items/view.rs +++ b/src/line_items/view.rs @@ -1,10 +1,9 @@ -use crate::quotes::view::Footer; +use crate::quotes::view::SwapFooter; use crate::{layout::Flash, line_items::model::LineItemPresenter, quotes::model::QuotePresenter}; -use convert_case::{Case, Casing}; markup::define! { LineItem<'a>(line_item: &'a LineItemPresenter) { - $"turbo-frame"[id = &line_item.dom_id()] { + div[id = &line_item.dom_id()] { div[class = "flex flex-wrap items-start bg-white gap-2 mb-3 p-2 rounded-md"] { div[class = "flex-1 font-bold mb-0"] { @line_item.name @@ -19,27 +18,33 @@ markup::define! { @line_item.unit_price.format() } div[class = "flex flex-[0_0_10rem] order-[revert] gap-2"] { - form[method = "post", action = "/line_items/delete"] { + form["hx-post" = "/line_items/delete", + "hx-target" = {format!("#{}", &line_item.dom_id())}, + "hx-swap" = "delete"] { + input[id = "line_item_id", name = "id", "type" = "hidden", value = &line_item.id()] {} button[class = "button button-light", "type" = "submit"] {"Delete"} } - a[class = "button button-light", href = {format!("/line_items/edit/{}", &line_item.id())}] { "Edit" } + a[class = "button button-light", + "hx-get" = {format!("/line_items/edit/{}", &line_item.id())}, + "hx-target" = {format!("#{}", &line_item.dom_id())}, + "hx-trigger" = "click"] { "Edit" } } } } } - Form<'a>(line_item: &'a LineItemPresenter, - quote: &'a QuotePresenter, - action: &'a str, - error_message: Option) { - $"turbo-frame"[id = &line_item.dom_id()] { + EditForm<'a>(line_item: &'a LineItemPresenter, + quote: &'a QuotePresenter, + error_message: Option) { + div[id = &line_item.dom_id()] { form[id = &line_item.dom_id(), - action = {format!("/line_items/{}", action.to_case(Case::Flat))}, - method = "post", + "hx-post" = "/line_items/update", + "hx-target" = {format!("#{}", &line_item.dom_id())}, + "hx-swap" = "outerHTML", class = "flex flex-wrap items-start bg-white gap-2 mb-3 p-2 rounded-md", autocomplete = "off", "accept-charset" = "UTF-8"] { @@ -96,38 +101,118 @@ markup::define! { div[class = "basis-full order-2 m-w-100 font-normal text-[0.875rem] text-[hsl(0,1%,44%)] mb-0"] { textarea[id = "line_item_description", name = "description", + class = {format!("resize-none {}", form_input_class)}, + placeholder = "Description (optional)"] { @line_item.description } + } + a[class = "button button-light", + "hx-get" = {format!("/line_items/{}", &line_item.id())}, + "hx-target" = {format!("#{}", &line_item.dom_id())}, + "hx-trigger" = "click", + "hx-swap" = "outerHTML"] { "Cancel" } + input[name = "commit", + "type" = "submit", + value = "Update item", + class = "button button-secondary", + "_" = "on click add { pointer-events: none }"] {} + } + } + } + + NewForm<'a>(line_item: &'a LineItemPresenter, + quote: &'a QuotePresenter, + error_message: Option) { + div[id = &line_item.dom_id()] { + @let line_item_new_dom_id = format!("#line_item_date_{}_line_items", &line_item.line_item_date_id); + form[id = "form_new", + "hx-post" = "/line_items/create", + "hx-target" = line_item_new_dom_id, + "hx-swap" = "beforeend", + class = "flex flex-wrap items-start bg-white gap-2 mb-3 p-2 rounded-md", + autocomplete = "off", + "accept-charset" = "UTF-8"] { + @let form_input_class = if error_message.is_some() { "form-input border-primary" } else { "form-input" }; + @if let Some(message) = error_message { + div[class = "w-full text-primary bg-primary-bg p-2 rounded-md"] { @message } + } + @if let Some(id) = &line_item.id { + input[id = "line_item_id", + name = "id", + "type" = "hidden", + value = id] {} + } + input[id = "quote_id", + name = "quote_id", + "type" = "hidden", + value = "e.id] {} + input[id = "line_item_date_id", + name = "line_item_date_id", + "type" = "hidden", + value = &line_item.line_item_date_id] {} + div[class = "flex-1 font-bold mb-0"] { + input[id = "line_item_name", + name = "name", + class = form_input_class, + autofocus = "autofocus", + placeholder = "Name of your item", + required, + "type" = "text", + value = &line_item.name] {} + } + div[class = "block flex-[0_0_7rem] mb-0"] { + input[id = "line_item_quantity", + name = "quantity", + class = form_input_class, + placeholder = "1", + required, + "type" = "number", + min = "1", + step = "1", + value = &line_item.quantity] {} + } + div[class = "block flex-[0_0_9rem] mb-0"] { + input[id = "line_item_price", + name = "unit_price", class = form_input_class, + placeholder = "$100.00", + required, + "type" = "number", + min = "0.01", + step = "0.01", + value = &line_item.unit_price.to_string()] {} + } + div[class = "basis-full order-2 m-w-100 font-normal text-[0.875rem] text-[hsl(0,1%,44%)] mb-0"] { + textarea[id = "line_item_description", + name = "description", + class = {format!("resize-none {}", form_input_class)}, placeholder = "Description (optional)"] { @line_item.description } } - a[class = "button button-light", href = {format!("/quotes/show/{}", "e.id())}] { "Cancel" } - @let button_text = format!("{} item", action.to_case(Case::Title)); + a[class = "button button-light", + "_" = "on click remove #form_new"] { "Cancel" } input[name = "commit", "type" = "submit", - value = &button_text, + value = "Create item", class = "button button-secondary", - "data-disable-with" = &button_text] {} + "_" = "on click add { pointer-events: none }"] {} } } } Create<'a>(line_item: &'a LineItemPresenter, quote: &'a QuotePresenter, message: &'a str) { - @let line_items_dom_id = format!("line_item_date_{}_line_items", &line_item.line_item_date_id); @let line_item_new_dom_id = format!("line_item_date_{}_line_item_new", &line_item.line_item_date_id); - @markup::raw(hotwire_turbo::stream::append(&line_items_dom_id, LineItem{ line_item }.to_string())) - @markup::raw(hotwire_turbo::stream::update(&line_item_new_dom_id, "")) - @markup::raw(hotwire_turbo::stream::prepend("flash", Flash{ message: Some(message) }.to_string())) - @markup::raw(hotwire_turbo::stream::update("e.total_dom_id(), Footer{ quote }.to_string())) + @LineItem{ line_item } + div[id = &line_item_new_dom_id, "hx-swap-oob"="innerHTML"]{} + @Flash{ message } + @SwapFooter{ quote } } Update<'a>(line_item: &'a LineItemPresenter, quote: &'a QuotePresenter, message: &'a str) { - @markup::raw(hotwire_turbo::stream::replace(&line_item.dom_id(), LineItem{ line_item }.to_string())) - @markup::raw(hotwire_turbo::stream::prepend("flash", Flash{ message: Some(message) }.to_string())) - @markup::raw(hotwire_turbo::stream::update("e.total_dom_id(), Footer{ quote }.to_string())) + @LineItem{ line_item } + @Flash{ message } + @SwapFooter{ quote } } - Destroy<'a>(line_item: &'a LineItemPresenter, quote: &'a QuotePresenter, message: &'a str) { - @markup::raw(hotwire_turbo::stream::remove(line_item.dom_id())) - @markup::raw(hotwire_turbo::stream::prepend("flash", Flash{ message: Some(message) }.to_string())) - @markup::raw(hotwire_turbo::stream::update("e.total_dom_id(), Footer{ quote }.to_string())) + Destroy<'a>(quote: &'a QuotePresenter, message: &'a str) { + @Flash{ message } + @SwapFooter{ quote } } } diff --git a/src/main.rs b/src/main.rs index 795db8a..ddaa1f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,7 @@ async fn main() { let app = Router::new() .route("/", get(|| async { Redirect::to("/quotes") })) .route("/quotes", get(quotes::controller::index)) + .route("/quotes/:id", get(quotes::controller::quote)) .route("/quotes/show/:id", get(quotes::controller::show)) .route("/quotes/new", get(quotes::controller::new)) .route("/quotes/create", post(quotes::controller::create)) @@ -88,6 +89,10 @@ async fn main() { .route("/quotes/update", post(quotes::controller::update)) .route("/quotes/delete", post(quotes::controller::delete)) .route_service("/dist/*file", asset_handler.into_service()) + .route( + "/line_item_dates/:id", + get(line_item_dates::controller::line_item_date), + ) .route( "/line_item_dates/new/:quote_id", get(line_item_dates::controller::new), @@ -112,6 +117,7 @@ async fn main() { "/line_items/new/:line_item_date_id", get(line_items::controller::new), ) + .route("/line_items/:id", get(line_items::controller::line_item)) .route("/line_items/create", post(line_items::controller::create)) .route("/line_items/edit/:id", get(line_items::controller::edit)) .route("/line_items/update", post(line_items::controller::update)) @@ -120,7 +126,7 @@ async fn main() { .layer(trace_layer) .fallback_service(asset_handler.into_service()); - let addr: std::net::SocketAddr = "[::]:8080".parse().unwrap(); + let addr: std::net::SocketAddr = "[::]:8081".parse().unwrap(); info!("listening on {addr}"); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); diff --git a/src/quotes/controller.rs b/src/quotes/controller.rs index 20840ea..8e518c8 100644 --- a/src/quotes/controller.rs +++ b/src/quotes/controller.rs @@ -1,22 +1,20 @@ use crate::{ - layout::Layout, + layout::{Flash, Layout}, line_item_dates::{self, model::LineItemDatePresenter}, line_items::{self, model::LineItemPresenter}, quotes::{ self, model::{DeleteForm, QuoteForm, QuotePresenter}, - view::{Create, Destroy, Form, Index, Show, Update}, + view::{Create, EditForm, Index, NewForm, Quote, Show, Update}, }, Result, }; use axum::{ extract::{Path, State}, - http::StatusCode, response::{Html, IntoResponse}, }; use diesel::prelude::SqliteConnection; use diesel::r2d2::{ConnectionManager, Pool}; -use hotwire_turbo_axum::TurboStream; use itertools::Itertools; use std::time::Instant; use tracing::info; @@ -38,12 +36,27 @@ pub(crate) async fn index( head: markup::new! { title { "Quotes" } }, - body: Index { quotes: "es }, + body: Index { quotes }, }; Ok(Html(template.to_string())) } +pub(crate) async fn quote( + State(pool): State>>, + Path(id): Path, +) -> Result { + let start = Instant::now(); + let quote = quotes::query::read(&pool, &id).await?; + let duration = start.elapsed().as_micros(); + info!("quo - read duration: {duration} μs"); + + let quote = Quote { + quote: "e.into(), + }; + Ok(Html(quote.to_string())) +} + pub(crate) async fn show( State(pool): State>>, Path(id): Path, @@ -88,9 +101,8 @@ pub(crate) async fn show( pub(crate) async fn new() -> impl IntoResponse { Html( - Form { + NewForm { quote: &QuotePresenter::default(), - action: "create", error_message: None, } .to_string(), @@ -108,7 +120,7 @@ pub(crate) async fn create( let quote = quotes::query::insert(&pool, &form).await?; let duration = start.elapsed().as_micros(); info!("quo - insert duration: {duration} μs"); - Ok(TurboStream( + Ok(Html( Create { quote: "e.into(), message: "Quote was successfully created.", @@ -120,18 +132,14 @@ pub(crate) async fn create( Err(errors) => { info!("ValidationErrors:\n{:?}", errors); let error_message = String::from("Test"); - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Html( - Form { - quote: &form.into(), - action: "create", - error_message: Some(error_message), - } - .to_string(), - ), + Ok(Html( + NewForm { + quote: &form.into(), + error_message: Some(error_message), + } + .to_string(), ) - .into_response()) + .into_response()) } } } @@ -145,9 +153,8 @@ pub(crate) async fn edit( let duration = start.elapsed().as_micros(); info!("quo - read duration: {duration} μs"); Ok(Html( - Form { + EditForm { quote: "e.into(), - action: "update", error_message: None, } .to_string(), @@ -165,7 +172,7 @@ pub(crate) async fn update( let quote = quotes::query::update(&pool, &form).await?; let duration = start.elapsed().as_micros(); info!("quo - update duration: {duration} μs"); - Ok(TurboStream( + Ok(Html( Update { quote: "e.into(), message: "Quote was successfully updated.", @@ -177,18 +184,14 @@ pub(crate) async fn update( Err(errors) => { info!("ValidationErrors:\n{:?}", errors); let error_message = String::from("Test"); - Ok(( - StatusCode::UNPROCESSABLE_ENTITY, - Html( - Form { - quote: &form.into(), - action: "update", - error_message: Some(error_message), - } - .to_string(), - ), + Ok(Html( + EditForm { + quote: &form.into(), + error_message: Some(error_message), + } + .to_string(), ) - .into_response()) + .into_response()) } } } @@ -198,12 +201,11 @@ pub(crate) async fn delete( axum::Form(form): axum::Form, ) -> Result { let start = Instant::now(); - let quote = quotes::query::delete(&pool, &form.id).await?; + quotes::query::delete(&pool, &form.id).await?; let duration = start.elapsed().as_micros(); info!("quo - delete duration: {duration} μs"); - Ok(TurboStream( - Destroy { - quote: "e.into(), + Ok(Html( + Flash { message: "Quote was successfully destroyed.", } .to_string(), diff --git a/src/quotes/model.rs b/src/quotes/model.rs index 7798b1e..a6eaa92 100644 --- a/src/quotes/model.rs +++ b/src/quotes/model.rs @@ -49,7 +49,7 @@ pub(crate) struct QuoteForm { pub(crate) name: String, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct QuotePresenter { pub id: Option, pub name: String, @@ -67,10 +67,6 @@ impl QuotePresenter { pub fn dom_id(&self) -> String { format!("quote_{}", &self.id()) } - - pub fn total_dom_id(&self) -> String { - format!("quote_total_{}", &self.id()) - } } impl Default for QuotePresenter { diff --git a/src/quotes/view.rs b/src/quotes/view.rs index 5e22014..fd454f5 100644 --- a/src/quotes/view.rs +++ b/src/quotes/view.rs @@ -1,32 +1,36 @@ use crate::{ - layout, + layout::Flash, line_item_dates::{model::LineItemDatePresenter, view::LineItemDate}, line_items::model::LineItemPresenter, quotes::model::QuotePresenter, }; -use convert_case::{Case, Casing}; use std::collections::HashMap; markup::define! { - Index<'a>(quotes: &'a Vec) { + Index(quotes: Vec) { main[id = "container", class = "w-full px-4 py-0 mx-auto my-0 max-w-[60rem]"] { div[id = "header", class = "flex flex-wrap gap-3 justify-between mt-4 mb-8"] { h1[class = "text-header text-[2rem]/[1.1] box-border m-0 p-0 font-bold"] {"Quotes"} - a[class = "button button-prime", "data-turbo-frame" = "quote_new", href = "/quotes/new"] { "Add quote" } + a[class = "button button-prime", + "hx-get" = "/quotes/new", + "hx-target" = "#quote_new", + "hx-trigger" = "click", + "hx-swap" = "outerHTML"] { "Add quote" } } - $"turbo-frame"[id = "quote_new"] {} + div[id = "quote_new"] {} - $"turbo-frame"[id = "quotes"] { - div[class = "p-4 border-2 border-[hsl(0,6%,93%)] border-dashed text-center hidden only:[display:revert]"] { + div[id = "quotes"] { + div[id = "quotes_empty", class = "p-4 border-2 border-[hsl(0,6%,93%)] border-dashed text-center hidden only:[display:revert]"] { p[class = "[font-size:1.125rem] text-header mb-6 font-bold"] { "You don't have any quotes yet!" } - a[class = "button button-prime", "data-turbo-frame" = "quote_new", href = "/quotes/new"] { - "Add quote" - } + a[class = "button button-prime", + "hx-get" = "/quotes/new", + "hx-target" = "quote_new", + "hx-trigger" = "click"] { "Add quote" } } - @for quote in *quotes { + @for quote in quotes { @Quote { quote } } } @@ -34,18 +38,26 @@ markup::define! { } Quote<'a>(quote: &'a QuotePresenter) { - $"turbo-frame"[id = "e.dom_id()] { + div[id = "e.dom_id()] { div[class= "flex justify-between items-center gap-3 bg-white rounded-md mb-4 px-4 py-2 shadow-[1px_3px_6px_hsl(0,0%,0%,0.1)]"] { - a["data-turbo-frame" = "_top", href = {format!("/quotes/show/{}", "e.id())}] { @quote.name } + a[href = {format!("/quotes/show/{}", "e.id())}, + "hx-boost" = "true", + "hx-push-url" = "true", + "hx-history" = "false"] { @quote.name } div[class = "flex flex-auto grow-0 shrink-0 self-start gap-2"] { - form[method = "post", action = "/quotes/delete"] { + form["hx-post" = "/quotes/delete", + "hx-target" = {format!("#{}", "e.dom_id())}, + "hx-swap" = "delete"] { input[id = "quote_id", name = "id", "type" = "hidden", value = "e.id()] {} button[class = "button button-light", "type" = "submit"] {"Delete"} } - a[class = "button button-light", href = {format!("/quotes/edit/{}", "e.id())}] { "Edit" } + a[class = "button button-light", + "hx-get" = {format!("/quotes/edit/{}", "e.id())}, + "hx-target" = {format!("#{}", "e.dom_id())}, + "hx-trigger" = "click"] { "Edit" } } } } @@ -55,21 +67,24 @@ markup::define! { line_item_dates: &'a Vec, line_items: &'a HashMap>) { main[id = "container", class = "w-full px-4 py-0 mb-16 mx-auto my-0 max-w-[60rem]"] { - a[href = "/quotes"] { "← Back to quotes" } + a[href = "/quotes", + "hx-boost" = "true", + "hx-push-url" = "true", + "hx-history" = "false"] { "← Back to quotes" } div[class = "flex flex-wrap gap-3 justify-between mt-4 mb-8"] { h1[class = "text-header text-[2rem]/[1.1] m-0 p-0 font-bold"] { @quote.name } a[class = "button button-prime", - "data-turbo-frame" = "line_item_date_new", - href = {format!("/line_item_dates/new/{}", "e.id())}] { - "New date" - } + "hx-get" = {format!("/line_item_dates/new/{}", "e.id())}, + "hx-target" = "#line_item_date_new", + "hx-trigger" = "click", + "hx-swap" = "innerHTML"] { "New date" } } - $"turbo-frame"[id = "line_item_date_new"] {} + div[id = "line_item_date_new"] {} - $"turbo-frame"[id = "line_item_dates"] { + div[id = "line_item_dates"] { @for line_item_date in *line_item_dates { @let empty = Vec::new(); @let line_items = line_items.get(&line_item_date.id()).unwrap_or(&empty); @@ -78,17 +93,19 @@ markup::define! { } } - @Footer { quote } + @InitialFooter { quote } } - Form<'a>(quote: &'a QuotePresenter, action: &'a str, error_message: Option) { - $"turbo-frame"[id = "e.dom_id()] { - form[id = "e.dom_id(), - action = {format!("/quotes/{}", action)}, - method = "post", + EditForm<'a>(quote: &'a QuotePresenter, error_message: Option) { + div[id = "e.dom_id()] { + form[id = format!("form_{}", "e.id()), + "hx-post" = "/quotes/update", + "hx-target" = {format!("#{}", "e.dom_id())}, + "hx-swap" = "outerHTML", class = "flex flex-wrap justify-between items-center gap-3 bg-white rounded-md mb-4 px-4 py-2 shadow-[1px_3px_6px_hsl(0,0%,0%,0.1)]", autocomplete = "off", "accept-charset" = "UTF-8"] { + @let form_input_class = if error_message.is_some() { "form-input border-primary" } else { "form-input" }; @if let Some(message) = error_message { div[class = "w-full text-primary bg-primary-bg p-2 rounded-md"] { @message } @@ -110,41 +127,90 @@ markup::define! { "type" = "text", value = "e.name] {} } - a[class = "button button-light", href="/quotes"] { "Cancel" } - @let button_text = format!("{} quote", action.to_case(Case::Title)); + a[class = "button button-light", + "hx-get" = {format!("/quotes/{}", "e.id())}, + "hx-target" = {format!("#{}", "e.dom_id())}, + "hx-trigger" = "click"] { "Cancel" } input[name = "commit", "type" = "submit", - value = &button_text, + value = "Update quote", class = "button button-secondary", - "data-disable-with" = &button_text] {} + "_" = "on click add { pointer-events: none }"] {} } } } - Footer<'a>(quote: &'a QuotePresenter) { - $"turbo-frame"[id = "e.total_dom_id()] { - footer[class = "fixed bottom-0 w-full py-4 text-[1.25rem] font-bold bg-white shadow-[2px_4px_10px_hsl(0,0%,0%,0.1)]"] { - div[class = "flex items-center justify-between w-full px-4 mx-auto max-w-[60rem]"] { - div { "Total:" } - div { @quote.total.format() } + NewForm<'a>(quote: &'a QuotePresenter, error_message: Option) { + div[id = "e.dom_id()] { + form[id = "form_new", + "hx-post" = "/quotes/create", + "hx-target" = "#quotes_empty", + "hx-swap" = "afterend", + class = "flex flex-wrap justify-between items-center gap-3 bg-white rounded-md mb-4 px-4 py-2 shadow-[1px_3px_6px_hsl(0,0%,0%,0.1)]", + autocomplete = "off", + "accept-charset" = "UTF-8"] { + + @let form_input_class = if error_message.is_some() { "form-input border-primary" } else { "form-input" }; + @if let Some(message) = error_message { + div[class = "w-full text-primary bg-primary-bg p-2 rounded-md"] { @message } } + div[class = "[flex:1]"] { + @if let Some(id) = "e.id { + input[id = "quote_id", + name = "id", + "type" = "hidden", + value = id] {} + } + label[class = "visually-hidden", "for" = "quote_name"] { "Name" } + input[id = "quote_name", + name = "name", + class = form_input_class, + autofocus = "autofocus", + placeholder = "Name of your quote", + required, + "type" = "text", + value = "e.name] {} + } + a[class = "button button-light", + "_" = "on click remove #form_new"] { "Cancel" } + input[name = "commit", + "type" = "submit", + value = "Create quote", + class = "button button-secondary", + "_" = "on click add { pointer-events: none }"] {} } } } - Create<'a>(quote: &'a QuotePresenter, message: &'a str) { - @markup::raw(hotwire_turbo::stream::prepend("quotes", Quote{ quote }.to_string())) - @markup::raw(hotwire_turbo::stream::update("quote_new", "")) - @markup::raw(hotwire_turbo::stream::prepend("flash", layout::Flash{ message: Some(message) }.to_string())) + Footer<'a>(quote: &'a QuotePresenter) { + footer[class = "fixed bottom-0 w-full py-4 text-[1.25rem] font-bold bg-white shadow-[2px_4px_10px_hsl(0,0%,0%,0.1)]"] { + div[class = "flex items-center justify-between w-full px-4 mx-auto max-w-[60rem]"] { + div { "Total:" } + div { @quote.total.format() } + } + } } - Update<'a>(quote: &'a QuotePresenter, message: &'a str) { - @markup::raw(hotwire_turbo::stream::replace("e.dom_id(), Quote{ quote }.to_string())) - @markup::raw(hotwire_turbo::stream::prepend("flash", layout::Flash{ message: Some(message) }.to_string())) + InitialFooter<'a>(quote: &'a QuotePresenter) { + div[id = "quote_total_footer"] { + @Footer{ quote } + } } - Destroy<'a>(quote: &'a QuotePresenter, message: &'a str) { - @markup::raw(hotwire_turbo::stream::remove(quote.dom_id())) - @markup::raw(hotwire_turbo::stream::prepend("flash", layout::Flash{ message: Some(message) }.to_string())) + SwapFooter<'a>(quote: &'a QuotePresenter) { + div[id = "quote_total_footer", "hx-swap-oob" = "true"] { + @Footer{ quote } + } + } + + Create<'a>(quote: &'a QuotePresenter, message: &'a str) { + @Quote{ quote } + div[id = "quote_new", "hx-swap-oob"="innerHTML"]{} + @Flash{ message } + } + + Update<'a>(quote: &'a QuotePresenter, message: &'a str) { + @Quote{ quote } + @Flash{ message } } } diff --git a/ui/src/app.js b/ui/src/app.js index ed75175..fdfd8c3 100644 --- a/ui/src/app.js +++ b/ui/src/app.js @@ -1,21 +1,16 @@ -// noinspection ES6UnusedImports -import * as Turbo from "@hotwired/turbo" -import { Application } from "@hotwired/stimulus" +import "htmx.org"; +import * as hyperscript from "hyperscript.org"; +hyperscript.browserInit(); -import RemovalsController from "./controllers/removals_controller" - -window.Stimulus = Application.start() -Stimulus.register("removals", RemovalsController) - -document.addEventListener("turbo:before-frame-render", (event) => { - const inputs = event.detail.newFrame.querySelectorAll("input, select, textarea"); - inputs.forEach(input => { - input.addEventListener( - "invalid", - _event => { - input.classList.add("error"); - }, - false - ); - }); -}) \ No newline at end of file +// document.addEventListener("turbo:before-frame-render", (event) => { +// const inputs = event.detail.newFrame.querySelectorAll("input, select, textarea"); +// inputs.forEach(input => { +// input.addEventListener( +// "invalid", +// _event => { +// input.classList.add("error"); +// }, +// false +// ); +// }); +// }) \ No newline at end of file From f4dd05e8ab7ed9b3dd06d11fd50207bdd9d41b66 Mon Sep 17 00:00:00 2001 From: John Bledsoe <59550+johnbcodes@users.noreply.github.com> Date: Thu, 14 Dec 2023 09:09:40 -0500 Subject: [PATCH 2/2] feat(docs): Update README to reflect recent changes and add minor clarifications --- README.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a9b9e52..0c12e21 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,23 @@ ### Motivation and caveats -The primary motivation was to learn more about how [Hotwire Turbo](https://turbo.hotwired.dev/). Since -no particular backend is required I wanted to see what it would take to integrate with Rust. A secondary -motivation was a chance to investigate the following custom stack: +The main motivation is learning to develop web applications with Rust and JavaScript combined. It now includes +the following stack: +* [htmx](https://htmx.org/) +* [hyperscript](https://hyperscript.org/) * [Axum](https://github.com/tokio-rs/axum) -* [Rusqlite](https://github.com/rusqlite/rusqlite) +* [Diesel](https://diesel.rs/) * [markup.rs](https://github.com/utkarshkukreti/markup.rs) -* Rust / NPM web builds/tooling +* Custom Rust / NPM build integration * [Tailwind](https://tailwindcss.com/) -* Other important crates -Due to the motivations above and a lack of time some features of the tutorial were left out: +In the past it included these technologies: + +* [Hotwire Turbo](https://turbo.hotwired.dev/) +* [Rusqlite](https://github.com/rusqlite/rusqlite) + +Some features of the tutorial were intentionally left out and possibly will be worked on in the future: * Broadcasting with WebSockets (Chapter 5) * Security (Chapter 6) @@ -24,7 +29,7 @@ Additionally, there were some other features and integral parts of Rails that ha * The look and feel deviates from [demo](https://www.hotrails.dev/quotes) because the author has made some UI enhancements that are not in the tutorial * Viewports less than tablet sizing -* "to_sentence" on ValidationErrors struct for flash message +* Proper validation error messages ("to_sentence" on ValidationErrors struct for flash message) * Only add border color to fields with errors * Labels for input fields * Delete confirmation