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

View File

@@ -8,37 +8,36 @@ use uuid::{uuid, Uuid};
pub struct Inventory;
impl Inventory {
pub fn build(state: ClientState, categories: Vec<Category>) -> Result<Markup, Error> {
let doc = html!(
pub fn build(
active_category: Option<&Category>,
categories: &Vec<Category>,
edit_item_id: Option<Uuid>,
) -> 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<Category>) -> Markup {
pub fn build(active_category: Option<&Category>, categories: &Vec<Category>) -> 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<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);
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<Category>) -> Markup {
pub fn build(active_category: Option<&Category>, categories: &Vec<Category>) -> 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<Category>) -> Markup {
pub fn build(active_category: Option<&Category>, categories: &Vec<Category>) -> 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) }
}

View File

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

View File

@@ -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<models::Trip>) -> 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<models::Trip>) -> 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<Markup, Error> {
Ok(html!(
pub fn build(
trip: &models::Trip,
trip_edit_attribute: Option<TripAttribute>,
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=(<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"
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=(<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"
{
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<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 {
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<Markup, Error> {
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<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);
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))
}
}
}

View File

@@ -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<u16>,
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<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]
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<InventoryQuery>,
) -> 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<InventoryQuery>,
) -> 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<Uuid>,
) -> 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::<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()),
)
})?;
}
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<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)]
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<AppState>,
headers: HeaderMap,
Form(new_item): Form<NewItem>,
) -> Result<Redirect, (StatusCode, String)> {
) -> Result<impl IntoResponse, (StatusCode, Markup)> {
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<AppState>,
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::<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(
@@ -1117,9 +1252,18 @@ async fn trip_item_set_unpick(
async fn trip_item_set_unpick_htmx(
State(state): State<AppState>,
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::<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(
@@ -1151,9 +1295,18 @@ async fn trip_item_set_pack(
async fn trip_item_set_pack_htmx(
State(state): State<AppState>,
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::<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(
@@ -1185,9 +1338,40 @@ async fn trip_item_set_unpack(
async fn trip_item_set_unpack_htmx(
State(state): State<AppState>,
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::<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)]
@@ -1263,8 +1447,9 @@ async fn inventory_category_create(
async fn trip_state_set(
State(state): State<AppState>,
headers: HeaderMap,
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 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::<HeaderName>(HtmxRequestHeaders::HtmxRequest.into())
.map(|value| value == "true")
.unwrap_or(false)
}
#[derive(Debug, Deserialize)]
struct TripTypeQuery {
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 },
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<i32>,
}
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> {
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<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 })
}
}