From 6dcf00efe9577b0cad0d27d6dfdf8267d101d80e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Volf?= <66163112+matous-volf@users.noreply.github.com> Date: Sat, 7 Sep 2024 09:53:22 +0200 Subject: [PATCH 1/2] feat: polish project editing --- src/components/pages/projects_page.rs | 6 ++---- src/components/project_form.rs | 16 ++++++---------- src/errors/mod.rs | 4 ++-- ...{project_create_error.rs => project_error.rs} | 6 ++++++ src/server/projects.rs | 10 +++------- 5 files changed, 19 insertions(+), 23 deletions(-) rename src/errors/{project_create_error.rs => project_error.rs} (91%) diff --git a/src/components/pages/projects_page.rs b/src/components/pages/projects_page.rs index 5b18b05..028181c 100644 --- a/src/components/pages/projects_page.rs +++ b/src/components/pages/projects_page.rs @@ -20,13 +20,11 @@ pub(crate) fn ProjectsPage() -> Element { key: "{project.id()}", class: format!( "px-8 py-4 select-none {}", - if project_being_edited().map(|p| p.id()) == Some(project.id()) { + if project_being_edited().is_some_and(|p| p.id() == project.id()) { "bg-zinc-700" } else { "" } ), - onclick: move |_| { - project_being_edited.set(Some(project.clone())); - }, + onclick: move |_| project_being_edited.set(Some(project.clone())), {project.title()} } } diff --git a/src/components/project_form.rs b/src/components/project_form.rs index 5efb58b..ee71abc 100644 --- a/src/components/project_form.rs +++ b/src/components/project_form.rs @@ -10,24 +10,20 @@ use crate::query::{QueryErrors, QueryKey, QueryValue}; pub(crate) fn ProjectForm(project: Option, on_successful_submit: EventHandler<()>) -> Element { let query_client = use_query_client::(); - let project_for_submit = project.clone(); - + rsx! { form { onsubmit: move |event| { - let project_clone = project_for_submit.clone(); + let project = project_for_submit.clone(); async move { let new_project = NewProject::new( event.values().get("title").unwrap().as_value() ); - match project_clone { - Some(project) => { - let _ = edit_project(project.id(), new_project).await; - } - None => { - let _ = create_project(new_project).await; - } + if let Some(project) = project { + let _ = edit_project(project.id(), new_project).await; + } else { + let _ = create_project(new_project).await; } query_client.invalidate_queries(&[ QueryKey::Projects diff --git a/src/errors/mod.rs b/src/errors/mod.rs index a0ca9ed..3eeb29b 100644 --- a/src/errors/mod.rs +++ b/src/errors/mod.rs @@ -1,4 +1,4 @@ pub(crate) mod error; pub(crate) mod error_vec; -pub(crate) mod project_create_error; -pub(crate) mod task_create_error; +pub(crate) mod project_error; +pub(crate) mod task_error; diff --git a/src/errors/project_create_error.rs b/src/errors/project_error.rs similarity index 91% rename from src/errors/project_create_error.rs rename to src/errors/project_error.rs index 8531ca4..f93283f 100644 --- a/src/errors/project_create_error.rs +++ b/src/errors/project_error.rs @@ -34,6 +34,12 @@ impl From for ErrorVec { } } +impl From for ProjectError { + fn from(_: diesel::result::Error) -> Self { + ProjectError::Error(Error::ServerInternal) + } +} + // Has to be implemented for Dioxus server functions. impl Display for ProjectError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { diff --git a/src/server/projects.rs b/src/server/projects.rs index 0d6f623..94ac3f9 100644 --- a/src/server/projects.rs +++ b/src/server/projects.rs @@ -1,6 +1,6 @@ use crate::errors::error::Error; use crate::errors::error_vec::ErrorVec; -use crate::errors::project_create_error::ProjectError; +use crate::errors::project_error::ProjectError; use crate::models::project::{NewProject, Project}; use crate::server::database_connection::establish_database_connection; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; @@ -24,9 +24,7 @@ pub(crate) async fn create_project(new_project: NewProject) .values(&new_project) .returning(Project::as_returning()) .get_result(&mut connection) - .map_err::, _>( - |_| vec![ProjectError::Error(Error::ServerInternal)].into() - )?; + .map_err::, _>(|error| vec![error.into()].into())?; Ok(new_project) } @@ -69,9 +67,7 @@ pub(crate) async fn edit_project(project_id: i32, new_project: NewProject) .set(title.eq(new_project.title)) .returning(Project::as_returning()) .get_result(&mut connection) - .map_err::, _>( - |_| vec![ProjectError::Error(Error::ServerInternal)].into() - )?; + .map_err::, _>(|error| vec![error.into()].into())?; Ok(updated_project) } -- 2.47.1 From 2152014b7e69a0ead2238822059e2f293b8af214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Volf?= <66163112+matous-volf@users.noreply.github.com> Date: Sat, 7 Sep 2024 09:55:12 +0200 Subject: [PATCH 2/2] feat: add the ability to edit a task upon clicking in a list --- src/components/bottom_panel.rs | 14 ++- src/components/category_input.rs | 2 +- src/components/form_open_button.rs | 7 +- src/components/layout.rs | 12 +- src/components/task_form.rs | 103 ++++++++++++------ src/components/task_list.rs | 10 +- .../{task_create_error.rs => task_error.rs} | 34 ++++-- src/server/tasks.rs | 59 ++++++---- 8 files changed, 166 insertions(+), 75 deletions(-) rename src/errors/{task_create_error.rs => task_error.rs} (59%) diff --git a/src/components/bottom_panel.rs b/src/components/bottom_panel.rs index 29f9dbe..6ba931a 100644 --- a/src/components/bottom_panel.rs +++ b/src/components/bottom_panel.rs @@ -1,9 +1,10 @@ -use dioxus::prelude::*; use crate::components::navigation::Navigation; use crate::components::project_form::ProjectForm; use crate::components::task_form::TaskForm; use crate::models::project::Project; +use crate::models::task::Task; use crate::route::Route; +use dioxus::prelude::*; #[component] pub(crate) fn BottomPanel(display_form: Signal) -> Element { @@ -12,8 +13,9 @@ pub(crate) fn BottomPanel(display_form: Signal) -> Element { let mut expanded = use_signal(|| display_form()); let navigation_expanded = use_signal(|| false); let current_route = use_route(); - + let mut project_being_edited = use_context::>>(); + let mut task_being_edited = use_context::>>(); use_effect(use_reactive(&display_form, move |display_form| { if display_form() { @@ -22,7 +24,11 @@ pub(crate) fn BottomPanel(display_form: Signal) -> Element { spawn(async move { // Necessary for a smooth – not instant – height transition. async_std::task::sleep(std::time::Duration::from_millis(500)).await; - expanded.set(false); + /* The check is necessary for the situation when the user expands the panel while + it is being closed. */ + if !display_form() { + expanded.set(false); + } }); } })); @@ -51,8 +57,10 @@ pub(crate) fn BottomPanel(display_form: Signal) -> Element { }, _ => rsx! { TaskForm { + task: task_being_edited(), on_successful_submit: move |_| { display_form.set(false); + task_being_edited.set(None); } } } diff --git a/src/components/category_input.rs b/src/components/category_input.rs index b9ffbab..615ebe8 100644 --- a/src/components/category_input.rs +++ b/src/components/category_input.rs @@ -74,7 +74,7 @@ pub(crate) fn CategoryInput(selected_category: Signal, class: Option<& ), onclick: move |_| { selected_category.set(Category::Calendar { - date: NaiveDate::default(), + date: chrono::Local::now().date_naive(), reoccurrence: None, time: None, }); diff --git a/src/components/form_open_button.rs b/src/components/form_open_button.rs index 81c584a..f96afeb 100644 --- a/src/components/form_open_button.rs +++ b/src/components/form_open_button.rs @@ -1,16 +1,19 @@ -use dioxus::prelude::*; use crate::models::project::Project; +use crate::models::task::Task; +use dioxus::prelude::*; #[component] pub(crate) fn FormOpenButton(opened: Signal) -> Element { let mut project_being_edited = use_context::>>(); - + let mut task_being_edited = use_context::>>(); + rsx! { button { class: "pointer-events-auto m-4 py-3 px-5 self-end text-center bg-zinc-300/50 rounded-xl border-t-zinc-200 border-t backdrop-blur drop-shadow-[0_-5px_10px_rgba(0,0,0,0.2)] text-2xl text-zinc-200", onclick: move |_| { if opened() { project_being_edited.set(None); + task_being_edited.set(None); } opened.set(!opened()); }, diff --git a/src/components/layout.rs b/src/components/layout.rs index 1e67361..2a9ee41 100644 --- a/src/components/layout.rs +++ b/src/components/layout.rs @@ -1,11 +1,12 @@ use crate::components::bottom_panel::BottomPanel; +use crate::components::form_open_button::FormOpenButton; +use crate::components::sticky_bottom::StickyBottom; +use crate::models::project::Project; +use crate::models::task::Task; use crate::route::Route; use dioxus::core_macro::rsx; use dioxus::dioxus_core::Element; use dioxus::prelude::*; -use crate::components::form_open_button::FormOpenButton; -use crate::components::sticky_bottom::StickyBottom; -use crate::models::project::Project; #[component] pub(crate) fn Layout() -> Element { @@ -13,9 +14,12 @@ pub(crate) fn Layout() -> Element { let project_being_edited = use_context_provider::>>( || Signal::new(None) ); + let task_being_edited = use_context_provider::>>( + || Signal::new(None) + ); use_effect(move || { - display_form.set(project_being_edited().is_some()); + display_form.set(project_being_edited().is_some() || task_being_edited().is_some()); }); rsx! { diff --git a/src/components/task_form.rs b/src/components/task_form.rs index 95cabd1..d0bc46b 100644 --- a/src/components/task_form.rs +++ b/src/components/task_form.rs @@ -2,15 +2,16 @@ use crate::components::category_input::CategoryInput; use crate::components::reoccurrence_input::ReoccurrenceIntervalInput; use crate::models::category::{CalendarTime, Category, Reoccurrence}; use crate::models::task::NewTask; +use crate::models::task::Task; +use crate::query::{QueryErrors, QueryKey, QueryValue}; +use crate::route::Route; use crate::server::projects::get_projects; -use crate::server::tasks::create_task; -use chrono::{Duration, NaiveDate}; +use crate::server::tasks::{create_task, edit_task}; +use chrono::{Duration}; use dioxus::core_macro::{component, rsx}; use dioxus::dioxus_core::Element; use dioxus::prelude::*; use dioxus_query::prelude::use_query_client; -use crate::query::{QueryErrors, QueryKey, QueryValue}; -use crate::route::Route; const REMINDER_OFFSETS: [Option; 17] = [ None, @@ -33,31 +34,54 @@ const REMINDER_OFFSETS: [Option; 17] = [ ]; #[component] -pub(crate) fn TaskForm(on_successful_submit: EventHandler<()>) -> Element { +pub(crate) fn TaskForm(task: Option, on_successful_submit: EventHandler<()>) -> Element { let projects = use_server_future(get_projects)?.unwrap().unwrap(); let route = use_route::(); - let selected_category = use_signal(|| match route { - Route::CategorySomedayMaybePage => Category::SomedayMaybe, - Route::CategoryWaitingForPage => Category::WaitingFor(String::new()), - Route::CategoryNextStepsPage => Category::NextSteps, - Route::CategoryCalendarPage | Route::CategoryTodayPage => Category::Calendar { - date: NaiveDate::default(), - reoccurrence: None, - time: None, - }, - Route::CategoryLongTermPage => Category::LongTerm, - _ => Category::Inbox, - }); - let category_calendar_reoccurrence_interval = use_signal(|| None); - let mut category_calendar_has_time = use_signal(|| false); - let mut category_calendar_reminder_offset_index = use_signal(|| REMINDER_OFFSETS.len() - 1); + let selected_category = use_signal(|| if let Some(task) = &task { + task.category().clone() + } else { + match route { + Route::CategorySomedayMaybePage => Category::SomedayMaybe, + Route::CategoryWaitingForPage => Category::WaitingFor(String::new()), + Route::CategoryNextStepsPage => Category::NextSteps, + Route::CategoryCalendarPage | Route::CategoryTodayPage => Category::Calendar { + date: chrono::Local::now().date_naive(), + reoccurrence: None, + time: None, + }, + Route::CategoryLongTermPage => Category::LongTerm, + _ => Category::Inbox, + } + } + ); + let category_calendar_reoccurrence_interval = use_signal(|| task.as_ref().and_then(|task| + if let Category::Calendar { reoccurrence: Some(reoccurrence), .. } = task.category() { + Some(reoccurrence.interval().clone()) + } else { + None + } + )); + let mut category_calendar_has_time = use_signal(|| task.as_ref().is_some_and( + |task| matches!(*task.category(), Category::Calendar { time: Some(_), .. })) + ); + let mut category_calendar_reminder_offset_index = use_signal(|| task.as_ref().and_then(|task| + if let Category::Calendar { time: Some(time), .. } = task.category() { + REMINDER_OFFSETS.iter().position(|&reminder_offset| + reminder_offset == time.reminder_offset() + ) + } else { + None + } + ).unwrap_or(REMINDER_OFFSETS.len() - 1)); let query_client = use_query_client::(); - + 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(), @@ -96,7 +120,11 @@ pub(crate) fn TaskForm(on_successful_submit: EventHandler<()>) -> Element { event.values().get("project_id").unwrap() .as_value().parse::().ok().filter(|&id| id > 0), ); - let _ = create_task(new_task).await; + 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()) @@ -115,9 +143,10 @@ pub(crate) fn TaskForm(on_successful_submit: EventHandler<()>) -> Element { }, }, input { - r#type: "text", 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" }, @@ -142,6 +171,9 @@ pub(crate) fn TaskForm(on_successful_submit: EventHandler<()>) -> Element { 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()} } } @@ -157,8 +189,10 @@ pub(crate) fn TaskForm(on_successful_submit: EventHandler<()>) -> Element { } }, input { - r#type: "date", 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" } @@ -177,7 +211,7 @@ pub(crate) fn TaskForm(on_successful_submit: EventHandler<()>) -> Element { } } match selected_category() { - Category::WaitingFor(_) => rsx! { + Category::WaitingFor(waiting_for) => rsx! { div { class: "flex flex-row items-center gap-3", label { @@ -188,15 +222,16 @@ pub(crate) fn TaskForm(on_successful_submit: EventHandler<()>) -> Element { } }, input { - r#type: "text", 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 { .. } => rsx! { + Category::Calendar { date, reoccurrence, time } => rsx! { div { class: "flex flex-row items-center gap-3", label { @@ -212,13 +247,16 @@ pub(crate) fn TaskForm(on_successful_submit: EventHandler<()>) -> Element { r#type: "date", name: "category_calendar_date", required: true, - initial_value: chrono::Local::now().format("%Y-%m-%d").to_string(), + 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| { @@ -248,9 +286,9 @@ pub(crate) fn TaskForm(on_successful_submit: EventHandler<()>) -> Element { disabled: category_calendar_reoccurrence_interval().is_none(), required: true, min: 1, - initial_value: - if category_calendar_reoccurrence_interval().is_none() { "" } - else { "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" } @@ -271,7 +309,8 @@ pub(crate) fn TaskForm(on_successful_submit: EventHandler<()>) -> Element { name: "category_calendar_reminder_offset_index", min: 0, max: REMINDER_OFFSETS.len() as i64 - 1, - initial_value: 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| { diff --git a/src/components/task_list.rs b/src/components/task_list.rs index c9b04cb..8429f00 100644 --- a/src/components/task_list.rs +++ b/src/components/task_list.rs @@ -6,6 +6,8 @@ use dioxus::prelude::*; #[component] pub(crate) fn TaskList(tasks: Vec, class: Option<&'static str>) -> Element { + let mut task_being_edited = use_context::>>(); + rsx! { div { class: format!("flex flex-col {}", class.unwrap_or("")), @@ -13,7 +15,7 @@ pub(crate) fn TaskList(tasks: Vec, class: Option<&'static str>) -> Element div { key: "{task.id()}", class: format!( - "px-8 pt-5 {} flex flex-row gap-4", + "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() { @@ -24,8 +26,12 @@ pub(crate) fn TaskList(tasks: Vec, class: Option<&'static str>) -> Element } } else { "pb-5" - } + }, + if task_being_edited().is_some_and(|t| t.id() == task.id()) { + "bg-zinc-700" + } else { "" } ), + onclick: move |_| task_being_edited.set(Some(task.clone())), i { class: "fa-regular fa-square text-3xl text-zinc-600", }, diff --git a/src/errors/task_create_error.rs b/src/errors/task_error.rs similarity index 59% rename from src/errors/task_create_error.rs rename to src/errors/task_error.rs index 649163b..a769de9 100644 --- a/src/errors/task_create_error.rs +++ b/src/errors/task_error.rs @@ -6,13 +6,13 @@ use std::str::FromStr; use validator::{ValidationErrors, ValidationErrorsKind}; #[derive(Serialize, Deserialize, Debug)] -pub enum TaskCreateError { +pub enum TaskError { TitleLengthInvalid, ProjectNotFound, Error(Error), } -impl From for ErrorVec { +impl From for ErrorVec { fn from(validation_errors: ValidationErrors) -> Self { validation_errors.errors() .iter() @@ -22,31 +22,49 @@ impl From for ErrorVec { .iter() .map(|validation_error| validation_error.code.as_ref()) .map(|code| match code { - "title_length" => TaskCreateError::TitleLengthInvalid, + "title_length" => TaskError::TitleLengthInvalid, _ => panic!("Unexpected validation error code: `{code}`."), }) - .collect::>(), + .collect::>(), _ => panic!("Unexpected validation error kind."), }, _ => panic!("Unexpected validation field name: `{field}`."), }) - .collect::>() + .collect::>() .into() } } +impl From for TaskError { + 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("tasks_project_id_fkey") => TaskError::ProjectNotFound, + _ => TaskError::Error(Error::ServerInternal) + } + } + _ => { + TaskError::Error(Error::ServerInternal) + } + } + } +} + // Has to be implemented for Dioxus server functions. -impl Display for TaskCreateError { +impl Display for TaskError { 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 TaskCreateError { +impl FromStr for TaskError { type Err = (); fn from_str(_: &str) -> Result { - Ok(TaskCreateError::Error(Error::ServerInternal)) + Ok(TaskError::Error(Error::ServerInternal)) } } diff --git a/src/server/tasks.rs b/src/server/tasks.rs index 62c383a..81efcca 100644 --- a/src/server/tasks.rs +++ b/src/server/tasks.rs @@ -2,52 +2,37 @@ use crate::errors::error::Error; use crate::errors::error_vec::ErrorVec; use crate::models::task::{NewTask, Task}; use crate::server::database_connection::establish_database_connection; -use diesel::{QueryDsl, RunQueryDsl, SelectableHelper}; +use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; use dioxus::prelude::*; use validator::Validate; -use crate::errors::task_create_error::TaskCreateError; +use crate::errors::task_error::TaskError; use crate::models::category::Category; #[server] pub(crate) async fn create_task(new_task: NewTask) - -> Result>> { + -> Result>> { use crate::schema::tasks; new_task.validate() - .map_err::, _>(|errors| errors.into())?; + .map_err::, _>(|errors| errors.into())?; let mut connection = establish_database_connection() - .map_err::, _>( - |_| vec![TaskCreateError::Error(Error::ServerInternal)].into() + .map_err::, _>( + |_| vec![TaskError::Error(Error::ServerInternal)].into() )?; let new_task = diesel::insert_into(tasks::table) .values(&new_task) .returning(Task::as_returning()) .get_result(&mut connection) - .map_err::, _>(|error| { - let error = match error { - diesel::result::Error::DatabaseError( - diesel::result::DatabaseErrorKind::ForeignKeyViolation, info - ) => { - match info.constraint_name() { - Some("tasks_project_id_fkey") => TaskCreateError::ProjectNotFound, - _ => TaskCreateError::Error(Error::ServerInternal) - } - }, - _ => { - TaskCreateError::Error(Error::ServerInternal) - } - }; - vec![error].into() - })?; + .map_err::, _>(|error| vec![error.into()].into())?; Ok(new_task) } #[server] pub(crate) async fn get_tasks_in_category(filtered_category: Category) - -> Result, ServerFnError>> { + -> Result, ServerFnError>> { use crate::schema::tasks::dsl::*; let mut connection = establish_database_connection() @@ -65,3 +50,31 @@ pub(crate) async fn get_tasks_in_category(filtered_category: Category) Ok(results) } + +#[server] +pub(crate) async fn edit_task(task_id: i32, new_task: NewTask) + -> Result>> { + use crate::schema::tasks::dsl::*; + + new_task.validate() + .map_err::, _>(|errors| errors.into())?; + + let mut connection = establish_database_connection() + .map_err::, _>( + |_| vec![TaskError::Error(Error::ServerInternal)].into() + )?; + + let updated_task = diesel::update(tasks) + .filter(id.eq(task_id)) + .set(( + title.eq(new_task.title), + deadline.eq(new_task.deadline), + category.eq(new_task.category), + project_id.eq(new_task.project_id), + )) + .returning(Task::as_returning()) + .get_result(&mut connection) + .map_err::, _>(|error| vec![error.into()].into())?; + + Ok(updated_task) +} -- 2.47.1