This commit is contained in:
2023-05-18 00:11:52 +02:00
parent f03814e622
commit 0ddeac69e6
7 changed files with 692 additions and 81 deletions

View File

@@ -43,6 +43,7 @@ version = "0.3.28"
[dependencies.time]
version = "0.3.21"
features = ["serde"]
[dependencies.serde]
version = "1.0.162"

View File

@@ -98,7 +98,7 @@ impl InventoryCategoryList {
id="select-category"
href=(
format!(
"/inventory/category/{id}",
"/inventory/category/{id}/",
id=category.id
)
)

View File

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

349
rust/src/components/trip.rs Normal file
View File

@@ -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<models::Trip>) -> 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<TripManager> for Markup {
fn from(val: TripManager) -> Self {
val.doc
}
}
impl TripTable {
pub fn build(trips: Vec<models::Trip>) -> 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
}
}

View File

@@ -1,38 +0,0 @@
use crate::models::*;
use maud::{html, Markup};
pub struct TripList {
doc: Markup,
}
impl TripList {
pub fn build(package_lists: Vec<Trip>) -> 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<TripList> for Markup {
fn from(val: TripList) -> Self {
val.doc
}
}

View File

@@ -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<String>,
Path(id): Path<Uuid>,
State(mut state): State<AppState>,
Query(inventory_query): Query<InventoryQuery>,
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
@@ -141,13 +145,8 @@ async fn inventory_inactive(
async fn inventory(
mut state: AppState,
active_id: Option<String>,
active_id: Option<Uuid>,
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
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<AppState>,
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
let trips = query("SELECT * FROM trips")
.fetch(&state.database_pool)
.map_ok(std::convert::TryInto::try_into)
.try_collect::<Vec<Result<Trip, 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, Html::from(e.to_string())))?
.into_iter()
.collect::<Result<Vec<Trip>, 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<AppState>,
Form(new_trip): Form<NewTrip>,
) -> Result<Redirect, (StatusCode, String)> {
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::<SqliteError>();
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<AppState>,
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
let trips: Vec<models::Trip> = query("SELECT * FROM trips")
.fetch(&state.database_pool)
.map_ok(std::convert::TryInto::try_into)
.try_collect::<Vec<Result<models::Trip, 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, Html::from(e.to_string())))?
.into_iter()
.collect::<Result<Vec<models::Trip>, 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<Uuid>,
State(state): State<AppState>,
) -> Result<(StatusCode, Html<String>), (StatusCode, Html<String>)> {
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<AppState>,
) -> Result<Redirect, (StatusCode, Html<String>)> {
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<AppState>,
) -> Result<Redirect, (StatusCode, Html<String>)> {
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::<SqliteError>();
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}/")))
}

View File

@@ -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<sqlx::Error> 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<Vec<TripType>>,
}
impl TryFrom<SqliteRow> for Trip {
@@ -70,18 +110,91 @@ impl TryFrom<SqliteRow> 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<TripType> {
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<sqlx::Sqlite>,
) -> 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::<Vec<Result<TripType, Error>>>()
.await?
.into_iter()
.collect::<Result<Vec<TripType>, Error>>()?;
self.types = Some(types);
Ok(())
}
}
pub struct TripType {
pub id: Uuid,
pub name: String,
pub active: bool,
}
impl TryFrom<SqliteRow> for TripType {
type Error = Error;
fn try_from(row: SqliteRow) -> Result<Self, Self::Error> {
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,