From edd9b94fb46a7b5d0000239270cb434e0ca8d2e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 29 Aug 2023 21:34:00 +0200 Subject: [PATCH] refactoring --- rust/query.sql | 84 +++++ rust/sqlx-data.json | 86 +++++- rust/src/components/inventory.rs | 96 +++--- rust/src/components/mod.rs | 1 - rust/src/components/trip/mod.rs | 514 +++++++++++++++++-------------- rust/src/main.rs | 439 ++++++++++++++++++++------ rust/src/models.rs | 121 +++++++- 7 files changed, 951 insertions(+), 390 deletions(-) create mode 100644 rust/query.sql diff --git a/rust/query.sql b/rust/query.sql new file mode 100644 index 0000000..b09655b --- /dev/null +++ b/rust/query.sql @@ -0,0 +1,84 @@ +/* SELECT */ +/* t_item.item_id AS id, */ +/* t_item.pick AS picked, */ +/* t_item.pack AS packed, */ +/* t_item.new AS new, */ +/* i_item.name AS name, */ +/* i_item.description AS description, */ +/* i_item.weight AS weight, */ +/* i_item.category_id AS category_id */ +/* FROM trips_items AS t_item */ +/* INNER JOIN inventory_items AS i_item */ +/* ON i_item.id = t_item.item_id */ +/* WHERE t_item.item_id = '7f492a29-5bc9-4e20-b4cf-445c5ac444fc' */ +/* AND t_item.trip_id = '0535193c-7b47-4ba4-bca5-40e54c15c2d0'; */ + +/* SELECT */ +/* COALESCE(MAX(i_item.weight), 0) AS weight, */ +/* COUNT(i_item.weight) AS found, */ +/* IFNULL(i_item.weight, 'IT IS NULL') AS found2 */ +/* FROM inventory_items_categories as category */ +/* INNER JOIN inventory_items as i_item */ +/* ON i_item.category_id = category.id */ +/* WHERE category_id = ( */ +/* SELECT category_id */ +/* FROM inventory_items */ +/* /1* WHERE inventory_items.id = '7f492a29-5bc9-4e20-b4cf-445c5ac444fc' *1/ */ +/* WHERE inventory_items.id = '69147a37-cc4e-416b-b8d5-d65017f12184' */ +/* ) */ + +/* SELECT */ +/* category.id as category_id, */ +/* category.name as category_name, */ +/* category.description AS category_description, */ +/* inner.trip_id AS trip_id, */ +/* inner.item_id AS item_id, */ +/* inner.item_name AS item_name, */ +/* inner.item_description AS item_description, */ +/* inner.item_weight AS item_weight, */ +/* inner.item_is_picked AS item_is_picked, */ +/* inner.item_is_packed AS item_is_packed, */ +/* inner.item_is_new AS item_is_new */ +/* FROM inventory_items_categories AS category */ +/* LEFT JOIN ( */ +/* SELECT */ +/* trip.trip_id AS trip_id, */ +/* category.id as category_id, */ +/* category.name as category_name, */ +/* category.description as category_description, */ +/* item.id as item_id, */ +/* item.name as item_name, */ +/* item.description as item_description, */ +/* item.weight as item_weight, */ +/* trip.pick as item_is_picked, */ +/* trip.pack as item_is_packed, */ +/* trip.new as item_is_new */ +/* FROM trips_items as trip */ +/* INNER JOIN inventory_items as item */ +/* ON item.id = trip.item_id */ +/* INNER JOIN inventory_items_categories as category */ +/* ON category.id = item.category_id */ +/* WHERE trip.trip_id = '0535193c-7b47-4ba4-bca5-40e54c15c2d0' */ +/* ) AS inner */ +/* ON inner.category_id = category.id */ +/* WHERE category.id = '1293c6b6-eef5-4269-bf10-a1ac20549dac' */ + +SELECT + trip.id AS id, + trip.name AS name, + CAST (date_start AS TEXT) date_start, + CAST (date_end AS TEXT) date_end, + state, + location, + temp_min, + temp_max, + comment, + SUM(i_item.weight) AS total_weight +FROM trips AS trip +INNER JOIN trips_items AS t_item + ON t_item.trip_id = trip.id +INNER JOIN inventory_items AS i_item + ON t_item.item_id = i_item.id +WHERE + trip.id = '0535193c-7b47-4ba4-bca5-40e54c15c2d0' + AND t_item.pick = true diff --git a/rust/sqlx-data.json b/rust/sqlx-data.json index 83f0e18..c330a39 100644 --- a/rust/sqlx-data.json +++ b/rust/sqlx-data.json @@ -404,6 +404,72 @@ }, "query": "INSERT INTO trips\n (id, name, date_start, date_end, state)\n VALUES\n (?, ?, ?, ?, ?)" }, + "999fe09a6a095ac0ee7b3e3c38a6f2008641e03f9344f31bf9f8eb16a47403da": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "date_start", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "date_end", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "state", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "location", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "temp_min", + "ordinal": 6, + "type_info": "Int64" + }, + { + "name": "temp_max", + "ordinal": 7, + "type_info": "Int64" + }, + { + "name": "comment", + "ordinal": 8, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true + ], + "parameters": { + "Right": 1 + } + }, + "query": "SELECT\n id,\n name,\n CAST (date_start AS TEXT) date_start,\n CAST (date_end AS TEXT) date_end,\n state,\n location,\n temp_min,\n temp_max,\n comment\n FROM trips\n WHERE id = ?" + }, "a81bcbeb11260e3b4363e19c26b71b489e326b08bfacb6e11b4c4fc068dc7806": { "describe": { "columns": [ @@ -444,35 +510,23 @@ }, "query": "DELETE FROM inventory_items\n WHERE id = ?" }, - "cc1ad49669cff7f89975abfab3d0a8caef2e3978c826e1877db91c05a7f9d00d": { + "cc70d7a392a0283fec1896acba805f5c2a527537b8faa22d1c69306017b9c465": { "describe": { "columns": [ { - "name": "id", + "name": "total_weight", "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "description", - "ordinal": 2, - "type_info": "Text" + "type_info": "Int" } ], "nullable": [ - false, - false, true ], "parameters": { "Right": 1 } }, - "query": "SELECT\n id,\n name,\n description\n FROM inventory_items_categories AS category\n WHERE category.id = ?" + "query": "\n SELECT\n CAST(IFNULL(SUM(i_item.weight), 0) AS INTEGER) AS total_weight\n FROM trips AS trip\n INNER JOIN trips_items AS t_item\n ON t_item.trip_id = trip.id\n INNER JOIN inventory_items AS i_item\n ON t_item.item_id = i_item.id\n WHERE\n trip.id = ?\n AND t_item.pick = true\n " }, "d0562ad92782a6ad6080c0535749c4a0a28fa78a17698933bce670db057e2628": { "describe": { diff --git a/rust/src/components/inventory.rs b/rust/src/components/inventory.rs index ee90095..12c4e1a 100644 --- a/rust/src/components/inventory.rs +++ b/rust/src/components/inventory.rs @@ -8,37 +8,36 @@ use uuid::{uuid, Uuid}; pub struct Inventory; impl Inventory { - pub fn build(state: ClientState, categories: Vec) -> Result { - let doc = html!( + pub fn build( + active_category: Option<&Category>, + categories: &Vec, + edit_item_id: Option, + ) -> Markup { + html!( div id="pkglist-item-manager" { div ."p-8" ."grid" ."grid-cols-4" ."gap-5" { div ."col-span-2" ."flex" ."flex-col" ."gap-8" { h1 ."text-2xl" ."text-center" { "Categories" } - (InventoryCategoryList::build(&state, &categories)) + (InventoryCategoryList::build(active_category, categories)) (InventoryNewCategoryForm::build()) } div ."col-span-2" ."flex" ."flex-col" ."gap-8" { h1 ."text-2xl" ."text-center" { "Items" } - @if let Some(active_category_id) = state.active_category_id { - (InventoryItemList::build(&state, categories.iter().find(|category| category.id == active_category_id) - .ok_or(Error::NotFound{ description: format!("no category with id {}", active_category_id) })? - .items()) - ) + @if let Some(active_category) = active_category { + (InventoryItemList::build(edit_item_id, active_category.items())) } - (InventoryNewItemForm::build(&state, &categories)) + (InventoryNewItemForm::build(active_category, &categories)) } } } - ); - - Ok(doc) + ) } } pub struct InventoryCategoryList; impl InventoryCategoryList { - pub fn build(state: &ClientState, categories: &Vec) -> Markup { + pub fn build(active_category: Option<&Category>, categories: &Vec) -> Markup { let biggest_category_weight: i64 = categories .iter() .map(Category::total_weight) @@ -68,10 +67,10 @@ impl InventoryCategoryList { } tbody { @for category in categories { - @let active = state.active_category_id.map_or(false, |id| category.id == id); + @let active = active_category.map_or(false, |c| category.id == c.id); tr ."h-10" - ."hover:bg-purple-100" + ."hover:bg-gray-100" ."m-3" ."h-full" ."outline"[active] @@ -81,7 +80,7 @@ impl InventoryCategoryList { { td - class=@if state.active_category_id.map_or(false, |id| category.id == id) { + class=@if active_category.map_or(false, |c| category.id == c.id) { "border p-0 m-0 font-bold" } @else { "border p-0 m-0" @@ -125,7 +124,7 @@ impl InventoryCategoryList { } } } - tr ."h-10" ."hover:bg-purple-200" ."bg-gray-300" ."font-bold" { + tr ."h-10" ."bg-gray-300" ."font-bold" { td ."border" ."p-0" ."m-0" { p ."p-2" ."m-2" { "Sum" } } @@ -144,18 +143,18 @@ impl InventoryCategoryList { pub struct InventoryItemList; impl InventoryItemList { - pub fn build(state: &ClientState, items: &Vec) -> Markup { + pub fn build(edit_item_id: Option, items: &Vec) -> Markup { let biggest_item_weight: i64 = items.iter().map(|item| item.weight).max().unwrap_or(1); html!( div #items { @if items.is_empty() { p ."text-lg" ."text-center" ."py-5" ."text-gray-400" { "[Empty]" } } @else { - @if let Some(edit_item) = state.edit_item { + @if let Some(edit_item_id) = edit_item_id { form name="edit-item" id="edit-item" - action=(format!("/inventory/item/{edit_item}/edit")) + action={"/inventory/item/" (edit_item_id) "/edit"} target="_self" method="post" {} @@ -163,7 +162,7 @@ impl InventoryItemList { table ."table" ."table-auto" - .table-fixed + ."table-fixed" ."border-collapse" ."border-spacing-0" ."border" @@ -179,7 +178,7 @@ impl InventoryItemList { } tbody { @for item in items { - @if state.edit_item.map_or(false, |edit_item| edit_item == item.id) { + @if edit_item_id.map_or(false, |id| id == item.id) { tr ."h-10" { td ."border" ."bg-blue-300" ."px-2" ."py-0" { div ."h-full" ."w-full" ."flex" { @@ -251,7 +250,7 @@ impl InventoryItemList { } } } @else { - tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" { + tr ."h-10" { td ."border" ."p-0" { a ."p-2" ."w-full" ."inline-block" @@ -321,6 +320,17 @@ pub struct InventoryNewItemFormName; impl InventoryNewItemFormName { pub fn build(value: Option<&str>, error: bool) -> Markup { html!( + script { + (PreEscaped(" + function inventory_new_item_check_input() { + return document.getElementById('new-item-name').value.length != 0 + && is_positive_integer(document.getElementById('new-item-weight').value) + } + function check_weight() { + return document.getElementById('new-item-weight').validity.valid; + } + ")) + } div ."grid" ."grid-cols-[2fr,3fr]" @@ -328,7 +338,7 @@ impl InventoryNewItemFormName { ."items-center" hx-post="/inventory/item/name/validate" hx-trigger="input delay:1s, loaded from:document" - + hx-target="this" hx-params="new-item-name" hx-swap="outerHTML" #abc @@ -349,7 +359,7 @@ impl InventoryNewItemFormName { ."rounded" ."focus:outline-none" ."focus:bg-white" - ."focus:border-purple-500"[!error] + ."focus:border-gray-500"[!error] value=[value] {} @if error { @@ -369,6 +379,17 @@ pub struct InventoryNewItemFormWeight; impl InventoryNewItemFormWeight { pub fn build() -> Markup { html!( + script { + (PreEscaped(" + function inventory_new_item_check_input() { + return document.getElementById('new-item-name').value.length != 0 + && is_positive_integer(document.getElementById('new-item-weight').value) + } + function check_weight() { + return document.getElementById('new-item-weight').validity.valid; + } + ")) + } div ."grid" ."grid-cols-[2fr,3fr]" @@ -385,7 +406,7 @@ impl InventoryNewItemFormWeight { save_active = inventory_new_item_check_input(); weight_error = !check_weight(); }" - x-bind:class="weight_error && 'border-red-500' || 'border-gray-300 focus:border-purple-500'" + x-bind:class="weight_error && 'border-red-500' || 'border-gray-300 focus:border-gray-500'" ."block" ."w-full" ."p-2" @@ -410,7 +431,7 @@ impl InventoryNewItemFormWeight { pub struct InventoryNewItemFormCategory; impl InventoryNewItemFormCategory { - pub fn build(state: &ClientState, categories: &Vec) -> Markup { + pub fn build(active_category: Option<&Category>, categories: &Vec) -> Markup { html!( div ."grid" @@ -431,11 +452,11 @@ impl InventoryNewItemFormCategory { ."rounded" ."focus:outline-none" ."focus:bg-white" - ."focus:border-purple-500" + ."focus:border-gray-500" autocomplete="off" // https://stackoverflow.com/a/10096033 { @for category in categories { - option value=(category.id) selected[state.active_category_id.map_or(false, |id| id == category.id)] { + option value=(category.id) selected[active_category.map_or(false, |c| c.id == category.id)] { (category.name) } } @@ -448,7 +469,7 @@ impl InventoryNewItemFormCategory { pub struct InventoryNewItemForm; impl InventoryNewItemForm { - pub fn build(state: &ClientState, categories: &Vec) -> Markup { + pub fn build(active_category: Option<&Category>, categories: &Vec) -> Markup { html!( script { (PreEscaped(" @@ -467,6 +488,9 @@ impl InventoryNewItemForm { weight_error: !check_weight(), }" name="new-item" + hx-post="/inventory/item/" + hx-swap="outerHTML" + hx-target="#pkglist-item-manager" id="new-item" action="/inventory/item/" target="_self" @@ -479,7 +503,7 @@ impl InventoryNewItemForm { div ."w-11/12" ."mx-auto" ."flex" ."flex-col" ."gap-8" { (InventoryNewItemFormName::build(None, false)) (InventoryNewItemFormWeight::build()) - (InventoryNewItemFormCategory::build(state, categories)) + (InventoryNewItemFormCategory::build(active_category, categories)) input type="submit" value="Add" x-bind:disabled="!save_active" ."enabled:cursor-pointer" @@ -530,7 +554,7 @@ impl InventoryNewCategoryForm { ."rounded" ."focus:outline-none" ."focus:bg-white" - ."focus:border-purple-500" + ."focus:border-gray-500" { } } @@ -568,19 +592,19 @@ impl InventoryItem { ."w-full" { tbody { - tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" { + tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" { td ."border" ."p-2" { "Name" } td ."border" ."p-2" { (item.name) } } - tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" { + tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" { td ."border" ."p-2" { "Description" } td ."border" ."p-2" { (item.description.clone().unwrap_or("".to_string())) } } - tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" { + tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" { td ."border" ."p-2" { "Weight" } td ."border" ."p-2" { (item.weight.to_string()) } } - tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" { + tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" { td ."border" ."p-2" { "Category" } td ."border" ."p-2" { (item.category.name) } } diff --git a/rust/src/components/mod.rs b/rust/src/components/mod.rs index a204286..e817d9e 100644 --- a/rust/src/components/mod.rs +++ b/rust/src/components/mod.rs @@ -33,7 +33,6 @@ impl Root { meta name="htmx-config" content=r#"{"useTemplateFragments":true}"# {} } body - hx-boost="true" { header #header diff --git a/rust/src/components/trip/mod.rs b/rust/src/components/trip/mod.rs index fd11ab8..c451727 100644 --- a/rust/src/components/trip/mod.rs +++ b/rust/src/components/trip/mod.rs @@ -1,5 +1,5 @@ -use crate::models; use crate::models::*; +use crate::{models, HtmxEvents}; use maud::{html, Markup, PreEscaped}; use uuid::Uuid; @@ -15,7 +15,13 @@ pub use types::*; impl TripManager { pub fn build(trips: Vec) -> Markup { html!( - div ."p-8" { + div + ."p-8" + ."flex" + ."flex-col" + ."gap-8" + { + h1 ."text-2xl" {"Trips"} (TripTable::build(trips)) (NewTrip::build()) } @@ -55,7 +61,6 @@ pub struct TripTable; impl TripTable { pub fn build(trips: Vec) -> Markup { html!( - h1 ."text-2xl" ."mb-5" {"Trips"} table ."table" ."table-auto" @@ -75,7 +80,7 @@ impl TripTable { } tbody { @for trip in trips { - tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" { + tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" { (TripTableRow::build(trip.id, &trip.name)) (TripTableRow::build(trip.id, trip.date_start.to_string())) (TripTableRow::build(trip.id, trip.date_end.to_string())) @@ -118,7 +123,7 @@ impl NewTrip { action="/trips/" target="_self" method="post" - ."mt-8" ."p-5" ."border-2" ."border-gray-200" + ."p-5" ."border-2" ."border-gray-200" { div ."mb-5" ."flex" ."flex-row" ."trips-center" { span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {} @@ -210,83 +215,110 @@ impl NewTrip { pub struct Trip; impl Trip { - pub fn build(state: &ClientState, trip: &models::Trip) -> Result { - Ok(html!( + pub fn build( + trip: &models::Trip, + trip_edit_attribute: Option, + active_category: Option<&TripCategory>, + ) -> Markup { + html!( div ."p-8" ."flex" ."flex-col" ."gap-8" { - div ."flex" ."flex-row" ."items-center" ."gap-x-3" { - @if state.trip_edit_attribute.as_ref().map_or(false, |a| *a == TripAttribute::Name) { - form - id="edit-trip" - action=(format!("edit/{}/submit", to_variant_name(&TripAttribute::Name).unwrap())) - target="_self" - method="post" + div + ."flex" + ."flex-row" + ."items-stretch" + ."gap-x-5" + { + a + href="/trips/" + ."text-sm" + ."text-gray-500" + ."flex" + { + div + ."m-auto" { - div - ."flex" - ."flex-row" - ."items-center" - ."gap-x-3" - ."items-stretch" + span + ."mdi" + ."mdi-arrow-left" + {} + "back" + } + } + div ."flex" ."flex-row" ."items-center" ."gap-x-3" { + @if trip_edit_attribute.as_ref().map_or(false, |a| *a == TripAttribute::Name) { + form + id="edit-trip" + action=(format!("edit/{}/submit", to_variant_name(&TripAttribute::Name).unwrap())) + target="_self" + method="post" { - input - ."bg-blue-200" - ."w-full" - ."text-2xl" - ."font-semibold" - type=(>::into(InputType::Text)) - name="new-value" - form="edit-trip" - value=(trip.name) - {} - a - href="." - ."bg-red-200" - ."hover:bg-red-300" - ."w-8" + div ."flex" + ."flex-row" + ."items-center" + ."gap-x-3" + ."items-stretch" { - span - ."mdi" - ."mdi-cancel" - ."text-xl" - ."m-auto" - {} - } - button - type="submit" - form="edit-trip" - ."bg-green-200" - ."hover:bg-green-300" - ."w-8" - { - span - ."mdi" - ."mdi-content-save" - ."text-xl" + input + ."bg-blue-200" + ."w-full" + ."text-2xl" + ."font-semibold" + type=(>::into(InputType::Text)) + name="new-value" + form="edit-trip" + value=(trip.name) {} + a + href="." + ."bg-red-200" + ."hover:bg-red-300" + ."w-8" + ."flex" + { + span + ."mdi" + ."mdi-cancel" + ."text-xl" + ."m-auto" + {} + } + button + type="submit" + form="edit-trip" + ."bg-green-200" + ."hover:bg-green-300" + ."w-8" + { + span + ."mdi" + ."mdi-content-save" + ."text-xl" + {} + } } } - } - } @else { - h1 ."text-2xl" ."font-semibold"{ (trip.name) } - span { - a href=(format!("?edit={}", to_variant_name(&TripAttribute::Name).unwrap())) - { - span - ."mdi" - ."mdi-pencil" - ."text-xl" - ."opacity-50" - {} + } @else { + h1 ."text-2xl" { (trip.name) } + span { + a href=(format!("?edit={}", to_variant_name(&TripAttribute::Name).unwrap())) + { + span + ."mdi" + ."mdi-pencil" + ."text-xl" + ."opacity-50" + {} + } } } } } - (TripInfo::build(state, trip)) + (TripInfo::build(trip_edit_attribute, trip)) (TripComment::build(trip)) - (TripItems::build(state, trip)?) + (TripItems::build(active_category, trip)) } - )) + ) } } @@ -299,7 +331,6 @@ impl TripInfoRow { attribute_key: &TripAttribute, edit_attribute: Option<&TripAttribute>, input_type: InputType, - has_two_columns: bool, ) -> Markup { let edit = edit_attribute.map_or(false, |a| a == attribute_key); html!( @@ -327,58 +358,66 @@ impl TripInfoRow { } } td - ."border-none" - ."bg-red-100" - ."hover:bg-red-200" + ."border" + ."border-solid" + ."border-gray-300" ."p-0" ."h-full" - ."w-8" { - a - ."aspect-square" + div ."flex" - ."w-full" + ."flex-row" + ."items-stretch" ."h-full" - ."p-0" - href="." // strips query parameters { - span - ."m-auto" - ."mdi" - ."mdi-cancel" - ."text-xl" - {} - } - } - td - ."border-none" - ."bg-green-100" - ."hover:bg-green-200" - ."p-0" - ."h-full" - ."w-8" - { - button - ."aspect-square" - ."flex" - ."w-full" - ."h-full" - type="submit" - form="edit-trip" - { - span - ."m-auto" - ."mdi" - ."mdi-content-save" - ."text-xl" - {} + div + ."bg-red-100" + ."hover:bg-red-200" + ."w-8" + ."h-full" + { + a + ."flex" + ."w-full" + ."h-full" + ."p-0" + href="." // strips query parameters + { + span + ."m-auto" + ."mdi" + ."mdi-cancel" + ."text-xl" + {} + } + } + div + ."bg-green-100" + ."hover:bg-green-200" + ."w-8" + ."h-full" + { + button + ."flex" + ."w-full" + ."h-full" + type="submit" + form="edit-trip" + { + span + ."m-auto" + ."mdi" + ."mdi-content-save" + ."text-xl" + {} + } + } } } } @else { td ."border" ."p-2" { (name) } td ."border" ."p-2" { (value.map_or(String::new(), |v| v.to_string())) } td - colspan=(if has_two_columns {"2"} else {"1"}) ."border-none" ."bg-blue-100" ."hover:bg-blue-200" @@ -406,79 +445,64 @@ impl TripInfoRow { } } -pub struct TripInfo; +pub struct TripInfoTotalWeightRow; -impl TripInfo { - pub fn build(state: &ClientState, trip: &models::Trip) -> Markup { - let has_two_columns = - state.trip_edit_attribute.is_some() || !(trip.state.is_first() || trip.state.is_last()); +impl TripInfoTotalWeightRow { + pub fn build(trip_id: Uuid, value: i64) -> Markup { html!( - table - ."table" - ."table-auto" - ."border-collapse" - ."border-spacing-0" - ."border" - ."w-full" + span + hx-trigger={ + (HtmxEvents::TripItemEdited.to_str()) " from:body" + } + hx-get={"/trips/" (trip_id) "/total_weight"} { - tbody { - (TripInfoRow::build("Location", - trip.location.as_ref(), - &TripAttribute::Location, - state.trip_edit_attribute.as_ref(), - InputType::Text, - has_two_columns - )) - (TripInfoRow::build("Start date", - Some(trip.date_start), - &TripAttribute::DateStart, - state.trip_edit_attribute.as_ref(), - InputType::Date, - has_two_columns - )) - (TripInfoRow::build("End date", - Some(trip.date_end), - &TripAttribute::DateEnd, - state.trip_edit_attribute.as_ref(), - InputType::Date, - has_two_columns - )) - (TripInfoRow::build("Temp (min)", - trip.temp_min, - &TripAttribute::TempMin, - state.trip_edit_attribute.as_ref(), - InputType::Number, - has_two_columns - )) - (TripInfoRow::build("Temp (max)", - trip.temp_max, - &TripAttribute::TempMax, - state.trip_edit_attribute.as_ref(), - InputType::Number, - has_two_columns - )) - tr .h-full { - td ."border" ."p-2" { "State" } - td ."border" { - span .flex .flex-row .items-center .justify-start ."gap-2" { - span ."mdi" .(trip_state_icon(&trip.state)) ."text-2xl" ."pl-2" {} - span ."pr-2" ."py-2" { (trip.state) } - } - } - @let prev_state = trip.state.prev(); - @let next_state = trip.state.next(); + (value) + } + ) + } +} +pub struct TripInfoStateRow; + +impl TripInfoStateRow { + pub fn build(trip_state: &models::TripState) -> Markup { + let prev_state = trip_state.prev(); + let next_state = trip_state.next(); + html!( + tr .h-full { + td ."border" ."p-2" { "State" } + td ."border" { + span .flex .flex-row .items-center .justify-start ."gap-2" { + span ."mdi" .(trip_state_icon(&trip_state)) ."text-2xl" ."pl-2" {} + span ."pr-2" ."py-2" { (trip_state) } + } + } + + td + ."border-none" + ."p-0" + ."w-8" + ."h-full" + { + div + ."h-full" + ."flex" + ."flex-row" + ."items-stretch" + ."justify-stretch" + { @if let Some(ref prev_state) = prev_state { - td - colspan=(if next_state.is_none() && has_two_columns { "2" } else { "1" }) - ."border-none" + div + ."w-8" + ."grow" + ."h-full" ."bg-yellow-100" ."hover:bg-yellow-200" - ."p-0" - ."w-8" - ."h-full" { form + hx-post={"./state/" (prev_state)} + hx-target="closest tr" + hx-swap="outerHTML" action={"./state/" (prev_state)} method="post" ."flex" @@ -501,17 +525,18 @@ impl TripInfo { } } } - @if let Some(ref next_state) = trip.state.next() { - td - colspan=(if prev_state.is_none() && has_two_columns { "2" } else { "1" }) - ."border-none" + @if let Some(ref next_state) = next_state { + div + ."w-8" + ."grow" + ."h-full" ."bg-green-100" ."hover:bg-green-200" - ."p-0" - ."w-8" - ."h-full" { form + hx-post={"./state/" (next_state)} + hx-target="closest tr" + hx-swap="outerHTML" action={"./state/" (next_state)} method="post" ."flex" @@ -535,9 +560,63 @@ impl TripInfo { } } } + } + } + ) + } +} + +pub struct TripInfo; + +impl TripInfo { + pub fn build(trip_edit_attribute: Option, trip: &models::Trip) -> Markup { + html!( + table + ."table" + ."table-auto" + ."border-collapse" + ."border-spacing-0" + ."border" + ."w-full" + { + tbody { + (TripInfoRow::build("Location", + trip.location.as_ref(), + &TripAttribute::Location, + trip_edit_attribute.as_ref(), + InputType::Text, + )) + (TripInfoRow::build("Start date", + Some(trip.date_start), + &TripAttribute::DateStart, + trip_edit_attribute.as_ref(), + InputType::Date, + )) + (TripInfoRow::build("End date", + Some(trip.date_end), + &TripAttribute::DateEnd, + trip_edit_attribute.as_ref(), + InputType::Date, + )) + (TripInfoRow::build("Temp (min)", + trip.temp_min, + &TripAttribute::TempMin, + trip_edit_attribute.as_ref(), + InputType::Number, + )) + (TripInfoRow::build("Temp (max)", + trip.temp_max, + &TripAttribute::TempMax, + trip_edit_attribute.as_ref(), + InputType::Number, + )) + (TripInfoStateRow::build(&trip.state)) tr .h-full { td ."border" ."p-2" { "Types" } - td ."border" { + td + colspan="2" + ."border" + { div ."flex" ."flex-row" @@ -636,9 +715,12 @@ impl TripInfo { } tr .h-full { td ."border" ."p-2" { "Carried weight" } - td ."border" ."p-2" + td + colspan="2" + ."border" + ."p-2" { - (trip.total_picked_weight()) + (TripInfoTotalWeightRow::build(trip.id, trip.total_picked_weight())) } } } @@ -704,38 +786,24 @@ impl TripComment { pub struct TripItems; impl TripItems { - pub fn build(state: &ClientState, trip: &models::Trip) -> Result { - Ok(html!( - div ."grid" ."grid-cols-4" ."gap-3" { + pub fn build(active_category: Option<&TripCategory>, trip: &models::Trip) -> Markup { + html!( + div #trip-items ."grid" ."grid-cols-4" ."gap-3" { div ."col-span-2" { - (TripCategoryList::build(state, trip)) + (TripCategoryList::build(active_category, trip)) } div ."col-span-2" { h1 ."text-2xl" ."mb-5" ."text-center" { "Items" } - @if let Some(active_category_id) = state.active_category_id { + @if let Some(active_category) = active_category { (TripItemList::build( - state, - trip, - trip - .categories() - .iter() - .find(|category| - category.category.id == active_category_id - ) - .ok_or( - Error::NotFound{ - description: format!("no category with id {active_category_id}") - } - )? - .items - .as_ref() - .unwrap() + trip.id, + active_category.items.as_ref().unwrap() ) ) } } } - )) + ) } } @@ -743,6 +811,7 @@ pub struct TripCategoryListRow; impl TripCategoryListRow { pub fn build( + trip_id: Uuid, category: &TripCategory, active: bool, biggest_category_weight: i64, @@ -754,19 +823,17 @@ impl TripCategoryListRow { id={"category-" (category.category.id)} hx-swap-oob=[htmx_swap.then_some("outerHTML")] ."h-10" - ."hover:bg-purple-100" + ."hover:bg-gray-100" ."m-3" ."h-full" ."outline"[active] ."outline-2"[active] ."outline-indigo-300"[active] { - td ."border" ."m-0" - { div ."p-0" @@ -783,6 +850,13 @@ impl TripCategoryListRow { id=category.category.id ) ) + hx-post={ + "/trips/" (trip_id) + "/categories/" (category.category.id) + "/select" + } + hx-target="#trip-items" + hx-swap="outerHTML" ."inline-block" ."p-2" ."m-0" @@ -833,7 +907,8 @@ impl TripCategoryListRow { * 100.0 ) ) - ) {} + ) + {} } } ) @@ -843,7 +918,7 @@ impl TripCategoryListRow { pub struct TripCategoryList; impl TripCategoryList { - pub fn build(state: &ClientState, trip: &models::Trip) -> Markup { + pub fn build(active_category: Option<&TripCategory>, trip: &models::Trip) -> Markup { let categories = trip.categories(); let biggest_category_weight: i64 = categories @@ -874,10 +949,10 @@ impl TripCategoryList { } tbody { @for category in trip.categories() { - @let active = state.active_category_id.map_or(false, |id| category.category.id == id); - (TripCategoryListRow::build(category, active, biggest_category_weight,false)) + @let active = active_category.map_or(false, |c| category.category.id == c.category.id); + (TripCategoryListRow::build(trip.id, category, active, biggest_category_weight, false)) } - tr ."h-10" ."hover:bg-purple-200" ."bg-gray-300" ."font-bold" { + tr ."h-10" ."bg-gray-300" ."font-bold" { td ."border" ."p-0" ."m-0" { p ."p-2" ."m-2" { "Sum" } } @@ -896,22 +971,13 @@ impl TripCategoryList { pub struct TripItemList; impl TripItemList { - pub fn build(state: &ClientState, trip: &models::Trip, items: &Vec) -> Markup { + pub fn build(trip_id: Uuid, items: &Vec) -> Markup { let biggest_item_weight: i64 = items.iter().map(|item| item.item.weight).max().unwrap_or(1); html!( @if items.is_empty() { p ."text-lg" ."text-center" ."py-5" ."text-gray-400" { "[Empty]" } } @else { - @if let Some(edit_item) = state.edit_item { - form - name="edit-item" - id="edit-item" - action=(format!("/inventory/item/{edit_item}/edit")) - target="_self" - method="post" - {} - } table ."table" ."table-auto" @@ -931,7 +997,7 @@ impl TripItemList { } tbody { @for item in items { - (TripItemListRow::build(trip.id, item, biggest_item_weight)) + (TripItemListRow::build(trip_id, item, biggest_item_weight)) } } } diff --git a/rust/src/main.rs b/rust/src/main.rs index 42234ba..3516739 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -3,8 +3,12 @@ use axum::{ extract::{Path, Query, State}, headers, headers::Header, - http::{header, header::HeaderMap, StatusCode}, - response::{Html, Redirect}, + http::{ + header, + header::{HeaderMap, HeaderName, HeaderValue}, + StatusCode, + }, + response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, Form, Router, }; @@ -50,7 +54,9 @@ use clap::Parser; #[command(author, version, about, long_about = None)] struct Args { #[arg(long)] - port: Option, + database_url: String, + #[arg(long, default_value_t = 3000)] + port: u16, } #[derive(Clone)] @@ -78,19 +84,60 @@ impl Default for ClientState { } } +enum HtmxEvents { + TripItemEdited, +} + +impl Into for HtmxEvents { + fn into(self) -> HeaderValue { + HeaderValue::from_static(self.to_str()) + } +} + +impl HtmxEvents { + fn to_str(self) -> &'static str { + match self { + Self::TripItemEdited => "TripItemEdited", + } + } +} + +enum HtmxResponseHeaders { + Trigger, +} + +impl Into for HtmxResponseHeaders { + fn into(self) -> HeaderName { + match self { + Self::Trigger => HeaderName::from_static("hx-trigger"), + } + } +} + +enum HtmxRequestHeaders { + HtmxRequest, +} + +impl Into for HtmxRequestHeaders { + fn into(self) -> HeaderName { + match self { + Self::HtmxRequest => HeaderName::from_static("hx-request"), + } + } +} + #[tokio::main] async fn main() -> Result<(), sqlx::Error> { tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .init(); + let args = Args::parse(); + let database_pool = SqlitePoolOptions::new() .max_connections(5) .connect_with( - SqliteConnectOptions::from_str( - &std::env::var("DATABASE_URL").expect("env DATABASE_URL not found"), - )? - .pragma("foreign_keys", "1"), + SqliteConnectOptions::from_str(&args.database_url)?.pragma("foreign_keys", "1"), ) .await .unwrap(); @@ -118,12 +165,14 @@ async fn main() -> Result<(), sqlx::Error> { "/trips/", Router::new() .route("/", get(trips)) - .route("/trips/types/", get(trips_types).post(trip_type_create)) + .route("/types/", get(trips_types).post(trip_type_create)) .route("/types/:id/edit/name/submit", post(trips_types_edit_name)) .route("/", post(trip_create)) .route("/:id/", get(trip)) .route("/:id/comment/submit", post(trip_comment_set)) + .route("/:id/categories/:id/select", post(trip_category_select)) .route("/:id/state/:id", post(trip_state_set)) + .route("/:id/total_weight", get(trip_total_weight_htmx)) .route("/:id/type/:id/add", get(trip_type_add)) .route("/:id/type/:id/remove", get(trip_type_remove)) .route("/:id/edit/:attribute/submit", post(trip_edit_attribute)) @@ -163,9 +212,7 @@ async fn main() -> Result<(), sqlx::Error> { .fallback(|| async { (StatusCode::NOT_FOUND, "not found") }) .with_state(state); - let args = Args::parse(); - - let addr = SocketAddr::from(([127, 0, 0, 1], args.port.unwrap_or(3000))); + let addr = SocketAddr::from(([127, 0, 0, 1], args.port)); tracing::debug!("listening on {}", addr); axum::Server::bind(&addr) .serve(app.into_make_service()) @@ -193,7 +240,43 @@ async fn inventory_active( Query(inventory_query): Query, ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { state.client_state.edit_item = inventory_query.edit_item; - inventory(state, Some(id)).await + state.client_state.active_category_id = Some(id); + + let inventory = models::Inventory::load(&state.database_pool) + .await + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::build(&error.to_string()), + ) + })?; + + let active_category: Option<&Category> = state + .client_state + .active_category_id + .map(|id| { + inventory + .categories + .iter() + .find(|category| category.id == id) + .ok_or(( + StatusCode::NOT_FOUND, + ErrorPage::build(&format!("a category with id {id} does not exist")), + )) + }) + .transpose()?; + + Ok(( + StatusCode::OK, + Root::build( + &components::Inventory::build( + active_category, + &inventory.categories, + state.client_state.edit_item, + ), + &TopLevelPage::Inventory, + ), + )) } async fn inventory_inactive( @@ -201,71 +284,53 @@ async fn inventory_inactive( Query(inventory_query): Query, ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { state.client_state.edit_item = inventory_query.edit_item; - inventory(state, None).await -} + state.client_state.active_category_id = None; -async fn inventory( - mut state: AppState, - active_id: Option, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { - state.client_state.active_category_id = active_id; - - let mut categories = query_as!( - DbCategoryRow, - "SELECT id,name,description FROM inventory_items_categories" - ) - .fetch(&state.database_pool) - .map_ok(|row: DbCategoryRow| row.try_into()) - .try_collect::>>() - .await - // we have two error handling lines here. these are distinct errors - // this one is the SQL error that may arise during the query - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorPage::build(&e.to_string()), - ) - })? - .into_iter() - .collect::, models::Error>>() - // and this one is the model mapping error that may arise e.g. during - // reading of the rows - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorPage::build(&e.to_string()), - ) - })?; - - for category in &mut categories { - category - .populate_items(&state.database_pool) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorPage::build(&e.to_string()), - ) - })?; - } + let inventory = models::Inventory::load(&state.database_pool) + .await + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::build(&error.to_string()), + ) + })?; Ok(( StatusCode::OK, Root::build( - &Inventory::build(state.client_state, categories).map_err(|e| match e { - Error::NotFound { description } => { - (StatusCode::NOT_FOUND, ErrorPage::build(&description)) - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorPage::build(&e.to_string()), - ), - })?, + &components::Inventory::build( + None, + &inventory.categories, + state.client_state.edit_item, + ), &TopLevelPage::Inventory, ), )) } +// async fn inventory( +// mut state: AppState, +// active_id: Option, +// ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +// state.client_state.active_category_id = active_id; + +// Ok(( +// StatusCode::OK, +// Root::build( +// &components::Inventory::build(state.client_state, categories).map_err(|e| match e { +// Error::NotFound { description } => { +// (StatusCode::NOT_FOUND, ErrorPage::build(&description)) +// } +// _ => ( +// StatusCode::INTERNAL_SERVER_ERROR, +// ErrorPage::build(&e.to_string()), +// ), +// })?, +// &TopLevelPage::Inventory, +// ), +// )) +// } + #[derive(Deserialize)] struct NewItem { #[serde(rename = "new-item-name")] @@ -325,12 +390,13 @@ async fn inventory_item_validate_name( async fn inventory_item_create( State(state): State, + headers: HeaderMap, Form(new_item): Form, -) -> Result { +) -> Result { if new_item.name.is_empty() { return Err(( StatusCode::UNPROCESSABLE_ENTITY, - "name cannot be empty".to_string(), + ErrorPage::build("name cannot be empty"), )); } @@ -360,42 +426,90 @@ async fn inventory_item_create( // SQLITE_CONSTRAINT_FOREIGNKEY ( StatusCode::BAD_REQUEST, - format!("category {id} not found", id = new_item.category_id), + ErrorPage::build(&format!( + "category {id} not found", + id = new_item.category_id + )), ) } "2067" => { // SQLITE_CONSTRAINT_UNIQUE ( StatusCode::BAD_REQUEST, - format!( + ErrorPage::build(&format!( "item with name \"{name}\" already exists in category {id}", name = new_item.name, id = new_item.category_id - ), + )), ) } _ => ( StatusCode::INTERNAL_SERVER_ERROR, - format!("got error with unknown code: {}", sqlite_error.to_string()), + ErrorPage::build(&format!( + "got error with unknown code: {}", + sqlite_error.to_string() + )), ), } } else { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("got error without code: {}", sqlite_error.to_string()), + ErrorPage::build(&format!( + "got error without code: {}", + sqlite_error.to_string() + )), ) } } _ => ( StatusCode::INTERNAL_SERVER_ERROR, - format!("got unknown error: {}", e.to_string()), + ErrorPage::build(&format!("got unknown error: {}", e.to_string())), ), })?; - Ok(Redirect::to(&format!( - "/inventory/category/{id}/", - id = new_item.category_id - ))) + if is_htmx(&headers) { + let inventory = models::Inventory::load(&state.database_pool) + .await + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::build(&error.to_string()), + ) + })?; + + // it's impossible to NOT find the item here, as we literally just added + // it. but good error handling never hurts + let active_category: Option<&Category> = state + .client_state + .active_category_id + .map(|id| { + inventory + .categories + .iter() + .find(|category| category.id == id) + .ok_or(( + StatusCode::NOT_FOUND, + ErrorPage::build(&format!("a category with id {id} was inserted but does not exist, this is a bug")), + )) + }) + .transpose()?; + + Ok(( + StatusCode::OK, + components::Inventory::build( + active_category, + &inventory.categories, + state.client_state.edit_item, + ), + ) + .into_response()) + } else { + Ok(Redirect::to(&format!( + "/inventory/category/{id}/", + id = new_item.category_id + )) + .into_response()) + } } async fn inventory_item_delete( @@ -778,18 +892,28 @@ async fn trip( ) })?; + let active_category: Option<&TripCategory> = state + .client_state + .active_category_id + .map(|id| { + trip.categories() + .iter() + .find(|category| category.category.id == id) + .ok_or(( + StatusCode::NOT_FOUND, + ErrorPage::build(&format!("an active category with id {id} does not exist")), + )) + }) + .transpose()?; + Ok(( StatusCode::OK, Root::build( - &components::Trip::build(&state.client_state, &trip).map_err(|e| match e { - Error::NotFound { description } => { - (StatusCode::NOT_FOUND, ErrorPage::build(&description)) - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - ErrorPage::build(&e.to_string()), - ), - })?, + &components::Trip::build( + &trip, + state.client_state.trip_edit_attribute, + active_category, + ), &TopLevelPage::Trips, ), )) @@ -1049,7 +1173,9 @@ async fn trip_row( ) })?; - let category_row = components::trip::TripCategoryListRow::build(&category, true, 0, true); + // TODO biggest_category_weight? + let category_row = + components::trip::TripCategoryListRow::build(trip_id, &category, true, 0, true); Ok(html!((item_row)(category_row))) } @@ -1083,9 +1209,18 @@ async fn trip_item_set_pick( async fn trip_item_set_pick_htmx( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> { trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pick, true).await?; - Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?)) + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::Trigger.into(), + HtmxEvents::TripItemEdited.into(), + ); + Ok(( + StatusCode::OK, + headers, + trip_row(&state, trip_id, item_id).await?, + )) } async fn trip_item_set_unpick( @@ -1117,9 +1252,18 @@ async fn trip_item_set_unpick( async fn trip_item_set_unpick_htmx( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> { trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pick, false).await?; - Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?)) + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::Trigger.into(), + HtmxEvents::TripItemEdited.into(), + ); + Ok(( + StatusCode::OK, + headers, + trip_row(&state, trip_id, item_id).await?, + )) } async fn trip_item_set_pack( @@ -1151,9 +1295,18 @@ async fn trip_item_set_pack( async fn trip_item_set_pack_htmx( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> { trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pack, true).await?; - Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?)) + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::Trigger.into(), + HtmxEvents::TripItemEdited.into(), + ); + Ok(( + StatusCode::OK, + headers, + trip_row(&state, trip_id, item_id).await?, + )) } async fn trip_item_set_unpack( @@ -1185,9 +1338,40 @@ async fn trip_item_set_unpack( async fn trip_item_set_unpack_htmx( State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, -) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { +) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> { trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pack, false).await?; - Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?)) + let mut headers = HeaderMap::new(); + headers.insert::( + HtmxResponseHeaders::Trigger.into(), + HtmxEvents::TripItemEdited.into(), + ); + Ok(( + StatusCode::OK, + headers, + trip_row(&state, trip_id, item_id).await?, + )) +} + +async fn trip_total_weight_htmx( + State(state): State, + Path(trip_id): Path, +) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { + let total_weight = models::Trip::find_total_picked_weight(&state.database_pool, trip_id) + .await + .map_err(|error| { + ( + StatusCode::BAD_REQUEST, + ErrorPage::build(&error.to_string()), + ) + })? + .ok_or(( + StatusCode::NOT_FOUND, + ErrorPage::build(&format!("trip with id {trip_id} not found")), + ))?; + Ok(( + StatusCode::OK, + components::trip::TripInfoTotalWeightRow::build(trip_id, total_weight), + )) } #[derive(Deserialize)] @@ -1263,8 +1447,9 @@ async fn inventory_category_create( async fn trip_state_set( State(state): State, + headers: HeaderMap, Path((trip_id, new_state)): Path<(Uuid, TripState)>, -) -> Result { +) -> Result { let trip_id = trip_id.to_string(); let result = query!( "UPDATE trips @@ -1283,10 +1468,25 @@ async fn trip_state_set( ErrorPage::build(&format!("trip with id {id} not found", id = trip_id)), )) } else { - Ok(Redirect::to(&format!("/trips/{id}/", id = trip_id))) + if is_htmx(&headers) { + Ok(( + StatusCode::OK, + components::trip::TripInfoStateRow::build(&new_state), + ) + .into_response()) + } else { + Ok(Redirect::to(&format!("/trips/{id}/", id = trip_id)).into_response()) + } } } +fn is_htmx(headers: &HeaderMap) -> bool { + headers + .get::(HtmxRequestHeaders::HtmxRequest.into()) + .map(|value| value == "true") + .unwrap_or(false) +} + #[derive(Debug, Deserialize)] struct TripTypeQuery { edit: Option, @@ -1500,3 +1700,44 @@ async fn inventory_item( ), )) } + +async fn trip_category_select( + State(state): State, + Path((trip_id, category_id)): Path<(Uuid, Uuid)>, +) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { + let mut trip = models::Trip::find(&state.database_pool, trip_id) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::build(&e.to_string()), + ) + })? + .ok_or(( + StatusCode::NOT_FOUND, + ErrorPage::build(&format!("trip with id {trip_id} not found")), + ))?; + + trip.load_categories(&state.database_pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::build(&e.to_string()), + ) + })?; + + let active_category = trip + .categories() + .iter() + .find(|c| c.category.id == category_id) + .ok_or(( + StatusCode::NOT_FOUND, + ErrorPage::build(&format!("category with id {category_id} not found")), + ))?; + + Ok(( + StatusCode::OK, + components::trip::TripItems::build(Some(&active_category), &trip), + )) +} diff --git a/rust/src/models.rs b/rust/src/models.rs index 08c547a..ded580d 100644 --- a/rust/src/models.rs +++ b/rust/src/models.rs @@ -25,7 +25,6 @@ pub enum Error { Sql { description: String }, Uuid { description: String }, Enum { description: String }, - NotFound { description: String }, Int { description: String }, TimeParse { description: String }, } @@ -39,9 +38,6 @@ impl fmt::Display for Error { Self::Uuid { description } => { write!(f, "UUID error: {description}") } - Self::NotFound { description } => { - write!(f, "Not found: {description}") - } Self::Int { description } => { write!(f, "Integer error: {description}") } @@ -132,14 +128,6 @@ impl TripState { Self::Done => Some(Self::Review), } } - - pub fn is_first(&self) -> bool { - self == &TripState::new() - } - - pub fn is_last(&self) -> bool { - self == &TripState::Done - } } impl fmt::Display for TripState { @@ -467,7 +455,79 @@ pub enum TripAttribute { TempMax, } +pub struct DbTripWeightRow { + pub total_weight: Option, +} + impl<'a> Trip { + pub async fn find( + pool: &sqlx::Pool, + trip_id: Uuid, + ) -> Result, Error> { + let trip_id_param = trip_id.to_string(); + let trip = sqlx::query_as!( + DbTripRow, + "SELECT + id, + name, + CAST (date_start AS TEXT) date_start, + CAST (date_end AS TEXT) date_end, + state, + location, + temp_min, + temp_max, + comment + FROM trips + WHERE id = ?", + trip_id_param + ) + .fetch_one(pool) + .map_ok(|row| row.try_into()) + .await; + + match trip { + Err(e) => match e { + sqlx::Error::RowNotFound => Ok(None), + _ => Err(e.into()), + }, + Ok(v) => Ok(Some(v?)), + } + } + + pub async fn find_total_picked_weight( + pool: &sqlx::Pool, + trip_id: Uuid, + ) -> Result, Error> { + let trip_id_param = trip_id.to_string(); + let weight = sqlx::query_as!( + DbTripWeightRow, + " + SELECT + CAST(IFNULL(SUM(i_item.weight), 0) AS INTEGER) AS total_weight + FROM trips AS trip + INNER JOIN trips_items AS t_item + ON t_item.trip_id = trip.id + INNER JOIN inventory_items AS i_item + ON t_item.item_id = i_item.id + WHERE + trip.id = ? + AND t_item.pick = true + ", + trip_id_param + ) + .fetch_one(pool) + .map_ok(|row| row.total_weight.map(|weight| weight as i64)) + .await; + + match weight { + Err(e) => match e { + sqlx::Error::RowNotFound => Ok(None), + _ => Err(e.into()), + }, + Ok(v) => Ok(v), + } + } + pub fn types(&'a self) -> &Vec { self.types .as_ref() @@ -479,9 +539,7 @@ impl<'a> Trip { .as_ref() .expect("you need to call load_trips_types()") } -} -impl<'a> Trip { pub fn total_picked_weight(&self) -> i64 { self.categories() .iter() @@ -1067,3 +1125,38 @@ impl TryFrom for InventoryItem { }) } } + +pub struct Inventory { + pub categories: Vec, +} + +impl Inventory { + pub async fn load(pool: &sqlx::Pool) -> Result { + let mut categories = sqlx::query_as!( + DbCategoryRow, + "SELECT id,name,description FROM inventory_items_categories" + ) + .fetch(pool) + .map_ok(|row: DbCategoryRow| row.try_into()) + .try_collect::>>() + .await + // we have two error handling lines here. these are distinct errors + // this one is the SQL error that may arise during the query + .map_err(|e| Error::Sql { + description: e.to_string(), + })? + .into_iter() + .collect::, Error>>() + // and this one is the model mapping error that may arise e.g. during + // reading of the rows + .map_err(|e| Error::Sql { + description: e.to_string(), + })?; + + for category in &mut categories { + category.populate_items(pool).await?; + } + + Ok(Self { categories }) + } +}