todos work

This commit is contained in:
2023-09-13 17:09:09 +02:00
committed by Hannes Körber
parent 6a6c62d736
commit 838263b091
9 changed files with 667 additions and 116 deletions

View File

@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "UPDATE trip_todos\n SET description = ?\n WHERE \n id = ? \n AND trip_id = ?\n AND EXISTS(SELECT 1 FROM trips WHERE trip_id = ? AND user_id = ?)\n RETURNING\n id,\n description,\n done\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": 5
},
"nullable": [
false,
false,
false
]
},
"hash": "33cba5d1fbcfb492f8f8443782c45f9326e3fa966b3f8e864b3e01d4fe7b25b8"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM trip_todos\n WHERE \n id = ?\n AND EXISTS (SELECT 1 FROM trips WHERE trip_id = ? AND user_id = ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "5ac0e60ed79f626300f0dfde880f92d4eae3aa4281eafc2ac29fdb83525e0536"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO trip_todos\n (id, description, done, trip_id)\n SELECT ?, ?, false, id as trip_id\n FROM trips\n WHERE trip_id = ? AND EXISTS(SELECT 1 FROM trips WHERE id = ? and user_id = ?)\n LIMIT 1",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "9784595191d25448b2a24c856288d8fa3ba73c423cafcaa555c6f0a588b622a3"
}

View File

@@ -8,9 +8,14 @@ name = "packager"
path = "src/main.rs" path = "src/main.rs"
[features] [features]
jaeger = [] jaeger = [
prometheus = [] "dep:opentelemetry",
tokio-console = [] "dep:tracing-opentelemetry",
"dep:opentelemetry-jaeger",
"tokio/tracing"
]
prometheus = ["dep:axum-prometheus"]
tokio-console = ["dep:console-subscriber"]
default = ["jaeger", "prometheus", "tokio-console"] default = ["jaeger", "prometheus", "tokio-console"]
@@ -20,9 +25,11 @@ lto = "off"
[dependencies.opentelemetry] [dependencies.opentelemetry]
version = "0.20" version = "0.20"
optional = true
[dependencies.tracing-opentelemetry] [dependencies.tracing-opentelemetry]
version = "0.21" version = "0.21"
optional = true
[dependencies.tracing-log] [dependencies.tracing-log]
version = "0.1" version = "0.1"
@@ -30,6 +37,7 @@ version = "0.1"
[dependencies.opentelemetry-jaeger] [dependencies.opentelemetry-jaeger]
version = "0.19" version = "0.19"
features = ["rt-tokio"] features = ["rt-tokio"]
optional = true
[dependencies.http] [dependencies.http]
version = "0.2" version = "0.2"
@@ -47,10 +55,11 @@ features = ["headers"]
[dependencies.tokio] [dependencies.tokio]
version = "1" version = "1"
features = ["macros", "rt-multi-thread", "tracing"] features = ["macros", "rt-multi-thread"]
[dependencies.console-subscriber] [dependencies.console-subscriber]
version = "0.1" version = "0.1"
optional = true
[dependencies.hyper] [dependencies.hyper]
version = "0.14" version = "0.14"
@@ -58,6 +67,7 @@ features = ["full"]
[dependencies.tower] [dependencies.tower]
version = "0.4" version = "0.4"
features = ["timeout"]
[dependencies.tower-http] [dependencies.tower-http]
version = "0.4" version = "0.4"
@@ -114,6 +124,7 @@ version = "0.1"
[dependencies.axum-prometheus] [dependencies.axum-prometheus]
version = "0.4" version = "0.4"
optional = true
[dependencies.metrics] [dependencies.metrics]
version = "0.21" version = "0.21"

View File

