From 6a6c62d73673c36cb2c833ecce128c60491cb4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Wed, 13 Sep 2023 00:44:59 +0200 Subject: [PATCH] add todos --- ...376e1df403cb81ef173cb8811a5481186db7a.json | 32 ++++ ...98c788f88be295b6171f2f36e08c91109e380.json | 32 ++++ ...b3b87f29e85ccdf67dc40f9ce372d5db9727d.json | 12 ++ migrations/20230911175138_todos.sql | 8 + src/models/inventory.rs | 1 - src/models/{trips.rs => trips/mod.rs} | 22 ++- src/models/trips/todos.rs | 176 ++++++++++++++++++ src/routing/mod.rs | 8 + src/routing/routes.rs | 96 ++++++++++ src/sqlite.rs | 5 + src/view/trip/mod.rs | 108 +++++++++++ 11 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 .sqlx/query-17dfc8ae16d077ed71976012315376e1df403cb81ef173cb8811a5481186db7a.json create mode 100644 .sqlx/query-a9e75a36e019bb54ff06443a2ce98c788f88be295b6171f2f36e08c91109e380.json create mode 100644 .sqlx/query-d29d72b5c9dbf34d672aa271823b3b87f29e85ccdf67dc40f9ce372d5db9727d.json rename src/models/{trips.rs => trips/mod.rs} (98%) create mode 100644 src/models/trips/todos.rs diff --git a/.sqlx/query-17dfc8ae16d077ed71976012315376e1df403cb81ef173cb8811a5481186db7a.json b/.sqlx/query-17dfc8ae16d077ed71976012315376e1df403cb81ef173cb8811a5481186db7a.json new file mode 100644 index 0000000..ef9ef29 --- /dev/null +++ b/.sqlx/query-17dfc8ae16d077ed71976012315376e1df403cb81ef173cb8811a5481186db7a.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n todo.id AS id,\n todo.description AS description,\n todo.done AS done\n FROM trip_todos AS todo\n INNER JOIN trips\n ON trips.id = todo.trip_id\n WHERE \n trips.id = $1\n AND trips.user_id = $2\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "done", + "ordinal": 2, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "17dfc8ae16d077ed71976012315376e1df403cb81ef173cb8811a5481186db7a" +} diff --git a/.sqlx/query-a9e75a36e019bb54ff06443a2ce98c788f88be295b6171f2f36e08c91109e380.json b/.sqlx/query-a9e75a36e019bb54ff06443a2ce98c788f88be295b6171f2f36e08c91109e380.json new file mode 100644 index 0000000..307d9ee --- /dev/null +++ b/.sqlx/query-a9e75a36e019bb54ff06443a2ce98c788f88be295b6171f2f36e08c91109e380.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n todo.id AS id,\n todo.description AS description,\n todo.done AS done\n FROM trip_todos AS todo\n INNER JOIN trips\n ON trips.id = todo.trip_id\n WHERE \n trips.id = $1\n AND todo.id = $2\n AND trips.user_id = $3\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "done", + "ordinal": 2, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "a9e75a36e019bb54ff06443a2ce98c788f88be295b6171f2f36e08c91109e380" +} diff --git a/.sqlx/query-d29d72b5c9dbf34d672aa271823b3b87f29e85ccdf67dc40f9ce372d5db9727d.json b/.sqlx/query-d29d72b5c9dbf34d672aa271823b3b87f29e85ccdf67dc40f9ce372d5db9727d.json new file mode 100644 index 0000000..c187050 --- /dev/null +++ b/.sqlx/query-d29d72b5c9dbf34d672aa271823b3b87f29e85ccdf67dc40f9ce372d5db9727d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE trip_todos\n SET done = ?\n WHERE trip_id = ?\n AND id = ?\n AND EXISTS(SELECT 1 FROM trips WHERE id = ? AND user_id = ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "d29d72b5c9dbf34d672aa271823b3b87f29e85ccdf67dc40f9ce372d5db9727d" +} diff --git a/migrations/20230911175138_todos.sql b/migrations/20230911175138_todos.sql index 8ddc1d3..6748341 100644 --- a/migrations/20230911175138_todos.sql +++ b/migrations/20230911175138_todos.sql @@ -1 +1,9 @@ -- Add migration script here +CREATE TABLE "trip_todos" ( + id VARCHAR(36) NOT NULL, + trip_id VARCHAR(36) NOT NULL, + description TEXT NOT NULL, + done BOOLEAN NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(trip_id) REFERENCES "trips" (id) +) diff --git a/src/models/inventory.rs b/src/models/inventory.rs index ef47d13..3051e47 100644 --- a/src/models/inventory.rs +++ b/src/models/inventory.rs @@ -1,7 +1,6 @@ use super::Error; use crate::{sqlite, Context}; -use futures::{TryFutureExt, TryStreamExt}; use uuid::Uuid; pub struct Inventory { diff --git a/src/models/trips.rs b/src/models/trips/mod.rs similarity index 98% rename from src/models/trips.rs rename to src/models/trips/mod.rs index 5f8eb3c..879d94c 100644 --- a/src/models/trips.rs +++ b/src/models/trips/mod.rs @@ -8,11 +8,12 @@ use super::{ use crate::{sqlite, Context}; -use futures::{TryFutureExt, TryStreamExt}; use serde::{Deserialize, Serialize}; use time; use uuid::Uuid; +pub mod todos; + // #[macro_use] // mod macros { // macro_rules! build_state_query { @@ -491,6 +492,7 @@ impl TryFrom for Trip { temp_min: row.temp_min, temp_max: row.temp_max, comment: row.comment, + todos: None, types: None, categories: None, }) @@ -508,8 +510,9 @@ pub struct Trip { pub temp_min: Option, pub temp_max: Option, pub comment: Option, - pub(crate) types: Option>, - pub(crate) categories: Option>, + pub todos: Option>, + pub types: Option>, + pub categories: Option>, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -990,7 +993,12 @@ impl Trip { pub fn categories(&self) -> &Vec { self.categories .as_ref() - .expect("you need to call load_trips_types()") + .expect("you need to call load_categories()") + } + + #[tracing::instrument] + pub fn todos(&self) -> &Vec { + self.todos.as_ref().expect("you need to call load_todos()") } #[tracing::instrument] @@ -1009,6 +1017,12 @@ impl Trip { .sum::() } + #[tracing::instrument] + pub async fn load_todos(&mut self, ctx: &Context, pool: &sqlite::Pool) -> Result<(), Error> { + self.todos = Some(todos::Todo::load(ctx, pool, self.id).await?); + Ok(()) + } + #[tracing::instrument] pub async fn load_trips_types( &mut self, diff --git a/src/models/trips/todos.rs b/src/models/trips/todos.rs new file mode 100644 index 0000000..f0a7782 --- /dev/null +++ b/src/models/trips/todos.rs @@ -0,0 +1,176 @@ +use uuid::Uuid; + +use crate::{ + models::{Error, QueryError}, + sqlite, Context, +}; + +#[derive(Debug, PartialEq, Eq)] +pub enum State { + Todo, + Done, +} + +impl From for State { + fn from(done: bool) -> Self { + if done { + Self::Done + } else { + Self::Todo + } + } +} + +impl From for bool { + fn from(value: State) -> Self { + match value { + State::Todo => false, + State::Done => true, + } + } +} + +#[derive(Debug)] +pub struct Todo { + pub id: Uuid, + pub description: String, + pub state: State, +} + +struct TodoRow { + id: String, + description: String, + done: bool, +} + +impl TryFrom for Todo { + type Error = Error; + + fn try_from(row: TodoRow) -> Result { + Ok(Todo { + id: Uuid::try_parse(&row.id)?, + description: row.description, + state: row.done.into(), + }) + } +} + +impl Todo { + pub fn is_done(&self) -> bool { + self.state == State::Done + } + + pub async fn load( + ctx: &Context, + pool: &sqlite::Pool, + trip_id: Uuid, + ) -> Result, Error> { + let trip_id_param = trip_id.to_string(); + let user_id = ctx.user.id.to_string(); + + let todos: Vec = crate::query_all!( + &sqlite::QueryClassification { + query_type: sqlite::QueryType::Select, + component: sqlite::Component::Todo, + }, + pool, + TodoRow, + Todo, + r#" + SELECT + todo.id AS id, + todo.description AS description, + todo.done AS done + FROM trip_todos AS todo + INNER JOIN trips + ON trips.id = todo.trip_id + WHERE + trips.id = $1 + AND trips.user_id = $2 + "#, + trip_id_param, + user_id, + ) + .await?; + + Ok(todos) + } + + #[tracing::instrument] + pub async fn find( + ctx: &Context, + pool: &sqlite::Pool, + trip_id: Uuid, + todo_id: Uuid, + ) -> Result, Error> { + let trip_id_param = trip_id.to_string(); + let todo_id_param = todo_id.to_string(); + let user_id = ctx.user.id.to_string(); + crate::query_one!( + &sqlite::QueryClassification { + query_type: sqlite::QueryType::Select, + component: sqlite::Component::Todo, + }, + pool, + TodoRow, + Self, + r#" + SELECT + todo.id AS id, + todo.description AS description, + todo.done AS done + FROM trip_todos AS todo + INNER JOIN trips + ON trips.id = todo.trip_id + WHERE + trips.id = $1 + AND todo.id = $2 + AND trips.user_id = $3 + "#, + trip_id_param, + todo_id_param, + user_id, + ) + .await + } + + #[tracing::instrument] + pub async fn set_state( + ctx: &Context, + pool: &sqlite::Pool, + trip_id: Uuid, + todo_id: Uuid, + state: State, + ) -> Result<(), Error> { + let user_id = ctx.user.id.to_string(); + let trip_id_param = trip_id.to_string(); + let todo_id_param = todo_id.to_string(); + let done = state == State::Done; + + let result = crate::execute!( + &sqlite::QueryClassification { + query_type: sqlite::QueryType::Update, + component: sqlite::Component::Trips, + }, + pool, + r#" + UPDATE trip_todos + SET done = ? + WHERE trip_id = ? + AND id = ? + AND EXISTS(SELECT 1 FROM trips WHERE id = ? AND user_id = ?)"#, + done, + trip_id_param, + todo_id_param, + trip_id_param, + user_id + ) + .await?; + + (result.rows_affected() != 0).then_some(()).ok_or_else(|| { + Error::Query(QueryError::NotFound { + description: format!("todo {todo_id} not found for trip {trip_id}"), + }) + }) + } +} diff --git a/src/routing/mod.rs b/src/routing/mod.rs index 68d1d4d..43d97f3 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -139,6 +139,14 @@ pub fn router(state: AppState) -> Router { .route( "/:id/items/:id/unready", get(trip_item_set_unready).post(trip_item_set_unready_htmx), + ) + .route( + "/:id/todo/:id/done", + get(trip_todo_done).post(trip_todo_done_htmx), + ) + .route( + "/:id/todo/:id/undone", + get(trip_todo_undone).post(trip_todo_undone_htmx), ), ) .nest( diff --git a/src/routing/routes.rs b/src/routing/routes.rs index c83f291..ca635e8 100644 --- a/src/routing/routes.rs +++ b/src/routing/routes.rs @@ -437,6 +437,8 @@ pub async fn trip( trip.load_trips_types(&ctx, &state.database_pool).await?; + trip.load_todos(&ctx, &state.database_pool).await?; + trip.sync_trip_items_with_inventory(&ctx, &state.database_pool) .await?; @@ -1238,3 +1240,97 @@ pub async fn trip_item_packagelist_set_unready_htmx( trip_id, &item, )) } + +#[tracing::instrument] +pub async fn trip_todo_done_htmx( + Extension(current_user): Extension, + State(state): State, + Path((trip_id, todo_id)): Path<(Uuid, Uuid)>, +) -> Result { + let ctx = Context::build(current_user); + models::trips::todos::Todo::set_state( + &ctx, + &state.database_pool, + trip_id, + todo_id, + models::trips::todos::State::Done, + ) + .await?; + + let todo_item = models::trips::todos::Todo::find(&ctx, &state.database_pool, trip_id, todo_id) + .await? + .ok_or_else(|| { + Error::Request(RequestError::NotFound { + message: format!("todo with id {todo_id} not found"), + }) + })?; + + Ok(view::trip::TripTodo::build(&trip_id, &todo_item)) +} + +#[tracing::instrument] +pub async fn trip_todo_done( + Extension(current_user): Extension, + State(state): State, + Path((trip_id, todo_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, +) -> Result { + let ctx = Context::build(current_user); + models::trips::todos::Todo::set_state( + &ctx, + &state.database_pool, + trip_id, + todo_id, + models::trips::todos::State::Done, + ) + .await?; + + Ok(Redirect::to(get_referer(&headers)?)) +} + +#[tracing::instrument] +pub async fn trip_todo_undone_htmx( + Extension(current_user): Extension, + State(state): State, + Path((trip_id, todo_id)): Path<(Uuid, Uuid)>, +) -> Result { + let ctx = Context::build(current_user); + models::trips::todos::Todo::set_state( + &ctx, + &state.database_pool, + trip_id, + todo_id, + models::trips::todos::State::Todo, + ) + .await?; + + let todo_item = models::trips::todos::Todo::find(&ctx, &state.database_pool, trip_id, todo_id) + .await? + .ok_or_else(|| { + Error::Request(RequestError::NotFound { + message: format!("todo with id {todo_id} not found"), + }) + })?; + + Ok(view::trip::TripTodo::build(&trip_id, &todo_item)) +} + +#[tracing::instrument] +pub async fn trip_todo_undone( + Extension(current_user): Extension, + State(state): State, + Path((trip_id, todo_id)): Path<(Uuid, Uuid)>, + headers: HeaderMap, +) -> Result { + let ctx = Context::build(current_user); + models::trips::todos::Todo::set_state( + &ctx, + &state.database_pool, + trip_id, + todo_id, + models::trips::todos::State::Todo, + ) + .await?; + + Ok(Redirect::to(get_referer(&headers)?)) +} diff --git a/src/sqlite.rs b/src/sqlite.rs index 3511a4f..1ef600e 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -89,6 +89,7 @@ pub enum Component { Inventory, User, Trips, + Todo, } impl fmt::Display for Component { @@ -100,6 +101,7 @@ impl fmt::Display for Component { Self::Inventory => "inventory", Self::User => "user", Self::Trips => "trips", + Self::Todo => "todo", } ) } @@ -167,6 +169,7 @@ macro_rules! query_all { ( $class:expr, $pool:expr, $struct_row:path, $struct_into:path, $query:expr, $( $args:tt )* ) => { { use tracing::Instrument as _; + use futures::TryStreamExt as _; async { $crate::sqlite::sqlx_query($class, $query, &[]); let result: Result, Error> = sqlx::query_as!( @@ -293,6 +296,7 @@ macro_rules! execute_returning { ( $class:expr, $pool:expr, $query:expr, $t:path, $fn:expr, $( $args:tt )*) => { { use tracing::Instrument as _; + use futures::TryFutureExt as _; async { $crate::sqlite::sqlx_query($class, $query, &[]); let result: Result<$t, Error> = sqlx::query!( @@ -317,6 +321,7 @@ macro_rules! execute_returning_uuid { ( $class:expr, $pool:expr, $query:expr, $( $args:tt )*) => { { use tracing::Instrument as _; + use futures::TryFutureExt as _; async { $crate::sqlite::sqlx_query($class, $query, &[]); let result: Result = sqlx::query!( diff --git a/src/view/trip/mod.rs b/src/view/trip/mod.rs index 794b444..143bd94 100644 --- a/src/view/trip/mod.rs +++ b/src/view/trip/mod.rs @@ -370,6 +370,7 @@ impl Trip { } } (TripInfo::build(trip_edit_attribute, trip)) + (TripTodoList::build(trip)) (TripComment::build(trip)) (TripItems::build(active_category, trip)) } @@ -794,6 +795,113 @@ impl TripInfo { } } +pub struct TripTodo; + +impl TripTodo { + #[tracing::instrument] + pub fn build(trip_id: &Uuid, todo: &models::trips::todos::Todo) -> Markup { + let done = todo.is_done(); + html!( + li + ."flex" + ."flex-row" + ."justify-start" + ."items-stretch" + ."bg-green-50"[done] + ."bg-red-50"[!done] + ."hover:bg-white"[!done] + ."h-full" + { + @if done { + a + ."flex" + ."flex-row" + ."aspect-square" + href={ + "/trips/" (trip_id) + "/todo/" (todo.id) + "/undone" + } + hx-post={ + "/trips/" (trip_id) + "/todo/" (todo.id) + "/undone" + } + hx-target="closest li" + hx-swap="outerHTML" + { + span + ."mdi" + ."m-auto" + ."text-xl" + ."mdi-check" + {} + } + } @else { + a + ."flex" + ."flex-row" + ."aspect-square" + href={ + "/trips/" (trip_id) + "/todo/" (todo.id) + "/done" + } + hx-post={ + "/trips/" (trip_id) + "/todo/" (todo.id) + "/done" + } + hx-target="closest li" + hx-swap="outerHTML" + { + span + ."mdi" + ."m-auto" + ."text-xl" + ."mdi-checkbox-blank-outline" + {} + } + } + span + ."p-2" + { + (todo.description) + } + } + ) + } +} + +pub struct TripTodoList; + +impl TripTodoList { + #[tracing::instrument] + pub fn build(trip: &models::trips::Trip) -> Markup { + let todos = trip.todos(); + html!( + div { + h1 ."text-xl" ."mb-5" { "Todos" } + + @if todos.is_empty() { + p { "no todos" } + + } @else { + + ul + ."flex" + ."flex-col" + { + @for todo in trip.todos() { + (TripTodo::build(&trip.id, &todo)) + } + } + } + } + ) + } +} + pub struct TripComment; impl TripComment {