refactoring

This commit is contained in:
2023-08-29 21:34:00 +02:00
parent c1f16ce035
commit edd9b94fb4
7 changed files with 951 additions and 390 deletions

84
rust/query.sql Normal file
View File

@@ -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

View File

@@ -404,6 +404,72 @@
}, },
"query": "INSERT INTO trips\n (id, name, date_start, date_end, state)\n VALUES\n (?, ?, ?, ?, ?)" "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": { "a81bcbeb11260e3b4363e19c26b71b489e326b08bfacb6e11b4c4fc068dc7806": {
"describe": { "describe": {
"columns": [ "columns": [
@@ -444,35 +510,23 @@
}, },
"query": "DELETE FROM inventory_items\n WHERE id = ?" "query": "DELETE FROM inventory_items\n WHERE id = ?"
}, },
"cc1ad49669cff7f89975abfab3d0a8caef2e3978c826e1877db91c05a7f9d00d": { "cc70d7a392a0283fec1896acba805f5c2a527537b8faa22d1c69306017b9c465": {
"describe": { "describe": {
"columns": [ "columns": [
{ {
"name": "id", "name": "total_weight",
"ordinal": 0, "ordinal": 0,
"type_info": "Text" "type_info": "Int"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 2,
"type_info": "Text"
} }
], ],
"nullable": [ "nullable": [
false,
false,
true true
], ],
"parameters": { "parameters": {
"Right": 1 "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": { "d0562ad92782a6ad6080c0535749c4a0a28fa78a17698933bce670db057e2628": {
"describe": { "describe": {

View File

@@ -8,37 +8,36 @@ use uuid::{uuid, Uuid};
pub struct Inventory; pub struct Inventory;
impl Inventory { impl Inventory {
pub fn build(state: ClientState, categories: Vec<Category>) -> Result<Markup, Error> { pub fn build(
let doc = html!( active_category: Option<&Category>,
categories: &Vec<Category>,
edit_item_id: Option<Uuid>,
) -> Markup {
html!(
div id="pkglist-item-manager" { div id="pkglist-item-manager" {
div ."p-8" ."grid" ."grid-cols-4" ."gap-5" { div ."p-8" ."grid" ."grid-cols-4" ."gap-5" {
div ."col-span-2" ."flex" ."flex-col" ."gap-8" { div ."col-span-2" ."flex" ."flex-col" ."gap-8" {
h1 ."text-2xl" ."text-center" { "Categories" } h1 ."text-2xl" ."text-center" { "Categories" }
(InventoryCategoryList::build(&state, &categories)) (InventoryCategoryList::build(active_category, categories))
(InventoryNewCategoryForm::build()) (InventoryNewCategoryForm::build())
} }
div ."col-span-2" ."flex" ."flex-col" ."gap-8" { div ."col-span-2" ."flex" ."flex-col" ."gap-8" {
h1 ."text-2xl" ."text-center" { "Items" } h1 ."text-2xl" ."text-center" { "Items" }
@if let Some(active_category_id) = state.active_category_id { @if let Some(active_category) = active_category {
(InventoryItemList::build(&state, categories.iter().find(|category| category.id == active_category_id) (InventoryItemList::build(edit_item_id, active_category.items()))
.ok_or(Error::NotFound{ description: format!("no category with id {}", active_category_id) })?
.items())
)
} }
(InventoryNewItemForm::build(&state, &categories)) (InventoryNewItemForm::build(active_category, &categories))
} }
} }
} }
); )
Ok(doc)
} }
} }
pub struct InventoryCategoryList; pub struct InventoryCategoryList;
impl InventoryCategoryList { impl InventoryCategoryList {
pub fn build(state: &ClientState, categories: &Vec<Category>) -> Markup { pub fn build(active_category: Option<&Category>, categories: &Vec<Category>) -> Markup {
let biggest_category_weight: i64 = categories let biggest_category_weight: i64 = categories
.iter() .iter()
.map(Category::total_weight) .map(Category::total_weight)
@@ -68,10 +67,10 @@ impl InventoryCategoryList {
} }
tbody { tbody {
@for category in categories { @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 tr
."h-10" ."h-10"
."hover:bg-purple-100" ."hover:bg-gray-100"
."m-3" ."m-3"
."h-full" ."h-full"
."outline"[active] ."outline"[active]
@@ -81,7 +80,7 @@ impl InventoryCategoryList {
{ {
td 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" "border p-0 m-0 font-bold"
} @else { } @else {
"border p-0 m-0" "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" { td ."border" ."p-0" ."m-0" {
p ."p-2" ."m-2" { "Sum" } p ."p-2" ."m-2" { "Sum" }
} }
@@ -144,18 +143,18 @@ impl InventoryCategoryList {
pub struct InventoryItemList; pub struct InventoryItemList;
impl InventoryItemList { impl InventoryItemList {
pub fn build(state: &ClientState, items: &Vec<Item>) -> Markup { pub fn build(edit_item_id: Option<Uuid>, items: &Vec<Item>) -> Markup {
let biggest_item_weight: i64 = items.iter().map(|item| item.weight).max().unwrap_or(1); let biggest_item_weight: i64 = items.iter().map(|item| item.weight).max().unwrap_or(1);
html!( html!(
div #items { div #items {
@if items.is_empty() { @if items.is_empty() {
p ."text-lg" ."text-center" ."py-5" ."text-gray-400" { "[Empty]" } p ."text-lg" ."text-center" ."py-5" ."text-gray-400" { "[Empty]" }
} @else { } @else {
@if let Some(edit_item) = state.edit_item { @if let Some(edit_item_id) = edit_item_id {
form form
name="edit-item" name="edit-item"
id="edit-item" id="edit-item"
action=(format!("/inventory/item/{edit_item}/edit")) action={"/inventory/item/" (edit_item_id) "/edit"}
target="_self" target="_self"
method="post" method="post"
{} {}
@@ -163,7 +162,7 @@ impl InventoryItemList {
table table
."table" ."table"
."table-auto" ."table-auto"
.table-fixed ."table-fixed"
."border-collapse" ."border-collapse"
."border-spacing-0" ."border-spacing-0"
."border" ."border"
@@ -179,7 +178,7 @@ impl InventoryItemList {
} }
tbody { tbody {
@for item in items { @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" { tr ."h-10" {
td ."border" ."bg-blue-300" ."px-2" ."py-0" { td ."border" ."bg-blue-300" ."px-2" ."py-0" {
div ."h-full" ."w-full" ."flex" { div ."h-full" ."w-full" ."flex" {
@@ -251,7 +250,7 @@ impl InventoryItemList {
} }
} }
} @else { } @else {
tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" { tr ."h-10" {
td ."border" ."p-0" { td ."border" ."p-0" {
a a
."p-2" ."w-full" ."inline-block" ."p-2" ."w-full" ."inline-block"
@@ -321,6 +320,17 @@ pub struct InventoryNewItemFormName;
impl InventoryNewItemFormName { impl InventoryNewItemFormName {
pub fn build(value: Option<&str>, error: bool) -> Markup { pub fn build(value: Option<&str>, error: bool) -> Markup {
html!( 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 div
."grid" ."grid"
."grid-cols-[2fr,3fr]" ."grid-cols-[2fr,3fr]"
@@ -328,7 +338,7 @@ impl InventoryNewItemFormName {
."items-center" ."items-center"
hx-post="/inventory/item/name/validate" hx-post="/inventory/item/name/validate"
hx-trigger="input delay:1s, loaded from:document" hx-trigger="input delay:1s, loaded from:document"
hx-target="this"
hx-params="new-item-name" hx-params="new-item-name"
hx-swap="outerHTML" hx-swap="outerHTML"
#abc #abc
@@ -349,7 +359,7 @@ impl InventoryNewItemFormName {
."rounded" ."rounded"
."focus:outline-none" ."focus:outline-none"
."focus:bg-white" ."focus:bg-white"
."focus:border-purple-500"[!error] ."focus:border-gray-500"[!error]
value=[value] value=[value]
{} {}
@if error { @if error {
@@ -369,6 +379,17 @@ pub struct InventoryNewItemFormWeight;
impl InventoryNewItemFormWeight { impl InventoryNewItemFormWeight {
pub fn build() -> Markup { pub fn build() -> Markup {
html!( 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 div
."grid" ."grid"
."grid-cols-[2fr,3fr]" ."grid-cols-[2fr,3fr]"
@@ -385,7 +406,7 @@ impl InventoryNewItemFormWeight {
save_active = inventory_new_item_check_input(); save_active = inventory_new_item_check_input();
weight_error = !check_weight(); 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" ."block"
."w-full" ."w-full"
."p-2" ."p-2"
@@ -410,7 +431,7 @@ impl InventoryNewItemFormWeight {
pub struct InventoryNewItemFormCategory; pub struct InventoryNewItemFormCategory;
impl InventoryNewItemFormCategory { impl InventoryNewItemFormCategory {
pub fn build(state: &ClientState, categories: &Vec<Category>) -> Markup { pub fn build(active_category: Option<&Category>, categories: &Vec<Category>) -> Markup {
html!( html!(
div div
."grid" ."grid"
@@ -431,11 +452,11 @@ impl InventoryNewItemFormCategory {
."rounded" ."rounded"
."focus:outline-none" ."focus:outline-none"
."focus:bg-white" ."focus:bg-white"
."focus:border-purple-500" ."focus:border-gray-500"
autocomplete="off" // https://stackoverflow.com/a/10096033 autocomplete="off" // https://stackoverflow.com/a/10096033
{ {
@for category in categories { @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) (category.name)
} }
} }
@@ -448,7 +469,7 @@ impl InventoryNewItemFormCategory {
pub struct InventoryNewItemForm; pub struct InventoryNewItemForm;
impl InventoryNewItemForm { impl InventoryNewItemForm {
pub fn build(state: &ClientState, categories: &Vec<Category>) -> Markup { pub fn build(active_category: Option<&Category>, categories: &Vec<Category>) -> Markup {
html!( html!(
script { script {
(PreEscaped(" (PreEscaped("
@@ -467,6 +488,9 @@ impl InventoryNewItemForm {
weight_error: !check_weight(), weight_error: !check_weight(),
}" }"
name="new-item" name="new-item"
hx-post="/inventory/item/"
hx-swap="outerHTML"
hx-target="#pkglist-item-manager"
id="new-item" id="new-item"
action="/inventory/item/" action="/inventory/item/"
target="_self" target="_self"
@@ -479,7 +503,7 @@ impl InventoryNewItemForm {
div ."w-11/12" ."mx-auto" ."flex" ."flex-col" ."gap-8" { div ."w-11/12" ."mx-auto" ."flex" ."flex-col" ."gap-8" {
(InventoryNewItemFormName::build(None, false)) (InventoryNewItemFormName::build(None, false))
(InventoryNewItemFormWeight::build()) (InventoryNewItemFormWeight::build())
(InventoryNewItemFormCategory::build(state, categories)) (InventoryNewItemFormCategory::build(active_category, categories))
input type="submit" value="Add" input type="submit" value="Add"
x-bind:disabled="!save_active" x-bind:disabled="!save_active"
."enabled:cursor-pointer" ."enabled:cursor-pointer"
@@ -530,7 +554,7 @@ impl InventoryNewCategoryForm {
."rounded" ."rounded"
."focus:outline-none" ."focus:outline-none"
."focus:bg-white" ."focus:bg-white"
."focus:border-purple-500" ."focus:border-gray-500"
{ {
} }
} }
@@ -568,19 +592,19 @@ impl InventoryItem {
."w-full" ."w-full"
{ {
tbody { 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" { "Name" }
td ."border" ."p-2" { (item.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" { "Description" }
td ."border" ."p-2" { (item.description.clone().unwrap_or("".to_string())) } 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" { "Weight" }
td ."border" ."p-2" { (item.weight.to_string()) } 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" { "Category" }
td ."border" ."p-2" { (item.category.name) } td ."border" ."p-2" { (item.category.name) }
} }

View File

@@ -33,7 +33,6 @@ impl Root {
meta name="htmx-config" content=r#"{"useTemplateFragments":true}"# {} meta name="htmx-config" content=r#"{"useTemplateFragments":true}"# {}
} }
body body
hx-boost="true"
{ {
header header
#header #header

View File

@@ -1,5 +1,5 @@
use crate::models;
use crate::models::*; use crate::models::*;
use crate::{models, HtmxEvents};
use maud::{html, Markup, PreEscaped}; use maud::{html, Markup, PreEscaped};
use uuid::Uuid; use uuid::Uuid;
@@ -15,7 +15,13 @@ pub use types::*;
impl TripManager { impl TripManager {
pub fn build(trips: Vec<models::Trip>) -> Markup { pub fn build(trips: Vec<models::Trip>) -> Markup {
html!( html!(
div ."p-8" { div
."p-8"
."flex"
."flex-col"
."gap-8"
{
h1 ."text-2xl" {"Trips"}
(TripTable::build(trips)) (TripTable::build(trips))
(NewTrip::build()) (NewTrip::build())
} }
@@ -55,7 +61,6 @@ pub struct TripTable;
impl TripTable { impl TripTable {
pub fn build(trips: Vec<models::Trip>) -> Markup { pub fn build(trips: Vec<models::Trip>) -> Markup {
html!( html!(
h1 ."text-2xl" ."mb-5" {"Trips"}
table table
."table" ."table"
."table-auto" ."table-auto"
@@ -75,7 +80,7 @@ impl TripTable {
} }
tbody { tbody {
@for trip in trips { @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.name))
(TripTableRow::build(trip.id, trip.date_start.to_string())) (TripTableRow::build(trip.id, trip.date_start.to_string()))
(TripTableRow::build(trip.id, trip.date_end.to_string())) (TripTableRow::build(trip.id, trip.date_end.to_string()))
@@ -118,7 +123,7 @@ impl NewTrip {
action="/trips/" action="/trips/"
target="_self" target="_self"
method="post" 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" { div ."mb-5" ."flex" ."flex-row" ."trips-center" {
span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {} span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {}
@@ -210,83 +215,110 @@ impl NewTrip {
pub struct Trip; pub struct Trip;
impl Trip { impl Trip {
pub fn build(state: &ClientState, trip: &models::Trip) -> Result<Markup, Error> { pub fn build(
Ok(html!( trip: &models::Trip,
trip_edit_attribute: Option<TripAttribute>,
active_category: Option<&TripCategory>,
) -> Markup {
html!(
div ."p-8" ."flex" ."flex-col" ."gap-8" { div ."p-8" ."flex" ."flex-col" ."gap-8" {
div ."flex" ."flex-row" ."items-center" ."gap-x-3" { div
@if state.trip_edit_attribute.as_ref().map_or(false, |a| *a == TripAttribute::Name) { ."flex"
form ."flex-row"
id="edit-trip" ."items-stretch"
action=(format!("edit/{}/submit", to_variant_name(&TripAttribute::Name).unwrap())) ."gap-x-5"
target="_self" {
method="post" a
href="/trips/"
."text-sm"
."text-gray-500"
."flex"
{
div
."m-auto"
{ {
div span
."flex" ."mdi"
."flex-row" ."mdi-arrow-left"
."items-center" {}
."gap-x-3" "back"
."items-stretch" }
}
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 div
."bg-blue-200"
."w-full"
."text-2xl"
."font-semibold"
type=(<InputType as Into<&'static str>>::into(InputType::Text))
name="new-value"
form="edit-trip"
value=(trip.name)
{}
a
href="."
."bg-red-200"
."hover:bg-red-300"
."w-8"
."flex" ."flex"
."flex-row"
."items-center"
."gap-x-3"
."items-stretch"
{ {
span input
."mdi" ."bg-blue-200"
."mdi-cancel" ."w-full"
."text-xl" ."text-2xl"
."m-auto" ."font-semibold"
{} type=(<InputType as Into<&'static str>>::into(InputType::Text))
} name="new-value"
button form="edit-trip"
type="submit" value=(trip.name)
form="edit-trip"
."bg-green-200"
."hover:bg-green-300"
."w-8"
{
span
."mdi"
."mdi-content-save"
."text-xl"
{} {}
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 {
} @else { h1 ."text-2xl" { (trip.name) }
h1 ."text-2xl" ."font-semibold"{ (trip.name) } span {
span { a href=(format!("?edit={}", to_variant_name(&TripAttribute::Name).unwrap()))
a href=(format!("?edit={}", to_variant_name(&TripAttribute::Name).unwrap())) {
{ span
span ."mdi"
."mdi" ."mdi-pencil"
."mdi-pencil" ."text-xl"
."text-xl" ."opacity-50"
."opacity-50" {}
{} }
} }
} }
} }
} }
(TripInfo::build(state, trip)) (TripInfo::build(trip_edit_attribute, trip))
(TripComment::build(trip)) (TripComment::build(trip))
(TripItems::build(state, trip)?) (TripItems::build(active_category, trip))
} }
)) )
} }
} }
@@ -299,7 +331,6 @@ impl TripInfoRow {
attribute_key: &TripAttribute, attribute_key: &TripAttribute,
edit_attribute: Option<&TripAttribute>, edit_attribute: Option<&TripAttribute>,
input_type: InputType, input_type: InputType,
has_two_columns: bool,
) -> Markup { ) -> Markup {
let edit = edit_attribute.map_or(false, |a| a == attribute_key); let edit = edit_attribute.map_or(false, |a| a == attribute_key);
html!( html!(
@@ -327,58 +358,66 @@ impl TripInfoRow {
} }
} }
td td
."border-none" ."border"
."bg-red-100" ."border-solid"
."hover:bg-red-200" ."border-gray-300"
."p-0" ."p-0"
."h-full" ."h-full"
."w-8"
{ {
a div
."aspect-square"
."flex" ."flex"
."w-full" ."flex-row"
."items-stretch"
."h-full" ."h-full"
."p-0"
href="." // strips query parameters
{ {
span div
."m-auto" ."bg-red-100"
."mdi" ."hover:bg-red-200"
."mdi-cancel" ."w-8"
."text-xl" ."h-full"
{} {
} a
} ."flex"
td ."w-full"
."border-none" ."h-full"
."bg-green-100" ."p-0"
."hover:bg-green-200" href="." // strips query parameters
."p-0" {
."h-full" span
."w-8" ."m-auto"
{ ."mdi"
button ."mdi-cancel"
."aspect-square" ."text-xl"
."flex" {}
."w-full" }
."h-full" }
type="submit" div
form="edit-trip" ."bg-green-100"
{ ."hover:bg-green-200"
span ."w-8"
."m-auto" ."h-full"
."mdi" {
."mdi-content-save" button
."text-xl" ."flex"
{} ."w-full"
."h-full"
type="submit"
form="edit-trip"
{
span
."m-auto"
."mdi"
."mdi-content-save"
."text-xl"
{}
}
}
} }
} }
} @else { } @else {
td ."border" ."p-2" { (name) } td ."border" ."p-2" { (name) }
td ."border" ."p-2" { (value.map_or(String::new(), |v| v.to_string())) } td ."border" ."p-2" { (value.map_or(String::new(), |v| v.to_string())) }
td td
colspan=(if has_two_columns {"2"} else {"1"})
."border-none" ."border-none"
."bg-blue-100" ."bg-blue-100"
."hover:bg-blue-200" ."hover:bg-blue-200"
@@ -406,79 +445,64 @@ impl TripInfoRow {
} }
} }
pub struct TripInfo; pub struct TripInfoTotalWeightRow;
impl TripInfo { impl TripInfoTotalWeightRow {
pub fn build(state: &ClientState, trip: &models::Trip) -> Markup { pub fn build(trip_id: Uuid, value: i64) -> Markup {
let has_two_columns =
state.trip_edit_attribute.is_some() || !(trip.state.is_first() || trip.state.is_last());
html!( html!(
table span
."table" hx-trigger={
."table-auto" (HtmxEvents::TripItemEdited.to_str()) " from:body"
."border-collapse" }
."border-spacing-0" hx-get={"/trips/" (trip_id) "/total_weight"}
."border"
."w-full"
{ {
tbody { (value)
(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();
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 { @if let Some(ref prev_state) = prev_state {
td div
colspan=(if next_state.is_none() && has_two_columns { "2" } else { "1" }) ."w-8"
."border-none" ."grow"
."h-full"
."bg-yellow-100" ."bg-yellow-100"
."hover:bg-yellow-200" ."hover:bg-yellow-200"
."p-0"
."w-8"
."h-full"
{ {
form form
hx-post={"./state/" (prev_state)}
hx-target="closest tr"
hx-swap="outerHTML"
action={"./state/" (prev_state)} action={"./state/" (prev_state)}
method="post" method="post"
."flex" ."flex"
@@ -501,17 +525,18 @@ impl TripInfo {
} }
} }
} }
@if let Some(ref next_state) = trip.state.next() { @if let Some(ref next_state) = next_state {
td div
colspan=(if prev_state.is_none() && has_two_columns { "2" } else { "1" }) ."w-8"
."border-none" ."grow"
."h-full"
."bg-green-100" ."bg-green-100"
."hover:bg-green-200" ."hover:bg-green-200"
."p-0"
."w-8"
."h-full"
{ {
form form
hx-post={"./state/" (next_state)}
hx-target="closest tr"
hx-swap="outerHTML"
action={"./state/" (next_state)} action={"./state/" (next_state)}
method="post" method="post"
."flex" ."flex"
@@ -535,9 +560,63 @@ impl TripInfo {
} }
} }
} }
}
}
)
}
}
pub struct TripInfo;
impl TripInfo {
pub fn build(trip_edit_attribute: Option<TripAttribute>, 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 { tr .h-full {
td ."border" ."p-2" { "Types" } td ."border" ."p-2" { "Types" }
td ."border" { td
colspan="2"
."border"
{
div div
."flex" ."flex"
."flex-row" ."flex-row"
@@ -636,9 +715,12 @@ impl TripInfo {
} }
tr .h-full { tr .h-full {
td ."border" ."p-2" { "Carried weight" } 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; pub struct TripItems;
impl TripItems { impl TripItems {
pub fn build(state: &ClientState, trip: &models::Trip) -> Result<Markup, Error> { pub fn build(active_category: Option<&TripCategory>, trip: &models::Trip) -> Markup {
Ok(html!( html!(
div ."grid" ."grid-cols-4" ."gap-3" { div #trip-items ."grid" ."grid-cols-4" ."gap-3" {
div ."col-span-2" { div ."col-span-2" {
(TripCategoryList::build(state, trip)) (TripCategoryList::build(active_category, trip))
} }
div ."col-span-2" { div ."col-span-2" {
h1 ."text-2xl" ."mb-5" ."text-center" { "Items" } 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( (TripItemList::build(
state, trip.id,
trip, active_category.items.as_ref().unwrap()
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()
) )
) )
} }
} }
} }
)) )
} }
} }
@@ -743,6 +811,7 @@ pub struct TripCategoryListRow;
impl TripCategoryListRow { impl TripCategoryListRow {
pub fn build( pub fn build(
trip_id: Uuid,
category: &TripCategory, category: &TripCategory,
active: bool, active: bool,
biggest_category_weight: i64, biggest_category_weight: i64,
@@ -754,19 +823,17 @@ impl TripCategoryListRow {
id={"category-" (category.category.id)} id={"category-" (category.category.id)}
hx-swap-oob=[htmx_swap.then_some("outerHTML")] hx-swap-oob=[htmx_swap.then_some("outerHTML")]
."h-10" ."h-10"
."hover:bg-purple-100" ."hover:bg-gray-100"
."m-3" ."m-3"
."h-full" ."h-full"
."outline"[active] ."outline"[active]
."outline-2"[active] ."outline-2"[active]
."outline-indigo-300"[active] ."outline-indigo-300"[active]
{ {
td td
."border" ."border"
."m-0" ."m-0"
{ {
div div
."p-0" ."p-0"
@@ -783,6 +850,13 @@ impl TripCategoryListRow {
id=category.category.id id=category.category.id
) )
) )
hx-post={
"/trips/" (trip_id)
"/categories/" (category.category.id)
"/select"
}
hx-target="#trip-items"
hx-swap="outerHTML"
."inline-block" ."inline-block"
."p-2" ."p-2"
."m-0" ."m-0"
@@ -833,7 +907,8 @@ impl TripCategoryListRow {
* 100.0 * 100.0
) )
) )
) {} )
{}
} }
} }
) )
@@ -843,7 +918,7 @@ impl TripCategoryListRow {
pub struct TripCategoryList; pub struct TripCategoryList;
impl 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 categories = trip.categories();
let biggest_category_weight: i64 = categories let biggest_category_weight: i64 = categories
@@ -874,10 +949,10 @@ impl TripCategoryList {
} }
tbody { tbody {
@for category in trip.categories() { @for category in trip.categories() {
@let active = state.active_category_id.map_or(false, |id| category.category.id == id); @let active = active_category.map_or(false, |c| category.category.id == c.category.id);
(TripCategoryListRow::build(category, active, biggest_category_weight,false)) (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" { td ."border" ."p-0" ."m-0" {
p ."p-2" ."m-2" { "Sum" } p ."p-2" ."m-2" { "Sum" }
} }
@@ -896,22 +971,13 @@ impl TripCategoryList {
pub struct TripItemList; pub struct TripItemList;
impl TripItemList { impl TripItemList {
pub fn build(state: &ClientState, trip: &models::Trip, items: &Vec<TripItem>) -> Markup { pub fn build(trip_id: Uuid, items: &Vec<TripItem>) -> Markup {
let biggest_item_weight: i64 = items.iter().map(|item| item.item.weight).max().unwrap_or(1); let biggest_item_weight: i64 = items.iter().map(|item| item.item.weight).max().unwrap_or(1);
html!( html!(
@if items.is_empty() { @if items.is_empty() {
p ."text-lg" ."text-center" ."py-5" ."text-gray-400" { "[Empty]" } p ."text-lg" ."text-center" ."py-5" ."text-gray-400" { "[Empty]" }
} @else { } @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" ."table"
."table-auto" ."table-auto"
@@ -931,7 +997,7 @@ impl TripItemList {
} }
tbody { tbody {
@for item in items { @for item in items {
(TripItemListRow::build(trip.id, item, biggest_item_weight)) (TripItemListRow::build(trip_id, item, biggest_item_weight))
} }
} }
} }

View File

@@ -3,8 +3,12 @@ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
headers, headers,
headers::Header, headers::Header,
http::{header, header::HeaderMap, StatusCode}, http::{
response::{Html, Redirect}, header,
header::{HeaderMap, HeaderName, HeaderValue},
StatusCode,
},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post}, routing::{get, post},
Form, Router, Form, Router,
}; };
@@ -50,7 +54,9 @@ use clap::Parser;
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
struct Args { struct Args {
#[arg(long)] #[arg(long)]
port: Option<u16>, database_url: String,
#[arg(long, default_value_t = 3000)]
port: u16,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -78,19 +84,60 @@ impl Default for ClientState {
} }
} }
enum HtmxEvents {
TripItemEdited,
}
impl Into<HeaderValue> 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<HeaderName> for HtmxResponseHeaders {
fn into(self) -> HeaderName {
match self {
Self::Trigger => HeaderName::from_static("hx-trigger"),
}
}
}
enum HtmxRequestHeaders {
HtmxRequest,
}
impl Into<HeaderName> for HtmxRequestHeaders {
fn into(self) -> HeaderName {
match self {
Self::HtmxRequest => HeaderName::from_static("hx-request"),
}
}
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), sqlx::Error> { async fn main() -> Result<(), sqlx::Error> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG) .with_max_level(tracing::Level::DEBUG)
.init(); .init();
let args = Args::parse();
let database_pool = SqlitePoolOptions::new() let database_pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(5)
.connect_with( .connect_with(
SqliteConnectOptions::from_str( SqliteConnectOptions::from_str(&args.database_url)?.pragma("foreign_keys", "1"),
&std::env::var("DATABASE_URL").expect("env DATABASE_URL not found"),
)?
.pragma("foreign_keys", "1"),
) )
.await .await
.unwrap(); .unwrap();
@@ -118,12 +165,14 @@ async fn main() -> Result<(), sqlx::Error> {
"/trips/", "/trips/",
Router::new() Router::new()
.route("/", get(trips)) .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("/types/:id/edit/name/submit", post(trips_types_edit_name))
.route("/", post(trip_create)) .route("/", post(trip_create))
.route("/:id/", get(trip)) .route("/:id/", get(trip))
.route("/:id/comment/submit", post(trip_comment_set)) .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/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/add", get(trip_type_add))
.route("/:id/type/:id/remove", get(trip_type_remove)) .route("/:id/type/:id/remove", get(trip_type_remove))
.route("/:id/edit/:attribute/submit", post(trip_edit_attribute)) .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") }) .fallback(|| async { (StatusCode::NOT_FOUND, "not found") })
.with_state(state); .with_state(state);
let args = Args::parse(); let addr = SocketAddr::from(([127, 0, 0, 1], args.port));
let addr = SocketAddr::from(([127, 0, 0, 1], args.port.unwrap_or(3000)));
tracing::debug!("listening on {}", addr); tracing::debug!("listening on {}", addr);
axum::Server::bind(&addr) axum::Server::bind(&addr)
.serve(app.into_make_service()) .serve(app.into_make_service())
@@ -193,7 +240,43 @@ async fn inventory_active(
Query(inventory_query): Query<InventoryQuery>, Query(inventory_query): Query<InventoryQuery>,
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
state.client_state.edit_item = inventory_query.edit_item; 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( async fn inventory_inactive(
@@ -201,71 +284,53 @@ async fn inventory_inactive(
Query(inventory_query): Query<InventoryQuery>, Query(inventory_query): Query<InventoryQuery>,
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
state.client_state.edit_item = inventory_query.edit_item; state.client_state.edit_item = inventory_query.edit_item;
inventory(state, None).await state.client_state.active_category_id = None;
}
async fn inventory( let inventory = models::Inventory::load(&state.database_pool)
mut state: AppState, .await
active_id: Option<Uuid>, .map_err(|error| {
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { (
state.client_state.active_category_id = active_id; StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&error.to_string()),
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::<Vec<Result<Category, models::Error>>>()
.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::<Result<Vec<Category>, 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()),
)
})?;
}
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Root::build( Root::build(
&Inventory::build(state.client_state, categories).map_err(|e| match e { &components::Inventory::build(
Error::NotFound { description } => { None,
(StatusCode::NOT_FOUND, ErrorPage::build(&description)) &inventory.categories,
} state.client_state.edit_item,
_ => ( ),
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&e.to_string()),
),
})?,
&TopLevelPage::Inventory, &TopLevelPage::Inventory,
), ),
)) ))
} }
// async fn inventory(
// mut state: AppState,
// active_id: Option<Uuid>,
// ) -> 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)] #[derive(Deserialize)]
struct NewItem { struct NewItem {
#[serde(rename = "new-item-name")] #[serde(rename = "new-item-name")]
@@ -325,12 +390,13 @@ async fn inventory_item_validate_name(
async fn inventory_item_create( async fn inventory_item_create(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Form(new_item): Form<NewItem>, Form(new_item): Form<NewItem>,
) -> Result<Redirect, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, Markup)> {
if new_item.name.is_empty() { if new_item.name.is_empty() {
return Err(( return Err((
StatusCode::UNPROCESSABLE_ENTITY, 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 // SQLITE_CONSTRAINT_FOREIGNKEY
( (
StatusCode::BAD_REQUEST, 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" => { "2067" => {
// SQLITE_CONSTRAINT_UNIQUE // SQLITE_CONSTRAINT_UNIQUE
( (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!( ErrorPage::build(&format!(
"item with name \"{name}\" already exists in category {id}", "item with name \"{name}\" already exists in category {id}",
name = new_item.name, name = new_item.name,
id = new_item.category_id id = new_item.category_id
), )),
) )
} }
_ => ( _ => (
StatusCode::INTERNAL_SERVER_ERROR, 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 { } else {
( (
StatusCode::INTERNAL_SERVER_ERROR, 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, StatusCode::INTERNAL_SERVER_ERROR,
format!("got unknown error: {}", e.to_string()), ErrorPage::build(&format!("got unknown error: {}", e.to_string())),
), ),
})?; })?;
Ok(Redirect::to(&format!( if is_htmx(&headers) {
"/inventory/category/{id}/", let inventory = models::Inventory::load(&state.database_pool)
id = new_item.category_id .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( 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(( Ok((
StatusCode::OK, StatusCode::OK,
Root::build( Root::build(
&components::Trip::build(&state.client_state, &trip).map_err(|e| match e { &components::Trip::build(
Error::NotFound { description } => { &trip,
(StatusCode::NOT_FOUND, ErrorPage::build(&description)) state.client_state.trip_edit_attribute,
} active_category,
_ => ( ),
StatusCode::INTERNAL_SERVER_ERROR,
ErrorPage::build(&e.to_string()),
),
})?,
&TopLevelPage::Trips, &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))) Ok(html!((item_row)(category_row)))
} }
@@ -1083,9 +1209,18 @@ async fn trip_item_set_pick(
async fn trip_item_set_pick_htmx( async fn trip_item_set_pick_htmx(
State(state): State<AppState>, State(state): State<AppState>,
Path((trip_id, item_id)): Path<(Uuid, Uuid)>, 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?; 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::<HeaderName>(
HtmxResponseHeaders::Trigger.into(),
HtmxEvents::TripItemEdited.into(),
);
Ok((
StatusCode::OK,
headers,
trip_row(&state, trip_id, item_id).await?,
))
} }
async fn trip_item_set_unpick( async fn trip_item_set_unpick(
@@ -1117,9 +1252,18 @@ async fn trip_item_set_unpick(
async fn trip_item_set_unpick_htmx( async fn trip_item_set_unpick_htmx(
State(state): State<AppState>, State(state): State<AppState>,
Path((trip_id, item_id)): Path<(Uuid, Uuid)>, 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?; 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::<HeaderName>(
HtmxResponseHeaders::Trigger.into(),
HtmxEvents::TripItemEdited.into(),
);
Ok((
StatusCode::OK,
headers,
trip_row(&state, trip_id, item_id).await?,
))
} }
async fn trip_item_set_pack( async fn trip_item_set_pack(
@@ -1151,9 +1295,18 @@ async fn trip_item_set_pack(
async fn trip_item_set_pack_htmx( async fn trip_item_set_pack_htmx(
State(state): State<AppState>, State(state): State<AppState>,
Path((trip_id, item_id)): Path<(Uuid, Uuid)>, 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?; 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::<HeaderName>(
HtmxResponseHeaders::Trigger.into(),
HtmxEvents::TripItemEdited.into(),
);
Ok((
StatusCode::OK,
headers,
trip_row(&state, trip_id, item_id).await?,
))
} }
async fn trip_item_set_unpack( async fn trip_item_set_unpack(
@@ -1185,9 +1338,40 @@ async fn trip_item_set_unpack(
async fn trip_item_set_unpack_htmx( async fn trip_item_set_unpack_htmx(
State(state): State<AppState>, State(state): State<AppState>,
Path((trip_id, item_id)): Path<(Uuid, Uuid)>, 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?; 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::<HeaderName>(
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<AppState>,
Path(trip_id): Path<Uuid>,
) -> 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)] #[derive(Deserialize)]
@@ -1263,8 +1447,9 @@ async fn inventory_category_create(
async fn trip_state_set( async fn trip_state_set(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap,
Path((trip_id, new_state)): Path<(Uuid, TripState)>, Path((trip_id, new_state)): Path<(Uuid, TripState)>,
) -> Result<Redirect, (StatusCode, Markup)> { ) -> Result<impl IntoResponse, (StatusCode, Markup)> {
let trip_id = trip_id.to_string(); let trip_id = trip_id.to_string();
let result = query!( let result = query!(
"UPDATE trips "UPDATE trips
@@ -1283,10 +1468,25 @@ async fn trip_state_set(
ErrorPage::build(&format!("trip with id {id} not found", id = trip_id)), ErrorPage::build(&format!("trip with id {id} not found", id = trip_id)),
)) ))
} else { } 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::<HeaderName>(HtmxRequestHeaders::HtmxRequest.into())
.map(|value| value == "true")
.unwrap_or(false)
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct TripTypeQuery { struct TripTypeQuery {
edit: Option<Uuid>, edit: Option<Uuid>,
@@ -1500,3 +1700,44 @@ async fn inventory_item(
), ),
)) ))
} }
async fn trip_category_select(
State(state): State<AppState>,
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),
))
}

View File

@@ -25,7 +25,6 @@ pub enum Error {
Sql { description: String }, Sql { description: String },
Uuid { description: String }, Uuid { description: String },
Enum { description: String }, Enum { description: String },
NotFound { description: String },
Int { description: String }, Int { description: String },
TimeParse { description: String }, TimeParse { description: String },
} }
@@ -39,9 +38,6 @@ impl fmt::Display for Error {
Self::Uuid { description } => { Self::Uuid { description } => {
write!(f, "UUID error: {description}") write!(f, "UUID error: {description}")
} }
Self::NotFound { description } => {
write!(f, "Not found: {description}")
}
Self::Int { description } => { Self::Int { description } => {
write!(f, "Integer error: {description}") write!(f, "Integer error: {description}")
} }
@@ -132,14 +128,6 @@ impl TripState {
Self::Done => Some(Self::Review), 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 { impl fmt::Display for TripState {
@@ -467,7 +455,79 @@ pub enum TripAttribute {
TempMax, TempMax,
} }
pub struct DbTripWeightRow {
pub total_weight: Option<i32>,
}
impl<'a> Trip { impl<'a> Trip {
pub async fn find(
pool: &sqlx::Pool<sqlx::Sqlite>,
trip_id: Uuid,
) -> Result<Option<Self>, 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<sqlx::Sqlite>,
trip_id: Uuid,
) -> Result<Option<i64>, 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<TripType> { pub fn types(&'a self) -> &Vec<TripType> {
self.types self.types
.as_ref() .as_ref()
@@ -479,9 +539,7 @@ impl<'a> Trip {
.as_ref() .as_ref()
.expect("you need to call load_trips_types()") .expect("you need to call load_trips_types()")
} }
}
impl<'a> Trip {
pub fn total_picked_weight(&self) -> i64 { pub fn total_picked_weight(&self) -> i64 {
self.categories() self.categories()
.iter() .iter()
@@ -1067,3 +1125,38 @@ impl TryFrom<DbInventoryItemRow> for InventoryItem {
}) })
} }
} }
pub struct Inventory {
pub categories: Vec<Category>,
}
impl Inventory {
pub async fn load(pool: &sqlx::Pool<sqlx::Sqlite>) -> Result<Self, Error> {
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::<Vec<Result<Category, Error>>>()
.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::<Result<Vec<Category>, 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 })
}
}