@@ -1,3 +1,4 @@
use maud::{html, Markup};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@@ -5,6 +6,8 @@ use crate::{
sqlite, Context, sqlite, Context,
}; };
use super::Trip;
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum State { pub enum State {
Todo, Todo,
@@ -173,4 +176,408 @@ impl Todo {
}) })
}) })
} }
pub async fn new(
ctx: &Context,
pool: &sqlite::Pool,
trip_id: Uuid,
description: String,
) -> Result<Uuid, Error> {
let user_id = ctx.user.id.to_string();
let id = Uuid::new_v4();
tracing::info!("adding new todo with id {id}");
let id_param = id.to_string();
let trip_id_param = trip_id.to_string();
crate::execute!(
&sqlite::QueryClassification {
query_type: sqlite::QueryType::Insert,
component: sqlite::Component::Todo,
},
pool,
"INSERT INTO trip_todos
(id, description, done, trip_id)
SELECT ?, ?, false, id as trip_id
FROM trips
WHERE trip_id = ? AND EXISTS(SELECT 1 FROM trips WHERE id = ? and user_id = ?)
LIMIT 1",
id_param,
description,
trip_id_param,
trip_id_param,
user_id,
)
.await?;
Ok(id)
}
#[tracing::instrument]
pub async fn delete(
ctx: &Context,
pool: &sqlite::Pool,
trip_id: Uuid,
id: Uuid,
) -> Result<bool, Error> {
let id_param = id.to_string();
let user_id = ctx.user.id.to_string();
let trip_id_param = trip_id.to_string();
let results = crate::execute!(
&sqlite::QueryClassification {
query_type: sqlite::QueryType::Delete,
component: sqlite::Component::Todo,
},
pool,
"DELETE FROM trip_todos
WHERE
id = ?
AND EXISTS (SELECT 1 FROM trips WHERE trip_id = ? AND user_id = ?)",
id_param,
trip_id_param,
user_id,
)
.await?;
Ok(results.rows_affected() != 0)
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum TodoUiState {
Default,
Edit,
}
impl Todo {
#[tracing::instrument]
pub fn build(&self, trip_id: &Uuid, state: TodoUiState) -> Markup {
let done = self.is_done();
html!(
li
."flex"
."flex-row"
."justify-start"
."items-stretch"
."bg-green-50"[done]
."bg-red-50"[!done]
."h-full"
{
@if state == TodoUiState::Edit {
form
name="edit-todo"
id="edit-todo"
action={
"/trips/" (trip_id)
"/todo/" (self.id)
"/edit/save"
}
target="_self"
method="post"
hx-post={
"/trips/" (trip_id)
"/todo/" (self.id)
"/edit/save"
}
hx-target="closest li"
hx-swap="outerHTML"
{}
div
."flex"
."flex-row"
."aspect-square"
{
span
."mdi"
."m-auto"
."text-xl"
."mdi-check"[self.is_done()]
."mdi-checkbox-blank-outline"[!self.is_done()]
{}
}
div
."p-2"
.grow
{
input
."w-full"
type="text"
form="edit-todo"
id="todo-description"
name="todo-description"
value=(self.description)
{}
}
button
type="submit"
form="edit-todo"
."bg-green-200"
."hover:bg-green-300"
."flex"
."flex-row"
."aspect-square"
{
span
."mdi"
."m-auto"
."mdi-content-save"
."text-xl"
{}
}
a
href="."
hx-post={
"/trips/" (trip_id)
"/todo/" (self.id)
"/edit/cancel"
}
hx-target="closest li"
hx-swap="outerHTML"
."flex"
."flex-row"
."aspect-square"
."bg-red-200"
."hover:bg-red-300"
{
span
."mdi"
."mdi-cancel"
."text-xl"
."m-auto"
{}
}
} @else {
@if done {
a
."flex"
."flex-row"
."aspect-square"
."hover:bg-red-50"
href={
"/trips/" (trip_id)
"/todo/" (self.id)
"/undone"
}
hx-post={
"/trips/" (trip_id)
"/todo/" (self.id)
"/undone"
}
hx-target="closest li"
hx-swap="outerHTML"
{
span
."mdi"
."m-auto"
."text-xl"
."mdi-check"
{}
}
} @else {
a
."flex"
."flex-row"
."aspect-square"
."hover:bg-green-50"
href={
"/trips/" (trip_id)
"/todo/" (self.id)
"/done"
}
hx-post={
"/trips/" (trip_id)
"/todo/" (self.id)
"/done"
}
hx-target="closest li"
hx-swap="outerHTML"
{
span
."mdi"
."m-auto"
."text-xl"
."mdi-checkbox-blank-outline"
{}
}
}
span
."p-2"
."grow"
{
(self.description)
}
a
."flex"
."flex-row"
."aspect-square"
."bg-blue-200"
."hover:bg-blue-400"
href=(format!("?edit_todo={id}", id = self.id))
hx-post={
"/trips/" (trip_id)
"/todo/" (self.id)
"/edit"
}
hx-target="closest li"
hx-swap="outerHTML"
{
span ."m-auto" ."mdi" ."mdi-pencil" ."text-xl" {}
}
a
."flex"
."flex-row"
."aspect-square"
."bg-red-100"
."hover:bg-red-200"
href=(format!("?delete_todo={id}", id = self.id))
hx-post={
"/trips/" (trip_id)
"/todo/" (self.id)
"/delete"
}
hx-target="#todolist"
hx-swap="outerHTML"
{
span ."m-auto" ."mdi" ."mdi-delete-outline" ."text-xl" {}
}
}
}
)
}
#[tracing::instrument]
pub async fn set_description(
ctx: &Context,
pool: &sqlite::Pool,
trip_id: Uuid,
todo_id: Uuid,
new_description: String,
) -> Result<Option<Self>, 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 result = crate::query_one!(
&sqlite::QueryClassification {
query_type: sqlite::QueryType::Update,
component: sqlite::Component::Todo,
},
pool,
TodoRow,
Todo,
"UPDATE trip_todos
SET description = ?
WHERE
id = ?
AND trip_id = ?
AND EXISTS(SELECT 1 FROM trips WHERE trip_id = ? AND user_id = ?)
RETURNING
id,
description,
done
",
new_description,
todo_id_param,
trip_id_param,
trip_id_param,
user_id,
)
.await?;
Ok(result)
}
}
pub struct NewTodo;
impl NewTodo {
#[tracing::instrument]
pub fn build(trip_id: &Uuid) -> Markup {
html!(
li
."flex"
."flex-row"
."justify-start"
."items-stretch"
."h-full"
{
form
name="new-todo"
id="new-todo"
action={
"/trips/" (trip_id)
"/todo/new"
}
target="_self"
method="post"
hx-post={
"/trips/" (trip_id)
"/todo/new"
}
hx-target="#todolist"
hx-swap="outerHTML"
{}
button
type="submit"
form="new-todo"
."bg-green-200"
."hover:bg-green-300"
."flex"
."flex-row"
."aspect-square"
{
span
."mdi"
."m-auto"
."mdi-plus"
."text-xl"
{}
}
div
."border-4"
."p-1"
.grow
{
input
."appearance-none"
."w-full"
type="text"
form="new-todo"
id="new-todo-description"
name="new-todo-description"
{}
}
}
)
}
}
#[derive(Debug)]
pub struct TodoList<'a> {
pub trip: &'a Trip,
pub todos: &'a Vec<Todo>,
}
impl<'a> TodoList<'a> {
#[tracing::instrument]
pub fn build(&self, edit_todo: Option<Uuid>) -> Markup {
html!(
div #todolist {
h1 ."text-xl" ."mb-5" { "Todos" }
ul
."flex"
."flex-col"
{
@for todo in self.todos {
@let state = edit_todo
.map(|id| if todo.id == id {
TodoUiState::Edit
} else {
TodoUiState::Default
}).unwrap_or(TodoUiState::Default);
(todo.build(&self.trip.id, state))
}
(NewTodo::build(&self.trip.id))
}
}
)
}
} }

