From f5e0bb180455c2c91d6f9faee1d93ae19dcc8cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Volf?= Date: Sun, 8 Sep 2024 19:46:19 +0200 Subject: [PATCH 1/6] style: formatting --- src/components/pages/category_calendar_page.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/pages/category_calendar_page.rs b/src/components/pages/category_calendar_page.rs index 1a610b3..dba2d59 100644 --- a/src/components/pages/category_calendar_page.rs +++ b/src/components/pages/category_calendar_page.rs @@ -41,8 +41,8 @@ pub(crate) fn CategoryCalendarPage() -> Element { .format_localized( format!( "%A %-d. %B{}", - if date_current.year() != today_date.year() {" %Y"} - else {""} + if date_current.year() != today_date.year() + {" %Y"} else {""} ).as_str(), Locale::en_US ) From 1be1e8f65aa00d68ac2d77725a7725f23e788ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Volf?= Date: Sun, 8 Sep 2024 19:46:59 +0200 Subject: [PATCH 2/6] feat: allow vertical scrolling in the bottom panel --- src/components/bottom_panel.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/bottom_panel.rs b/src/components/bottom_panel.rs index 6ba931a..ccda1db 100644 --- a/src/components/bottom_panel.rs +++ b/src/components/bottom_panel.rs @@ -36,11 +36,11 @@ pub(crate) fn BottomPanel(display_form: Signal) -> Element { rsx! { div { class: format!( - "pointer-events-auto bg-zinc-700/50 rounded-t-xl border-t-zinc-600 border-t backdrop-blur drop-shadow-[0_-5px_10px_rgba(0,0,0,0.2)] transition-[height] duration-[500ms] ease-[cubic-bezier(0.79,0.14,0.15,0.86)] {}", + "pointer-events-auto bg-zinc-700/50 rounded-t-xl border-t-zinc-600 border-t backdrop-blur drop-shadow-[0_-5px_10px_rgba(0,0,0,0.2)] transition-[height] duration-[500ms] ease-[cubic-bezier(0.79,0.14,0.15,0.86)] overflow-y-scroll {}", match (display_form(), current_route, navigation_expanded()) { - (false, _, false) => "h-[64px]", - (false, _, true) => "h-[128px]", - (true, Route::ProjectsPage, _) => "h-[128px]", + (false, _, false) => "h-[66px]", + (false, _, true) => "h-[130px]", + (true, Route::ProjectsPage, _) => "h-[130px]", (true, _, _) => "h-[448px]", } ), From a5a67792d7da2547eff9e1d708a6c5ec5aee22b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Volf?= Date: Sun, 8 Sep 2024 19:51:52 +0200 Subject: [PATCH 3/6] feat: create a model for subtasks --- .../down.sql | 4 + .../2024-09-08-083610_create_subtasks/up.sql | 15 +++ src/errors/mod.rs | 1 + src/errors/subtask_error.rs | 70 +++++++++++ src/models/mod.rs | 1 + src/models/subtask.rs | 67 ++++++++++ src/query/mod.rs | 8 +- src/query/subtasks.rs | 21 ++++ src/schema/mod.rs | 13 ++ src/server/mod.rs | 1 + src/server/subtasks.rs | 115 ++++++++++++++++++ 11 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 migrations/2024-09-08-083610_create_subtasks/down.sql create mode 100644 migrations/2024-09-08-083610_create_subtasks/up.sql create mode 100644 src/errors/subtask_error.rs create mode 100644 src/models/subtask.rs create mode 100644 src/query/subtasks.rs create mode 100644 src/server/subtasks.rs diff --git a/migrations/2024-09-08-083610_create_subtasks/down.sql b/migrations/2024-09-08-083610_create_subtasks/down.sql new file mode 100644 index 0000000..ca17be0 --- /dev/null +++ b/migrations/2024-09-08-083610_create_subtasks/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` + + +DROP TABLE IF EXISTS "subtasks"; diff --git a/migrations/2024-09-08-083610_create_subtasks/up.sql b/migrations/2024-09-08-083610_create_subtasks/up.sql new file mode 100644 index 0000000..b16d362 --- /dev/null +++ b/migrations/2024-09-08-083610_create_subtasks/up.sql @@ -0,0 +1,15 @@ +-- Your SQL goes here + + +CREATE TABLE "subtasks"( + "id" SERIAL NOT NULL PRIMARY KEY, + "task_id" INT4 NOT NULL, + "title" TEXT NOT NULL, + "is_completed" BOOL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE +); + +SELECT diesel_manage_updated_at('subtasks'); + diff --git a/src/errors/mod.rs b/src/errors/mod.rs index 3eeb29b..1dfc030 100644 --- a/src/errors/mod.rs +++ b/src/errors/mod.rs @@ -2,3 +2,4 @@ pub(crate) mod error; pub(crate) mod error_vec; pub(crate) mod project_error; pub(crate) mod task_error; +pub(crate) mod subtask_error; diff --git a/src/errors/subtask_error.rs b/src/errors/subtask_error.rs new file mode 100644 index 0000000..3297208 --- /dev/null +++ b/src/errors/subtask_error.rs @@ -0,0 +1,70 @@ +use crate::errors::error::Error; +use crate::errors::error_vec::ErrorVec; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use std::str::FromStr; +use validator::{ValidationErrors, ValidationErrorsKind}; + +#[derive(Serialize, Deserialize, Debug)] +pub enum SubtaskError { + TitleLengthInvalid, + TaskNotFound, + Error(Error), +} + +impl From for ErrorVec { + fn from(validation_errors: ValidationErrors) -> Self { + validation_errors.errors() + .iter() + .flat_map(|(&field, error_kind)| match field { + "title" => match error_kind { + ValidationErrorsKind::Field(validation_errors) => validation_errors + .iter() + .map(|validation_error| validation_error.code.as_ref()) + .map(|code| match code { + "title_length" => SubtaskError::TitleLengthInvalid, + _ => panic!("Unexpected validation error code: `{code}`."), + }) + .collect::>(), + _ => panic!("Unexpected validation error kind."), + }, + _ => panic!("Unexpected validation field name: `{field}`."), + }) + .collect::>() + .into() + } +} + +impl From for SubtaskError { + fn from(diesel_error: diesel::result::Error) -> Self { + match diesel_error { + diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::ForeignKeyViolation, info + ) => { + match info.constraint_name() { + Some("subtasks_task_id_fkey") => Self::TaskNotFound, + _ => Self::Error(Error::ServerInternal) + } + } + _ => { + Self::Error(Error::ServerInternal) + } + } + } +} + +// Has to be implemented for Dioxus server functions. +impl Display for SubtaskError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +// Has to be implemented for Dioxus server functions. +impl FromStr for SubtaskError { + type Err = (); + + fn from_str(_: &str) -> Result { + Ok(Self::Error(Error::ServerInternal)) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 9f449ce..940d15b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod project; pub(crate) mod category; pub(crate) mod task; +pub(crate) mod subtask; diff --git a/src/models/subtask.rs b/src/models/subtask.rs new file mode 100644 index 0000000..fbf9974 --- /dev/null +++ b/src/models/subtask.rs @@ -0,0 +1,67 @@ +use chrono::NaiveDateTime; +use crate::schema::subtasks; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +const TITLE_LENGTH_MIN: u64 = 1; +const TITLE_LENGTH_MAX: u64 = 255; + +#[derive(Queryable, Selectable, Serialize, Deserialize, PartialEq, Clone, Debug)] +#[diesel(table_name = subtasks)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Subtask { + id: i32, + task_id: i32, + title: String, + is_completed: bool, + created_at: NaiveDateTime, + updated_at: NaiveDateTime, +} + +impl Subtask { + pub fn id(&self) -> i32 { + self.id + } + + pub fn task_id(&self) -> i32 { + self.task_id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn is_completed(&self) -> bool { + self.is_completed + } + + pub fn created_at(&self) -> NaiveDateTime { + self.created_at + } + + pub fn updated_at(&self) -> NaiveDateTime { + self.updated_at + } +} + +#[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)] +#[diesel(table_name = subtasks)] +pub struct NewSubtask { + pub task_id: i32, + #[validate(length(min = "TITLE_LENGTH_MIN", max = "TITLE_LENGTH_MAX", code = "title_length"))] + pub title: String, + pub is_completed: bool, +} + +impl NewSubtask { + pub fn new(task_id: i32, title: String, is_completed: bool) -> Self { + Self { task_id, title, is_completed } + } +} + +impl From for NewSubtask { + fn from(subtask: Subtask) -> Self { + Self::new(subtask.task_id, subtask.title, subtask.is_completed) + } +} diff --git a/src/query/mod.rs b/src/query/mod.rs index 7e1566a..792d45b 100644 --- a/src/query/mod.rs +++ b/src/query/mod.rs @@ -2,15 +2,18 @@ use crate::errors::error::Error; use crate::errors::error_vec::ErrorVec; use crate::models::category::Category; use crate::models::project::Project; +use crate::models::subtask::Subtask; use crate::models::task::Task; pub(crate) mod tasks; pub(crate) mod projects; +pub(crate) mod subtasks; #[derive(PartialEq, Debug)] pub(crate) enum QueryValue { - Tasks(Vec), Projects(Vec), + Tasks(Vec), + Subtasks(Vec), } #[derive(Debug)] @@ -20,7 +23,8 @@ pub(crate) enum QueryErrors { #[derive(PartialEq, Eq, Hash, Clone, Debug)] pub(crate) enum QueryKey { + Projects, Tasks, TasksInCategory(Category), - Projects, + SubtasksOfTaskId(i32), } diff --git a/src/query/subtasks.rs b/src/query/subtasks.rs new file mode 100644 index 0000000..6ee4f1a --- /dev/null +++ b/src/query/subtasks.rs @@ -0,0 +1,21 @@ +use crate::query::{QueryErrors, QueryKey, QueryValue}; +use crate::server::subtasks::get_subtasks_of_task; +use dioxus::prelude::ServerFnError; +use dioxus_query::prelude::{use_get_query, QueryResult, UseQuery}; + +pub(crate) fn use_subtasks_of_task_query(task_id: i32) + -> UseQuery { + use_get_query([QueryKey::SubtasksOfTaskId(task_id)], fetch_subtasks_of_task) +} + +async fn fetch_subtasks_of_task(keys: Vec) -> QueryResult { + if let Some(QueryKey::SubtasksOfTaskId(task_id)) = keys.first() { + match get_subtasks_of_task(*task_id).await { + Ok(subtasks) => Ok(QueryValue::Subtasks(subtasks)), + Err(ServerFnError::WrappedServerError(errors)) => Err(QueryErrors::Error(errors)), + Err(error) => panic!("Unexpected error: {:?}", error) + }.into() + } else { + panic!("Unexpected query keys: {:?}", keys); + } +} diff --git a/src/schema/mod.rs b/src/schema/mod.rs index 7078264..d50a5ec 100644 --- a/src/schema/mod.rs +++ b/src/schema/mod.rs @@ -9,6 +9,17 @@ diesel::table! { } } +diesel::table! { + subtasks (id) { + id -> Int4, + task_id -> Int4, + title -> Text, + is_completed -> Bool, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + diesel::table! { tasks (id) { id -> Int4, @@ -21,9 +32,11 @@ diesel::table! { } } +diesel::joinable!(subtasks -> tasks (task_id)); diesel::joinable!(tasks -> projects (project_id)); diesel::allow_tables_to_appear_in_same_query!( projects, + subtasks, tasks, ); diff --git a/src/server/mod.rs b/src/server/mod.rs index 86a456c..59b63ad 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,3 +1,4 @@ mod database_connection; pub(crate) mod projects; pub(crate) mod tasks; +pub(crate) mod subtasks; diff --git a/src/server/subtasks.rs b/src/server/subtasks.rs new file mode 100644 index 0000000..6108575 --- /dev/null +++ b/src/server/subtasks.rs @@ -0,0 +1,115 @@ +use crate::errors::error::Error; +use crate::errors::error_vec::ErrorVec; +use crate::errors::subtask_error::SubtaskError; +use crate::models::subtask::{NewSubtask, Subtask}; +use crate::server::database_connection::establish_database_connection; +use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; +use dioxus::prelude::*; +use validator::Validate; + +#[server] +pub(crate) async fn create_subtask(new_subtask: NewSubtask) + -> Result>> { + use crate::schema::subtasks; + + new_subtask.validate() + .map_err::, _>(|errors| errors.into())?; + + let mut connection = establish_database_connection() + .map_err::, _>( + |_| vec![SubtaskError::Error(Error::ServerInternal)].into() + )?; + + let created_subtask = diesel::insert_into(subtasks::table) + .values(&new_subtask) + .returning(Subtask::as_returning()) + .get_result(&mut connection) + .map_err::, _>(|error| vec![error.into()].into())?; + + Ok(created_subtask) +} + +#[server] +pub(crate) async fn get_subtasks_of_task(filtered_task_id: i32) + -> Result, ServerFnError>> { + use crate::schema::subtasks::dsl::*; + + let mut connection = establish_database_connection() + .map_err::, _>( + |_| vec![Error::ServerInternal].into() + )?; + + let results = subtasks + .select(Subtask::as_select()) + .filter(task_id.eq(filtered_task_id)) + .load::(&mut connection) + .map_err::, _>( + |_| vec![Error::ServerInternal].into() + )?; + + Ok(results) +} + +#[server] +pub(crate) async fn edit_subtask(subtask_id: i32, new_subtask: NewSubtask) + -> Result>> { + use crate::schema::subtasks::dsl::*; + + new_subtask.validate() + .map_err::, _>(|errors| errors.into())?; + + let mut connection = establish_database_connection() + .map_err::, _>( + |_| vec![SubtaskError::Error(Error::ServerInternal)].into() + )?; + + let updated_task = diesel::update(subtasks) + .filter(id.eq(subtask_id)) + .set(( + title.eq(new_subtask.title), + is_completed.eq(new_subtask.is_completed) + )) + .returning(Subtask::as_returning()) + .get_result(&mut connection) + .map_err::, _>(|error| vec![error.into()].into())?; + + Ok(updated_task) +} + +#[server] +pub(crate) async fn restore_subtasks_of_task(filtered_task_id: i32) -> Result< + Vec, + ServerFnError> +> { + use crate::schema::subtasks::dsl::*; + + let mut connection = establish_database_connection() + .map_err::, _>( + |_| vec![SubtaskError::Error(Error::ServerInternal)].into() + )?; + + let updated_subtasks = diesel::update(subtasks) + .filter(task_id.eq(filtered_task_id)) + .set(is_completed.eq(false)) + .returning(Subtask::as_returning()) + .get_results(&mut connection) + .map_err::, _>(|error| vec![error.into()].into())?; + + Ok(updated_subtasks) +} + +// TODO: Get rid of this suppression. +//noinspection DuplicatedCode +#[server] +pub(crate) async fn delete_subtask(subtask_id: i32) + -> Result<(), ServerFnError>> { + use crate::schema::subtasks::dsl::*; + + let mut connection = establish_database_connection() + .map_err::, _>(|_| vec![Error::ServerInternal].into())?; + + diesel::delete(subtasks.filter(id.eq(subtask_id))).execute(&mut connection) + .map_err::, _>(|error| vec![error.into()].into())?; + + Ok(()) +} From 5e714a6485b88a8ca41dfb1e90e834491d350afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Volf?= Date: Sun, 8 Sep 2024 19:51:59 +0200 Subject: [PATCH 4/6] chore: remove an unused import --- src/models/task.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/task.rs b/src/models/task.rs index 1e2a52d..c75eac9 100644 --- a/src/models/task.rs +++ b/src/models/task.rs @@ -9,7 +9,7 @@ const TITLE_LENGTH_MIN: u64 = 1; const TITLE_LENGTH_MAX: u64 = 255; #[derive(Queryable, Selectable, Serialize, Deserialize, PartialEq, Clone, Debug)] -#[diesel(table_name = crate::schema::tasks)] +#[diesel(table_name = tasks)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Task { id: i32, From b5589860f8da0e3cc172ceac84c87757527c33a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Volf?= Date: Sun, 8 Sep 2024 19:52:36 +0200 Subject: [PATCH 5/6] feat: restore subtasks on a reoccurring task completion --- src/server/tasks.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/server/tasks.rs b/src/server/tasks.rs index b20d3b6..895551c 100644 --- a/src/server/tasks.rs +++ b/src/server/tasks.rs @@ -9,6 +9,7 @@ use time::util::days_in_year_month; use validator::Validate; use crate::errors::task_error::TaskError; use crate::models::category::{Category, ReoccurrenceInterval}; +use crate::server::subtasks::restore_subtasks_of_task; #[server] pub(crate) async fn create_task(new_task: NewTask) @@ -127,6 +128,8 @@ pub(crate) async fn complete_task(task_id: i32) -> Result, _>(|_| vec![Error::ServerInternal].into())?; } else { new_task.category = Category::Done; } From 0f841fb01ec30b7eb1284720b3d48630c92c4f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Volf?= Date: Sun, 8 Sep 2024 19:53:01 +0200 Subject: [PATCH 6/6] feat: create a from for subtasks --- src/components/mod.rs | 1 + src/components/subtasks_form.rs | 158 ++++++++++ src/components/task_form.rs | 490 ++++++++++++++++---------------- src/components/task_list.rs | 120 ++++---- 4 files changed, 475 insertions(+), 294 deletions(-) create mode 100644 src/components/subtasks_form.rs diff --git a/src/components/mod.rs b/src/components/mod.rs index 3dde58e..c820f1d 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -12,3 +12,4 @@ pub(crate) mod category_input; pub(crate) mod reoccurrence_input; pub(crate) mod layout; pub(crate) mod navigation_item; +pub(crate) mod subtasks_form; diff --git a/src/components/subtasks_form.rs b/src/components/subtasks_form.rs new file mode 100644 index 0000000..db03efb --- /dev/null +++ b/src/components/subtasks_form.rs @@ -0,0 +1,158 @@ +use crate::models::subtask::NewSubtask; +use crate::query::subtasks::use_subtasks_of_task_query; +use crate::query::{QueryErrors, QueryKey, QueryValue}; +use crate::server::subtasks::{create_subtask, delete_subtask, edit_subtask}; +use dioxus::core_macro::{component, rsx}; +use dioxus::dioxus_core::Element; +use dioxus::prelude::*; +use dioxus_query::prelude::{use_query_client, QueryResult}; + +#[component] +pub(crate) fn SubtasksForm(task_id: i32) -> Element { + let query_client = use_query_client::(); + let subtasks_query = use_subtasks_of_task_query(task_id); + + let mut new_title = use_signal(String::new); + + rsx! { + form { + class: "flex flex-row items-center gap-3", + onsubmit: move |event| async move { + let new_subtask = NewSubtask::new( + task_id, + event.values().get("title").unwrap().as_value(), + false + ); + let _ = create_subtask(new_subtask).await; + query_client.invalidate_queries(&[QueryKey::SubtasksOfTaskId(task_id)]); + new_title.set(String::new()); + }, + label { + r#for: "input_new_title", + class: "min-w-6 text-center", + i { + class: "fa-solid fa-list-check text-zinc-400/50" + } + } + div { + class: "grow grid grid-cols-6 gap-2", + input { + name: "title", + required: true, + value: new_title, + r#type: "text", + class: "grow py-2 px-3 col-span-5 bg-zinc-800/50 rounded-lg", + id: "input_new_title", + onchange: move |event| new_title.set(event.value()) + } + button { + r#type: "submit", + class: "py-2 col-span-1 bg-zinc-800/50 rounded-lg", + i { + class: "fa-solid fa-plus" + } + } + } + } + match subtasks_query.result().value() { + QueryResult::Ok(QueryValue::Subtasks(subtasks)) + | QueryResult::Loading(Some(QueryValue::Subtasks(subtasks))) => { + rsx! { + for subtask in subtasks.clone() { + div { + key: "{subtask.id()}", + class: "flex flex-row items-center gap-3", + i { + class: format!( + "{} min-w-6 text-center text-2xl text-zinc-400/50", + if subtask.is_completed() { + "fa solid fa-square-check" + } else { + "fa-regular fa-square" + } + ), + onclick: { + let subtask = subtask.clone(); + move |_| { + let subtask = subtask.clone(); + async move { + let new_subtask = NewSubtask::new( + subtask.task_id(), + subtask.title().to_owned(), + !subtask.is_completed() + ); + let _ = edit_subtask( + subtask.id(), + new_subtask + ).await; + query_client.invalidate_queries(&[ + QueryKey::SubtasksOfTaskId(task_id) + ]); + } + } + } + } + div { + class: "grow grid grid-cols-6 gap-2", + input { + r#type: "text", + class: "grow py-2 px-3 col-span-5 bg-zinc-800/50 rounded-lg", + id: "input_new_title", + initial_value: subtask.title(), + onchange: { + let subtask = subtask.clone(); + move |event| { + let subtask = subtask.clone(); + async move { + let new_subtask = NewSubtask::new( + subtask.task_id(), + event.value(), + subtask.is_completed() + ); + let _ = edit_subtask( + subtask.id(), + new_subtask + ).await; + query_client.invalidate_queries(&[ + QueryKey::SubtasksOfTaskId(task_id) + ]); + } + } + } + } + button { + r#type: "button", + class: "py-2 col-span-1 bg-zinc-800/50 rounded-lg", + onclick: { + let subtask = subtask.clone(); + move |_| { + let subtask = subtask.clone(); + async move { + let _ = delete_subtask(subtask.id()).await; + query_client.invalidate_queries(&[ + QueryKey::SubtasksOfTaskId(task_id) + ]); + } + } + }, + i { + class: "fa-solid fa-trash-can" + } + } + } + } + } + } + }, + QueryResult::Loading(None) => rsx! { + // TODO: Add a loading indicator. + }, + QueryResult::Err(errors) => rsx! { + div { + "Errors occurred: {errors:?}" + } + }, + value => panic!("Unexpected query result: {value:?}") + } + } +} diff --git a/src/components/task_form.rs b/src/components/task_form.rs index 8618c48..943ca1a 100644 --- a/src/components/task_form.rs +++ b/src/components/task_form.rs @@ -12,6 +12,7 @@ use dioxus::core_macro::{component, rsx}; use dioxus::dioxus_core::Element; use dioxus::prelude::*; use dioxus_query::prelude::use_query_client; +use crate::components::subtasks_form::SubtasksForm; const REMINDER_OFFSETS: [Option; 17] = [ None, @@ -79,262 +80,274 @@ pub(crate) fn TaskForm(task: Option, on_successful_submit: EventHandler<() let task_for_submit = task.clone(); rsx! { - form { - onsubmit: move |event| { - let task = task_for_submit.clone(); - async move { - let new_task = NewTask::new( - event.values().get("title").unwrap().as_value(), - event.values().get("deadline").unwrap().as_value().parse().ok(), - match &selected_category() { - Category::WaitingFor(_) => Category::WaitingFor( - event.values().get("category_waiting_for").unwrap() - .as_value() - ), - Category::Calendar { .. } => Category::Calendar { - date: event.values().get("category_calendar_date").unwrap() - .as_value().parse().unwrap(), - reoccurrence: category_calendar_reoccurrence_interval().map( - |reoccurrence_interval| Reoccurrence::new( - event.values().get("category_calendar_date").unwrap() - .as_value().parse().unwrap(), - reoccurrence_interval, - event.values().get("category_calendar_reoccurrence_length") - .unwrap().as_value().parse().unwrap() - ) - ), - time: event.values().get("category_calendar_time").unwrap() - .as_value().parse().ok().map(|time| - CalendarTime::new( - time, - REMINDER_OFFSETS[ - event.values() - .get("category_calendar_reminder_offset_index").unwrap() - .as_value().parse::().unwrap() - ] - ) - ) - }, - category => category.clone() - }, - event.values().get("project_id").unwrap() - .as_value().parse::().ok().filter(|&id| id > 0), - ); - if let Some(task) = task { - let _ = edit_task(task.id(), new_task).await; - } else { - let _ = create_task(new_task).await; - } - query_client.invalidate_queries(&[ - QueryKey::Tasks, - QueryKey::TasksInCategory(selected_category()) - ]); - on_successful_submit.call(()); - } - }, + div { class: "p-4 flex flex-col gap-4", - div { - class: "flex flex-row items-center gap-3", - label { - r#for: "input_title", - class: "min-w-6 text-center", - i { - class: "fa-solid fa-pen-clip text-zinc-400/50" - }, - }, - input { - name: "title", - required: true, - initial_value: task.as_ref().map(|task| task.title().to_owned()), - r#type: "text", - class: "py-2 px-3 grow bg-zinc-800/50 rounded-lg", - id: "input_title" - }, - }, - div { - class: "flex flex-row items-center gap-3", - label { - r#for: "input_project", - class: "min-w-6 text-center", - i { - class: "fa-solid fa-list text-zinc-400/50" - } - }, - select { - name: "project_id", - class: "px-3.5 py-2.5 bg-zinc-800/50 rounded-lg grow", - id: "input_project", - option { - value: 0, - "None" - }, - for project in projects { - option { - value: project.id().to_string(), - selected: task.as_ref().is_some_and( - |task| task.project_id() == Some(project.id()) - ), - {project.title()} - } - } - }, - }, - div { - class: "flex flex-row items-center gap-3", - label { - r#for: "input_deadline", - class: "min-w-6 text-center", - i { - class: "fa-solid fa-bomb text-zinc-400/50" - } - }, - input { - name: "deadline", - initial_value: task.as_ref().and_then(|task| task.deadline()) - .map(|deadline| deadline.format("%Y-%m-%d").to_string()), - r#type: "date", - class: "py-2 px-3 bg-zinc-800/50 rounded-lg grow basis-0", - id: "input_deadline" - } - }, - div { - class: "flex flex-row items-center gap-3", - label { - class: "min-w-6 text-center", - i { - class: "fa-solid fa-layer-group text-zinc-400/50" - } - }, - CategoryInput { - selected_category: selected_category, - class: "grow" - } - } - match selected_category() { - Category::WaitingFor(waiting_for) => rsx! { - div { - class: "flex flex-row items-center gap-3", - label { - r#for: "input_deadline", - class: "min-w-6 text-center", - i { - class: "fa-solid fa-hourglass-end text-zinc-400/50" - } - }, - input { - name: "category_waiting_for", - required: true, - initial_value: waiting_for, - r#type: "text", - class: "py-2 px-3 bg-zinc-800/50 rounded-lg grow", - id: "input_category_waiting_for" - }, - } - }, - Category::Calendar { date, reoccurrence, time } => rsx! { - div { - class: "flex flex-row items-center gap-3", - label { - r#for: "input_category_calendar_date", - class: "min-w-6 text-center", - i { - class: "fa-solid fa-clock text-zinc-400/50" - } - }, - div { - class: "grow flex flex-row gap-2", - input { - r#type: "date", - name: "category_calendar_date", - required: true, - initial_value: date.format("%Y-%m-%d").to_string(), - class: "py-2 px-3 bg-zinc-800/50 rounded-lg grow", - id: "input_category_calendar_date" - }, - input { - r#type: "time", - name: "category_calendar_time", - initial_value: time.map(|calendar_time| - calendar_time.time().format("%H:%M").to_string() + form { + class: "flex flex-col gap-4", + id: "form_task", + onsubmit: move |event| { + let task = task_for_submit.clone(); + async move { + let new_task = NewTask::new( + event.values().get("title").unwrap().as_value(), + event.values().get("deadline").unwrap().as_value().parse().ok(), + match &selected_category() { + Category::WaitingFor(_) => Category::WaitingFor( + event.values().get("category_waiting_for").unwrap() + .as_value() ), - class: "py-2 px-3 bg-zinc-800/50 rounded-lg grow", - id: "input_category_calendar_time", - oninput: move |event| { - category_calendar_has_time.set(!event.value().is_empty()); - } - } - } - }, - div { - class: "flex flex-row items-center gap-3", - label { - r#for: "category_calendar_reoccurrence_length", - class: "min-w-6 text-center", - i { - class: "fa-solid fa-repeat text-zinc-400/50" - } - }, - div { - class: "grow grid grid-cols-6 gap-2", - ReoccurrenceIntervalInput { - reoccurrence_interval: category_calendar_reoccurrence_interval + Category::Calendar { .. } => Category::Calendar { + date: event.values().get("category_calendar_date").unwrap() + .as_value().parse().unwrap(), + reoccurrence: category_calendar_reoccurrence_interval().map( + |reoccurrence_interval| Reoccurrence::new( + event.values().get("category_calendar_date").unwrap() + .as_value().parse().unwrap(), + reoccurrence_interval, + event.values() + .get("category_calendar_reoccurrence_length") + .unwrap().as_value().parse().unwrap() + ) + ), + time: event.values().get("category_calendar_time").unwrap() + .as_value().parse().ok().map(|time| + CalendarTime::new( + time, + REMINDER_OFFSETS[ + event.values() + .get("category_calendar_reminder_offset_index") + .unwrap().as_value().parse::().unwrap() + ] + ) + ) + }, + category => category.clone() }, - input { - r#type: "number", - inputmode: "numeric", - name: "category_calendar_reoccurrence_length", - disabled: category_calendar_reoccurrence_interval().is_none(), - required: true, - min: 1, - initial_value: category_calendar_reoccurrence_interval() - .map_or(String::new(), |_| reoccurrence.map_or(1, |reoccurrence| - reoccurrence.length()).to_string()), - class: "py-2 px-3 bg-zinc-800/50 rounded-lg col-span-2 text-right", - id: "category_calendar_reoccurrence_length" + event.values().get("project_id").unwrap() + .as_value().parse::().ok().filter(|&id| id > 0), + ); + if let Some(task) = task { + let _ = edit_task(task.id(), new_task).await; + } else { + let _ = create_task(new_task).await; + } + query_client.invalidate_queries(&[ + QueryKey::Tasks, + QueryKey::TasksInCategory(selected_category()) + ]); + on_successful_submit.call(()); + } + }, + div { + class: "flex flex-row items-center gap-3", + label { + r#for: "input_title", + class: "min-w-6 text-center", + i { + class: "fa-solid fa-pen-clip text-zinc-400/50" + }, + }, + input { + name: "title", + required: true, + initial_value: task.as_ref().map(|task| task.title().to_owned()), + r#type: "text", + class: "py-2 px-3 grow bg-zinc-800/50 rounded-lg", + id: "input_title" + }, + }, + div { + class: "flex flex-row items-center gap-3", + label { + r#for: "input_project", + class: "min-w-6 text-center", + i { + class: "fa-solid fa-list text-zinc-400/50" + } + }, + select { + name: "project_id", + class: "px-3.5 py-2.5 bg-zinc-800/50 rounded-lg grow", + id: "input_project", + option { + value: 0, + "None" + }, + for project in projects { + option { + value: project.id().to_string(), + selected: task.as_ref().is_some_and( + |task| task.project_id() == Some(project.id()) + ), + {project.title()} } } }, - if category_calendar_has_time() { + }, + div { + class: "flex flex-row items-center gap-3", + label { + r#for: "input_deadline", + class: "min-w-6 text-center", + i { + class: "fa-solid fa-bomb text-zinc-400/50" + } + }, + input { + name: "deadline", + initial_value: task.as_ref().and_then(|task| task.deadline()) + .map(|deadline| deadline.format("%Y-%m-%d").to_string()), + r#type: "date", + class: "py-2 px-3 bg-zinc-800/50 rounded-lg grow basis-0", + id: "input_deadline" + } + }, + div { + class: "flex flex-row items-center gap-3", + label { + class: "min-w-6 text-center", + i { + class: "fa-solid fa-layer-group text-zinc-400/50" + } + }, + CategoryInput { + selected_category: selected_category, + class: "grow" + } + } + match selected_category() { + Category::WaitingFor(waiting_for) => rsx! { div { class: "flex flex-row items-center gap-3", label { - r#for: "category_calendar_reminder_offset_index", + r#for: "input_deadline", class: "min-w-6 text-center", i { - class: "fa-solid fa-bell text-zinc-400/50" + class: "fa-solid fa-hourglass-end text-zinc-400/50" } }, input { - r#type: "range", - name: "category_calendar_reminder_offset_index", - min: 0, - max: REMINDER_OFFSETS.len() as i64 - 1, - initial_value: category_calendar_reminder_offset_index() - .to_string(), - class: "grow input-range-reverse", - id: "category_calendar_has_reminder", - oninput: move |event| { - category_calendar_reminder_offset_index.set( - event.value().parse().unwrap() - ); + name: "category_waiting_for", + required: true, + initial_value: waiting_for, + r#type: "text", + class: "py-2 px-3 bg-zinc-800/50 rounded-lg grow", + id: "input_category_waiting_for" + }, + } + }, + Category::Calendar { date, reoccurrence, time } => rsx! { + div { + class: "flex flex-row items-center gap-3", + label { + r#for: "input_category_calendar_date", + class: "min-w-6 text-center", + i { + class: "fa-solid fa-clock text-zinc-400/50" } }, - label { - r#for: "category_calendar_reminder_offset_index", - class: "pr-3 min-w-16 text-right", - {REMINDER_OFFSETS[category_calendar_reminder_offset_index()].map( - |offset| if offset.num_hours() < 1 { - format!("{} min", offset.num_minutes()) - } else { - format!("{} h", offset.num_hours()) + div { + class: "grow flex flex-row gap-2", + input { + r#type: "date", + name: "category_calendar_date", + required: true, + initial_value: date.format("%Y-%m-%d").to_string(), + class: "py-2 px-3 bg-zinc-800/50 rounded-lg grow", + id: "input_category_calendar_date" + }, + input { + r#type: "time", + name: "category_calendar_time", + initial_value: time.map(|calendar_time| + calendar_time.time().format("%H:%M").to_string() + ), + class: "py-2 px-3 bg-zinc-800/50 rounded-lg grow", + id: "input_category_calendar_time", + oninput: move |event| { + category_calendar_has_time.set(!event.value().is_empty()); } - ).unwrap_or_else(|| "none".to_string())} + } + } + }, + div { + class: "flex flex-row items-center gap-3", + label { + r#for: "category_calendar_reoccurrence_length", + class: "min-w-6 text-center", + i { + class: "fa-solid fa-repeat text-zinc-400/50" + } + }, + div { + class: "grow grid grid-cols-6 gap-2", + ReoccurrenceIntervalInput { + reoccurrence_interval: category_calendar_reoccurrence_interval + }, + input { + r#type: "number", + inputmode: "numeric", + name: "category_calendar_reoccurrence_length", + disabled: category_calendar_reoccurrence_interval().is_none(), + required: true, + min: 1, + initial_value: category_calendar_reoccurrence_interval().map_or( + String::new(), + |_| reoccurrence.map_or(1, |reoccurrence| + reoccurrence.length()).to_string() + ), + class: "py-2 px-3 bg-zinc-800/50 rounded-lg col-span-2 text-right", + id: "category_calendar_reoccurrence_length" + } + } + }, + if category_calendar_has_time() { + div { + class: "flex flex-row items-center gap-3", + label { + r#for: "category_calendar_reminder_offset_index", + class: "min-w-6 text-center", + i { + class: "fa-solid fa-bell text-zinc-400/50" + } + }, + input { + r#type: "range", + name: "category_calendar_reminder_offset_index", + min: 0, + max: REMINDER_OFFSETS.len() as i64 - 1, + initial_value: category_calendar_reminder_offset_index() + .to_string(), + class: "grow input-range-reverse", + id: "category_calendar_has_reminder", + oninput: move |event| { + category_calendar_reminder_offset_index.set( + event.value().parse().unwrap() + ); + } + }, + label { + r#for: "category_calendar_reminder_offset_index", + class: "pr-3 min-w-16 text-right", + {REMINDER_OFFSETS[category_calendar_reminder_offset_index()].map( + |offset| if offset.num_hours() < 1 { + format!("{} min", offset.num_minutes()) + } else { + format!("{} h", offset.num_hours()) + } + ).unwrap_or_else(|| "none".to_string())} + } } } - } - }, - _ => None + }, + _ => None + } }, + if let Some(task) = task.as_ref() { + SubtasksForm { + task_id: task.id() + } + } div { class: "flex flex-row justify-between mt-auto", button { @@ -353,10 +366,10 @@ pub(crate) fn TaskForm(task: Option, on_successful_submit: EventHandler<() Category::Trash, task.project_id() ); - + let _ = edit_task(task.id(), new_task).await; } - + query_client.invalidate_queries(&[ QueryKey::TasksInCategory(task.category().clone()), QueryKey::Tasks, @@ -370,6 +383,7 @@ pub(crate) fn TaskForm(task: Option, on_successful_submit: EventHandler<() } } button { + form: "form_task", r#type: "submit", class: "py-2 px-4 bg-zinc-300/50 rounded-lg", i { diff --git a/src/components/task_list.rs b/src/components/task_list.rs index a6e1959..65f00e8 100644 --- a/src/components/task_list.rs +++ b/src/components/task_list.rs @@ -15,86 +15,94 @@ pub(crate) fn TaskList(tasks: Vec, class: Option<&'static str>) -> Element rsx! { div { class: format!("flex flex-col {}", class.unwrap_or("")), - {tasks.iter().cloned().map(|task| { - let task_clone = task.clone(); - rsx! { - div { - key: "{task.id()}", - class: format!( - "px-8 pt-5 {} flex flex-row gap-4 select-none {}", - if task.deadline().is_some() { + for task in tasks.clone() { + div { + key: "{task.id()}", + class: format!( + "px-8 pt-5 {} flex flex-row gap-4 select-none {}", + if task.deadline().is_some() { + "pb-0.5" + } else if let Category::Calendar { time, .. } = task.category() { + if time.is_some() { "pb-0.5" - } else if let Category::Calendar { time, .. } = task.category() { - if time.is_some() { - "pb-0.5" - } else { - "pb-5" - } } else { "pb-5" - }, - if task_being_edited().is_some_and(|t| t.id() == task.id()) { - "bg-zinc-700" - } else { "" } + } + } else { + "pb-5" + }, + if task_being_edited().is_some_and(|t| t.id() == task.id()) { + "bg-zinc-700" + } else { "" } + ), + onclick: { + let task = task.clone(); + move |_| task_being_edited.set(Some(task.clone())) + }, + i { + class: format!( + "{} text-3xl text-zinc-500", + if *(task.category()) == Category::Done { + "fa solid fa-square-check" + } else { + "fa-regular fa-square" + } ), - onclick: move |_| task_being_edited.set(Some(task.clone())), - i { - class: format!( - "{} text-3xl text-zinc-500", - if *(task_clone.category()) == Category::Done { - "fa solid fa-square-check" - } else { - "fa-regular fa-square" - } - ), - onclick: move |event| { + onclick: { + let task = task.clone(); + move |event| { // To prevent editing the task. event.stop_propagation(); - let task = task_clone.clone(); + let task = task.clone(); async move { let completed_task = complete_task(task.id()).await; - query_client.invalidate_queries(&[ - QueryKey::Tasks, + let mut query_keys = vec![ + QueryKey::Tasks, QueryKey::TasksInCategory( completed_task.unwrap().category().clone() - ), - ]); + ) + ]; + if let Category::Calendar { reoccurrence: Some(_), .. } + = task.category() { + query_keys.push(QueryKey::SubtasksOfTaskId(task.id())); + } + query_client.invalidate_queries(&query_keys); } } + } + }, + div { + class: "flex flex-col", + div { + class: "mt-1 grow font-medium", + {task.title()} }, div { - class: "flex flex-col", - div { - class: "mt-1 grow font-medium", - {task.title()} - }, - div { - class: "flex flex-row gap-3", - if let Some(deadline) = task.deadline() { + class: "flex flex-row gap-3", + if let Some(deadline) = task.deadline() { + div { + class: "text-sm text-zinc-400", + i { + class: "fa-solid fa-bomb" + }, + {deadline.format(" %m. %d.").to_string()} + } + } + if let Category::Calendar { time, .. } = task.category() { + if let Some(calendar_time) = time { div { class: "text-sm text-zinc-400", i { - class: "fa-solid fa-bomb" + class: "fa-solid fa-clock" }, - {deadline.format(" %m. %d.").to_string()} - } - } - if let Category::Calendar { time, .. } = task.category() { - if let Some(calendar_time) = time { - div { - class: "text-sm text-zinc-400", - i { - class: "fa-solid fa-clock" - }, - {calendar_time.time().format(" %k:%M").to_string()} - } + {calendar_time.time().format(" %k:%M").to_string()} } } } } } } - })} + } } } }