diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 29b1c67..ab34402 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -43,6 +43,7 @@ version = "0.3.28" [dependencies.time] version = "0.3.21" +features = ["serde"] [dependencies.serde] version = "1.0.162" diff --git a/rust/src/components/inventory.rs b/rust/src/components/inventory.rs index 2b2f7b2..d5dbbfd 100644 --- a/rust/src/components/inventory.rs +++ b/rust/src/components/inventory.rs @@ -98,7 +98,7 @@ impl InventoryCategoryList { id="select-category" href=( format!( - "/inventory/category/{id}", + "/inventory/category/{id}/", id=category.id ) ) diff --git a/rust/src/components/mod.rs b/rust/src/components/mod.rs index 2cf073a..919b2f3 100644 --- a/rust/src/components/mod.rs +++ b/rust/src/components/mod.rs @@ -2,13 +2,11 @@ use maud::{html, Markup, DOCTYPE}; pub mod home; pub mod inventory; -pub mod triplist; - -mod theme; +pub mod trip; pub use home::*; pub use inventory::*; -pub use triplist::*; +pub use trip::*; pub struct Root { doc: Markup, diff --git a/rust/src/components/trip.rs b/rust/src/components/trip.rs new file mode 100644 index 0000000..fafb6be --- /dev/null +++ b/rust/src/components/trip.rs @@ -0,0 +1,349 @@ +use crate::models; +use crate::models::*; + +use maud::{html, Markup}; + +pub struct TripManager { + doc: Markup, +} + +impl TripManager { + pub fn build(trips: Vec) -> Self { + let doc = html!( + div ."p-8" { + (TripTable::build(trips).into_markup()) + (NewTrip::build().into_markup()) + } + ); + + Self { doc } + } +} + +pub struct TripTable { + doc: Markup, +} + +impl From for Markup { + fn from(val: TripManager) -> Self { + val.doc + } +} + +impl TripTable { + pub fn build(trips: Vec) -> Self { + let doc = html!( + h1 ."text-2xl" ."mb-5" {"Trips"} + table + ."table" + ."table-auto" + ."border-collapse" + ."border-spacing-0" + ."border" + ."w-full" + { + thead ."bg-gray-200" { + tr ."h-10" { + th ."border" ."p-2" { "Name" } + th ."border" ."p-2" { "From" } + th ."border" ."p-2" { "To" } + th ."border" ."p-2" { "Nights" } + th ."border" ."p-2" { "State" } + } + } + tbody { + @for trip in trips { + tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" { + td ."border" ."p-0" ."m-0" { + a ."inline-block" ."p-2" ."m-0" ."w-full" + href=(format!("/trip/{id}/", id=trip.id)) + { (trip.name) } + } + td ."border" ."p-0" ."m-0" { + a ."inline-block" ."p-2" ."m-0" ."w-full" + href=(format!("/trip/{id}/", id=trip.id)) + { (trip.start_date) } + } + td ."border" ."p-0" ."m-0" { + a ."inline-block" ."p-2" ."m-0" ."w-full" + href=(format!("/trip/{id}/", id=trip.id)) + { (trip.end_date) } + } + td ."border" ."p-0" ."m-0" { + a ."inline-block" ."p-2" ."m-0" ."w-full" + href=(format!("/trip/{id}/", id=trip.id)) + { ((trip.end_date - trip.start_date).whole_days()) } + } + td ."border" ."p-0" ."m-0" { + a ."inline-block" ."p-2" ."m-0" ."w-full" + href=(format!("/trip/{id}/", id=trip.id)) + { (trip.state.to_string()) } + } + } + } + } + } + ); + + Self { doc } + } + + pub fn into_markup(self) -> Markup { + self.doc + } +} + +pub struct NewTrip { + doc: Markup, +} + +impl NewTrip { + pub fn build() -> Self { + let doc = html!( + form + name="new_trip" + action="/trip/" + target="_self" + method="post" + ."mt-8" ."p-5" ."border-2" ."border-gray-200" + { + div ."mb-5" ."flex" ."flex-row" ."trips-center" { + span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {} + p ."inline" ."text-xl" { "Add new trip" } + } + div ."w-11/12" ."m-auto" { + div ."mx-auto" ."pb-8" { + div ."flex" ."flex-row" ."justify-center" { + label for="trip-name" ."font-bold" ."w-1/2" ."p-2" ."text-center" { "Name" } + span ."w-1/2" { + input + type="text" + id="trip-name" + name="new-trip-name" + ."block" + ."w-full" + ."p-2" + ."bg-gray-50" + ."border-2" + ."rounded" + ."focus:outline-none" + ."focus:bg-white" + {} + } + } + } + div ."mx-auto" ."pb-8" { + div ."flex" ."flex-row" ."justify-center" { + label for="trip-name" ."font-bold" ."w-1/2" ."p-2" ."text-center" { "Start date" } + span ."w-1/2" { + input + type="date" + id="start-date" + name="new-trip-start-date" + ."block" + ."w-full" + ."p-2" + ."bg-gray-50" + ."appearance-none" + ."border-2" + ."border-gray-300" + ."rounded" + ."focus:outline-none" + ."focus:bg-white" + ."focus:border-purple-500" + {} + } + } + } + div ."mx-auto" ."pb-8" { + div ."flex" ."flex-row" ."justify-center" { + label for="trip-name" ."font-bold" ."w-1/2" ."p-2" ."text-center" { "Start date" } + span ."w-1/2" { + input + type="date" + id="end-date" + name="new-trip-end-date" + ."block" + ."w-full" + ."p-2" + ."bg-gray-50" + ."appearance-none" + ."border-2" + ."border-gray-300" + ."rounded" + ."focus:outline-none" + ."focus:bg-white" + ."focus:border-purple-500" + {} + } + } + } + input + type="submit" + value="Add" + ."py-2" + ."border-2" + ."rounded" + ."border-gray-300" + ."mx-auto" + ."w-full" + {} + } + } + ); + + Self { doc } + } + + pub fn into_markup(self) -> Markup { + self.doc + } +} + +pub struct Trip { + doc: Markup, +} + +impl Trip { + pub fn build(trip: &models::Trip) -> Self { + let doc = html!( + div ."p-8" { + div ."flex" ."flex-row" ."items-center" ."gap-x-3" { + h1 ."text-2xl" ."font-semibold"{ (trip.name) } + } + div ."my-6" { + (TripInfo::build(&trip).into_markup()) + } + } + ); + + Self { doc } + } + + pub fn into_markup(self) -> Markup { + self.doc + } +} + +pub struct TripInfo { + doc: Markup, +} + +impl TripInfo { + pub fn build(trip: &models::Trip) -> Self { + let doc = html!( + table + ."table" + ."table-auto" + ."border-collapse" + ."border-spacing-0" + ."border" + ."w-full" + { + tbody { + tr { + td ."border" ."p-2" { "State" } + td ."border" ."p-2" { (trip.state.to_string()) } + } + tr { + td ."border" ."p-2" { "Location" } + td ."border" ."p-2" { (trip.location) } + } + tr { + td ."border" ."p-2" { "Start date" } + td ."border" ."p-2" { (trip.start_date) } + } + tr { + td ."border" ."p-2" { "End date" } + td ."border" ."p-2" { (trip.end_date) } + } + tr { + td ."border" ."p-2" { "Temp (min)" } + td ."border" ."p-2" { (trip.temp_min) } + } + tr { + td ."border" ."p-2" { "Temp (max)" } + td ."border" ."p-2" { (trip.temp_max) } + } + tr { + td ."border" ."p-2" { "Types" } + td ."border" ."p-2" { + ul + ."flex" + ."flex-row" + ."flex-wrap" + ."gap-2" + ."justify-between" + { + @let types = trip.types(); + div + ."flex" + ."flex-row" + ."flex-wrap" + ."gap-2" + ."justify-start" + { + @for triptype in types.iter().filter(|t| t.active) { + a href=(format!("type/{}/remove", triptype.id)) { + li + ."border" + ."rounded-2xl" + ."py-0.5" + ."px-2" + ."bg-green-100" + ."cursor-pointer" + ."flex" + ."flex-column" + ."items-center" + ."hover:bg-red-200" + ."gap-1" + { + span { (triptype.name) } + span ."mdi" ."mdi-delete" ."text-sm" {} + } + } + } + } + div + ."flex" + ."flex-row" + ."flex-wrap" + ."gap-2" + ."justify-start" + { + @for triptype in types.iter().filter(|t| !t.active) { + a href=(format!("type/{}/add", triptype.id)) { + li + ."border" + ."rounded-2xl" + ."py-0.5" + ."px-2" + ."bg-gray-100" + ."cursor-pointer" + ."flex" + ."flex-column" + ."items-center" + ."hover:bg-green-200" + ."gap-1" + ."opacity-60" + { + span { (triptype.name) } + span ."mdi" ."mdi-plus" ."text-sm" {} + } + } + } + } + } + } + } + } + } + ); + + Self { doc } + } + + pub fn into_markup(self) -> Markup { + self.doc + } +} diff --git a/rust/src/components/triplist.rs b/rust/src/components/triplist.rs deleted file mode 100644 index df5d541..0000000 --- a/rust/src/components/triplist.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::models::*; - -use maud::{html, Markup}; - -pub struct TripList { - doc: Markup, -} - -impl TripList { - pub fn build(package_lists: Vec) -> Self { - let doc = html!( - table { - thead { - td { - td { "ID" } - td { "Name" } - } - } - tbody { - @for list in package_lists { - tr { - td { (list.id.to_string()) } - td { (list.name) } - } - } - } - } - ); - - Self { doc } - } -} - -impl From for Markup { - fn from(val: TripList) -> Self { - val.doc - } -} diff --git a/rust/src/main.rs b/rust/src/main.rs index 52e1a3d..f24896f 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -82,9 +82,13 @@ async fn main() -> Result<(), sqlx::Error> { let app = Router::new() .route("/", get(root)) .route("/trips/", get(trips)) + .route("/trip/", post(trip_create)) + .route("/trip/:id/", get(trip)) + .route("/trip/:id/type/:id/add", get(trip_type_add)) + .route("/trip/:id/type/:id/remove", get(trip_type_remove)) .route("/inventory/", get(inventory_inactive)) .route("/inventory/item/", post(inventory_item_create)) - .route("/inventory/category/:id", get(inventory_active)) + .route("/inventory/category/:id/", get(inventory_active)) .route("/inventory/item/:id/delete", get(inventory_item_delete)) .route("/inventory/item/:id/edit", post(inventory_item_edit)) .route("/inventory/item/:id/cancel", get(inventory_item_cancel)) @@ -123,7 +127,7 @@ impl Default for InventoryQuery { } async fn inventory_active( - Path(id): Path, + Path(id): Path, State(mut state): State, Query(inventory_query): Query, ) -> Result<(StatusCode, Html), (StatusCode, Html)> { @@ -141,13 +145,8 @@ async fn inventory_inactive( async fn inventory( mut state: AppState, - active_id: Option, + active_id: Option, ) -> Result<(StatusCode, Html), (StatusCode, Html)> { - let active_id = active_id - .map(|id| Uuid::try_parse(&id)) - .transpose() - .map_err(|e| (StatusCode::BAD_REQUEST, Html::from(e.to_string())))?; - state.client_state.active_category_id = active_id; let mut categories = query("SELECT id,name,description FROM inventoryitemcategories") @@ -191,29 +190,6 @@ async fn inventory( )) } -async fn trips( - State(state): State, -) -> Result<(StatusCode, Html), (StatusCode, Html)> { - let trips = query("SELECT * FROM trips") - .fetch(&state.database_pool) - .map_ok(std::convert::TryInto::try_into) - .try_collect::>>() - .await - // we have two error handling lines here. these are distinct errors - // this one is the SQL error that may arise during the query - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))? - .into_iter() - .collect::, models::Error>>() - // and this one is the model mapping error that may arise e.g. during - // reading of the rows - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?; - - Ok(( - StatusCode::OK, - Html::from(Root::build(TripList::build(trips).into(), &TopLevelPage::Trips).into_string()), - )) -} - #[derive(Deserialize)] struct NewItem { #[serde(rename = "new-item-name")] @@ -285,7 +261,7 @@ async fn inventory_item_create( })?; Ok(Redirect::to(&format!( - "/inventory/category/{id}", + "/inventory/category/{id}/", id = new_item.category_id ))) } @@ -342,7 +318,7 @@ async fn inventory_item_delete( // "SELECT // i.id, i.name, i.description, i.weight, i.category_id // FROM inventoryitemcategories AS c -// LEFT JOIN inventoryitems AS i +// INNER JOIN inventoryitems AS i // ON i.category_id = c.id WHERE c.id = '{id}';", // id = id, // )) @@ -390,7 +366,7 @@ async fn inventory_item_edit( format!("item with id {id} not found", id = id), ))?; - Ok(Redirect::to(&format!("/inventory/category/{id}", id = id))) + Ok(Redirect::to(&format!("/inventory/category/{id}/", id = id))) } async fn inventory_item_cancel( @@ -406,7 +382,219 @@ async fn inventory_item_cancel( ))?; Ok(Redirect::to(&format!( - "/inventory/category/{id}", + "/inventory/category/{id}/", id = id.category_id ))) } + +#[derive(Deserialize)] +struct NewTrip { + #[serde(rename = "new-trip-name")] + name: String, + #[serde(rename = "new-trip-start-date")] + start_date: time::Date, + #[serde(rename = "new-trip-end-date")] + end_date: time::Date, +} + +async fn trip_create( + State(state): State, + Form(new_trip): Form, +) -> Result { + let id = Uuid::new_v4(); + query( + "INSERT INTO trips + (id, name, start_date, end_date) + VALUES + (?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(&new_trip.name) + .bind(new_trip.start_date) + .bind(new_trip.end_date) + .execute(&state.database_pool) + .await + .map_err(|e| match e { + sqlx::Error::Database(ref error) => { + let sqlite_error = error.downcast_ref::(); + if let Some(code) = sqlite_error.code() { + match &*code { + "2067" => { + // SQLITE_CONSTRAINT_UNIQUE + ( + StatusCode::BAD_REQUEST, + format!( + "trip with name \"{name}\" already exists", + name = new_trip.name, + ), + ) + } + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("got error with unknown code: {}", sqlite_error.to_string()), + ), + } + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("got error without code: {}", sqlite_error.to_string()), + ) + } + } + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("got unknown error: {}", e.to_string()), + ), + })?; + + Ok(Redirect::to(&format!("/trips/{id}/", id = id.to_string()))) +} + +async fn trips( + State(state): State, +) -> Result<(StatusCode, Html), (StatusCode, Html)> { + let trips: Vec = query("SELECT * FROM trips") + .fetch(&state.database_pool) + .map_ok(std::convert::TryInto::try_into) + .try_collect::>>() + .await + // we have two error handling lines here. these are distinct errors + // this one is the SQL error that may arise during the query + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))? + .into_iter() + .collect::, models::Error>>() + // and this one is the model mapping error that may arise e.g. during + // reading of the rows + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?; + + Ok(( + StatusCode::OK, + Html::from( + Root::build(TripManager::build(trips).into(), &TopLevelPage::Trips).into_string(), + ), + )) +} + +async fn trip( + Path(id): Path, + State(state): State, +) -> Result<(StatusCode, Html), (StatusCode, Html)> { + let mut trip: models::Trip = + query("SELECT id,name,start_date,end_date,state,location,temp_min,temp_max FROM trips WHERE id = ?") + .bind(id.to_string()) + .fetch_one(&state.database_pool) + .map_ok(std::convert::TryInto::try_into) + .await + .map_err(|e: sqlx::Error| match e { + sqlx::Error::RowNotFound => ( + StatusCode::NOT_FOUND, + Html::from(format!("trip with id {} not found", id)), + ), + _ => (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())), + })? + .map_err(|e: Error| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?; + + trip.load_triptypes(&state.database_pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Html::from(e.to_string())))?; + + Ok(( + StatusCode::OK, + Html::from( + Root::build( + components::Trip::build(&trip).into_markup(), + &TopLevelPage::Trips, + ) + .into_string(), + ), + )) +} + +async fn trip_type_remove( + Path((trip_id, type_id)): Path<(Uuid, Uuid)>, + State(state): State, +) -> Result)> { + let results = query( + "DELETE FROM trips_to_triptypes AS ttt + WHERE ttt.trip_id = ? + AND ttt.trip_type_id = ? + ", + ) + .bind(trip_id.to_string()) + .bind(type_id.to_string()) + .execute(&state.database_pool) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, Html::from(e.to_string())))?; + + if results.rows_affected() == 0 { + Err(( + StatusCode::NOT_FOUND, + Html::from(format!("type {type_id} is not active for trip {trip_id}")), + )) + } else { + Ok(Redirect::to(&format!("/trip/{trip_id}/"))) + } +} + +async fn trip_type_add( + Path((trip_id, type_id)): Path<(Uuid, Uuid)>, + State(state): State, +) -> Result)> { + let results = query( + "INSERT INTO trips_to_triptypes + (trip_id, trip_type_id) VALUES (?, ?)", + ) + .bind(trip_id.to_string()) + .bind(type_id.to_string()) + .execute(&state.database_pool) + .await + .map_err(|e| match e { + sqlx::Error::Database(ref error) => { + let sqlite_error = error.downcast_ref::(); + if let Some(code) = sqlite_error.code() { + match &*code { + "787" => { + // SQLITE_CONSTRAINT_FOREIGNKEY + ( + StatusCode::BAD_REQUEST, + // TODO: this is not perfect, as both foreign keys + // may be responsible for the error. how can we tell + // which one? + Html::from(format!("invalid id: {}", code.to_string())), + ) + } + "2067" => { + // SQLITE_CONSTRAINT_UNIQUE + ( + StatusCode::BAD_REQUEST, + Html::from(format!( + "type {type_id} is already active for trip {trip_id}" + )), + ) + } + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + Html::from(format!( + "got error with unknown code: {}", + sqlite_error.to_string() + )), + ), + } + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Html::from(format!( + "got error without code: {}", + sqlite_error.to_string() + )), + ) + } + } + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + Html::from(format!("got unknown error: {}", e.to_string())), + ), + })?; + + Ok(Redirect::to(&format!("/trip/{trip_id}/"))) +} diff --git a/rust/src/models.rs b/rust/src/models.rs index 8467687..2469142 100644 --- a/rust/src/models.rs +++ b/rust/src/models.rs @@ -1,7 +1,13 @@ -use sqlx::{sqlite::SqliteRow, Row}; +use sqlx::{ + database::Database, + database::HasValueRef, + sqlite::{Sqlite, SqliteRow}, + Decode, Row, +}; use std::convert; use std::error; use std::fmt; +use std::str::FromStr; use uuid::Uuid; use sqlx::sqlite::SqlitePoolOptions; @@ -13,6 +19,7 @@ pub enum Error { SqlError { description: String }, UuidError { description: String }, NotFoundError { description: String }, + InvalidEnumError { description: String }, } impl fmt::Display for Error { @@ -27,6 +34,9 @@ impl fmt::Display for Error { Self::NotFoundError { description } => { write!(f, "Not found: {description}") } + Self::InvalidEnumError { description } => { + write!(f, "Enum error: {description}") + } } } } @@ -56,11 +66,41 @@ impl convert::From for Error { impl error::Error for Error {} +#[derive(sqlx::Type)] +pub enum TripState { + Planning, + Planned, + Active, + Review, + Done, +} + +impl fmt::Display for TripState { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Planning => "Planning", + Self::Planned => "Planned", + Self::Active => "Active", + Self::Review => "Review", + Self::Done => "Done", + }, + ) + } +} + pub struct Trip { pub id: Uuid, pub name: String, pub start_date: time::Date, pub end_date: time::Date, + pub state: TripState, + pub location: String, + pub temp_min: i32, + pub temp_max: i32, + types: Option>, } impl TryFrom for Trip { @@ -70,18 +110,91 @@ impl TryFrom for Trip { let name: &str = row.try_get("name")?; let id: &str = row.try_get("id")?; let start_date: time::Date = row.try_get("start_date")?; - let end_date: time::Date = row.try_get("start_date")?; + let end_date: time::Date = row.try_get("end_date")?; + let state: TripState = row.try_get("state")?; + let location = row.try_get("location")?; + let temp_min = row.try_get("temp_min")?; + let temp_max = row.try_get("temp_max")?; + let id: Uuid = Uuid::try_parse(id)?; Ok(Trip { - name: name.to_string(), id, + name: name.to_string(), start_date, end_date, + state, + location, + temp_min, + temp_max, + types: None, }) } } +impl<'a> Trip { + pub fn types(&'a self) -> &Vec { + self.types + .as_ref() + .expect("you need to call load_triptypes()") + } +} + +impl<'a> Trip { + pub async fn load_triptypes( + &'a mut self, + pool: &sqlx::Pool, + ) -> Result<(), Error> { + let types = sqlx::query( + " + SELECT + type.id as id, + type.name as name, + CASE WHEN inner.id IS NOT NULL THEN true ELSE false END AS active + FROM triptypes AS type + LEFT JOIN ( + SELECT type.id as id, type.name as name + FROM trips as trip + INNER JOIN trips_to_triptypes as ttt + ON ttt.trip_id = trip.id + INNER JOIN triptypes AS type + ON type.id == ttt.trip_type_id + WHERE trip.id = ? + ) AS inner + ON inner.id = type.id + ", + ) + .bind(self.id.to_string()) + .fetch(pool) + .map_ok(std::convert::TryInto::try_into) + .try_collect::>>() + .await? + .into_iter() + .collect::, Error>>()?; + + self.types = Some(types); + Ok(()) + } +} + +pub struct TripType { + pub id: Uuid, + pub name: String, + pub active: bool, +} + +impl TryFrom for TripType { + type Error = Error; + + fn try_from(row: SqliteRow) -> Result { + let id: Uuid = Uuid::try_parse(row.try_get("id")?)?; + let name: String = row.try_get::<&str, _>("name")?.to_string(); + let active: bool = row.try_get("active")?; + + Ok(Self { id, name, active }) + } +} + #[derive(Debug)] pub struct Category { pub id: Uuid,