View File

@@ -147,7 +147,12 @@ pub fn router(state: AppState) -> Router {
.route( .route(
"/:id/todo/:id/undone", "/:id/todo/:id/undone",
get(trip_todo_undone).post(trip_todo_undone_htmx), get(trip_todo_undone).post(trip_todo_undone_htmx),
), )
.route("/:id/todo/:id/edit", post(trip_todo_edit))
.route("/:id/todo/:id/edit/save", post(trip_todo_edit_save))
.route("/:id/todo/:id/edit/cancel", post(trip_todo_edit_cancel))
.route("/:id/todo/new", post(trip_todo_new))
.route("/:id/todo/:id/delete", post(trip_todo_delete)),
) )
.nest( .nest(
(&TopLevelPage::Inventory.path()).into(), (&TopLevelPage::Inventory.path()).into(),

View File

@@ -73,6 +73,8 @@ pub struct NewTrip {
pub struct TripQuery { pub struct TripQuery {
edit: Option<models::trips::TripAttribute>, edit: Option<models::trips::TripAttribute>,
category: Option<Uuid>, category: Option<Uuid>,
edit_todo: Option<Uuid>,
delete_todo: Option<Uuid>,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@@ -424,11 +426,25 @@ pub async fn trip(
State(mut state): State<AppState>, State(mut state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Query(trip_query): Query<TripQuery>, Query(trip_query): Query<TripQuery>,
headers: HeaderMap,
) -> Result<impl IntoResponse, Error> { ) -> Result<impl IntoResponse, Error> {
let ctx = Context::build(current_user); let ctx = Context::build(current_user);
state.client_state.trip_edit_attribute = trip_query.edit; state.client_state.trip_edit_attribute = trip_query.edit;
state.client_state.active_category_id = trip_query.category; state.client_state.active_category_id = trip_query.category;
if let Some(delete_todo) = trip_query.delete_todo {
let deleted =
models::trips::todos::Todo::delete(&ctx, &state.database_pool, id, delete_todo).await?;
return if deleted {
Ok(Redirect::to(get_referer(&headers)?).into_response())
} else {
Err(Error::Request(RequestError::NotFound {
message: format!("todo with id {id} not found"),
}))
};
}
let mut trip: models::trips::Trip = models::trips::Trip::find(&ctx, &state.database_pool, id) let mut trip: models::trips::Trip = models::trips::Trip::find(&ctx, &state.database_pool, id)
.await? .await?
.ok_or(Error::Request(RequestError::NotFound { .ok_or(Error::Request(RequestError::NotFound {
@@ -463,9 +479,11 @@ pub async fn trip(
&trip, &trip,
state.client_state.trip_edit_attribute.as_ref(), state.client_state.trip_edit_attribute.as_ref(),
active_category, active_category,
trip_query.edit_todo,
), ),
Some(&TopLevelPage::Trips), Some(&TopLevelPage::Trips),
)) )
.into_response())
} }
#[tracing::instrument] #[tracing::instrument]
@@ -1265,7 +1283,7 @@ pub async fn trip_todo_done_htmx(
}) })
})?; })?;
Ok(view::trip::TripTodo::build(&trip_id, &todo_item)) Ok(todo_item.build(&trip_id, models::trips::todos::TodoUiState::Default))
} }
#[tracing::instrument] #[tracing::instrument]
@@ -1312,7 +1330,7 @@ pub async fn trip_todo_undone_htmx(
}) })
})?; })?;
Ok(view::trip::TripTodo::build(&trip_id, &todo_item)) Ok(todo_item.build(&trip_id, models::trips::todos::TodoUiState::Default))
} }
#[tracing::instrument] #[tracing::instrument]
@@ -1334,3 +1352,162 @@ pub async fn trip_todo_undone(
Ok(Redirect::to(get_referer(&headers)?)) Ok(Redirect::to(get_referer(&headers)?))
} }
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct TripTodoDescription {
#[serde(rename = "todo-description")]
description: String,
}
#[tracing::instrument]
pub async fn trip_todo_edit(
Extension(current_user): Extension<models::user::User>,
State(state): State<AppState>,
headers: HeaderMap,
Path((trip_id, todo_id)): Path<(Uuid, Uuid)>,
) -> Result<impl IntoResponse, Error> {
let ctx = Context::build(current_user);
let todo_item =
models::trips::todos::Todo::find(&ctx, &state.database_pool, trip_id, todo_id).await?;
match todo_item {
None => Err(Error::Request(RequestError::NotFound {
message: format!("todo with id {todo_id} not found"),
})),
Some(todo_item) => Ok(todo_item
.build(&trip_id, models::trips::todos::TodoUiState::Edit)
.into_response()),
}
}
#[tracing::instrument]
pub async fn trip_todo_edit_save(
Extension(current_user): Extension<models::user::User>,
State(state): State<AppState>,
headers: HeaderMap,
Path((trip_id, todo_id)): Path<(Uuid, Uuid)>,
Form(form): Form<TripTodoDescription>,
) -> Result<impl IntoResponse, Error> {
let ctx = Context::build(current_user);
let todo_item = models::trips::todos::Todo::set_description(
&ctx,
&state.database_pool,
trip_id,
todo_id,
form.description,
)
.await?;
match todo_item {
None => Err(Error::Request(RequestError::NotFound {
message: format!("todo with id {todo_id} not found"),
})),
Some(todo_item) => {
if htmx::is_htmx(&headers) {
Ok(todo_item
.build(&trip_id, models::trips::todos::TodoUiState::Default)
.into_response())
} else {
Ok(Redirect::to(&format!("/trips/{trip_id}/")).into_response())
}
}
}
}
#[tracing::instrument]
pub async fn trip_todo_edit_cancel(
Extension(current_user): Extension<models::user::User>,
State(state): State<AppState>,
headers: HeaderMap,
Path((trip_id, todo_id)): Path<(Uuid, Uuid)>,
) -> Result<impl IntoResponse, Error> {
let ctx = Context::build(current_user);
let todo_item =
models::trips::todos::Todo::find(&ctx, &state.database_pool, trip_id, todo_id).await?;
match todo_item {
None => Err(Error::Request(RequestError::NotFound {
message: format!("todo with id {todo_id} not found"),
})),
Some(todo_item) => Ok(todo_item
.build(&trip_id, models::trips::todos::TodoUiState::Default)
.into_response()),
}
}
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct TripTodoNew {
#[serde(rename = "new-todo-description")]
description: String,
}
#[tracing::instrument]
pub async fn trip_todo_new(
Extension(current_user): Extension<models::user::User>,
State(state): State<AppState>,
headers: HeaderMap,
Path(trip_id): Path<Uuid>,
Form(form): Form<TripTodoNew>,
) -> Result<impl IntoResponse, Error> {
let ctx = Context::build(current_user);
// method output is not required as we reload the whole trip todos anyway
let _todo_item =
models::trips::todos::Todo::new(&ctx, &state.database_pool, trip_id, form.description)
.await?;
if htmx::is_htmx(&headers) {
let trip = models::trips::Trip::find(&ctx, &state.database_pool, trip_id).await?;
match trip {
None => Err(Error::Request(RequestError::NotFound {
message: format!("trip with id {trip_id} not found"),
})),
Some(mut trip) => {
trip.load_todos(&ctx, &state.database_pool).await?;
Ok(models::trips::todos::TodoList {
trip: &trip,
todos: &trip.todos(),
}
.build(None)
.into_response())
}
}
} else {
Ok(Redirect::to(&format!("/trips/{trip_id}/")).into_response())
}
}
#[tracing::instrument]
pub async fn trip_todo_delete(
Extension(current_user): Extension<models::user::User>,
State(state): State<AppState>,
headers: HeaderMap,
Path((trip_id, todo_id)): Path<(Uuid, Uuid)>,
) -> Result<impl IntoResponse, Error> {
let ctx = Context::build(current_user);
let deleted =
models::trips::todos::Todo::delete(&ctx, &state.database_pool, trip_id, todo_id).await?;
if !deleted {
return Err(Error::Request(RequestError::NotFound {
message: format!("todo with id {todo_id} not found"),
}));
}
let trip = models::trips::Trip::find(&ctx, &state.database_pool, trip_id).await?;
match trip {
None => Err(Error::Request(RequestError::NotFound {
message: format!("trip with id {trip_id} not found"),
})),
Some(mut trip) => {
trip.load_todos(&ctx, &state.database_pool).await?;
Ok(models::trips::todos::TodoList {
trip: &trip,
todos: &trip.todos(),
}
.build(None)
.into_response())
}
}
}

View File

@@ -1,2 +1,3 @@
#[cfg(feature = "prometheus")]
pub mod metrics; pub mod metrics;
pub mod tracing; pub mod tracing;

View File

@@ -257,6 +257,7 @@ impl Trip {
trip: &models::trips::Trip, trip: &models::trips::Trip,
trip_edit_attribute: Option<&models::trips::TripAttribute>, trip_edit_attribute: Option<&models::trips::TripAttribute>,
active_category: Option<&models::trips::TripCategory>, active_category: Option<&models::trips::TripCategory>,
edit_todo: Option<Uuid>,
) -> Markup { ) -> Markup {
html!( html!(
div ."p-8" ."flex" ."flex-col" ."gap-8" { div ."p-8" ."flex" ."flex-col" ."gap-8" {
@@ -370,7 +371,7 @@ impl Trip {
} }
} }
(TripInfo::build(trip_edit_attribute, trip)) (TripInfo::build(trip_edit_attribute, trip))
(TripTodoList::build(trip)) (crate::models::trips::todos::TodoList{todos: trip.todos(), trip: &trip}.build(edit_todo))
(TripComment::build(trip)) (TripComment::build(trip))
(TripItems::build(active_category, trip)) (TripItems::build(active_category, trip))
} }
@@ -795,113 +796,6 @@ 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; pub struct TripComment;
impl TripComment { impl TripComment {