feat: ability to edit a task #32

Merged
matous-volf merged 2 commits from feat/task-edit into main 2024-09-07 08:26:42 +00:00
8 changed files with 166 additions and 75 deletions
Showing only changes of commit 2152014b7e - Show all commits

View File

@ -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<bool>) -> Element {
@ -14,6 +15,7 @@ pub(crate) fn BottomPanel(display_form: Signal<bool>) -> Element {
let current_route = use_route();
let mut project_being_edited = use_context::<Signal<Option<Project>>>();
let mut task_being_edited = use_context::<Signal<Option<Task>>>();
use_effect(use_reactive(&display_form, move |display_form| {
if display_form() {
@ -22,7 +24,11 @@ pub(crate) fn BottomPanel(display_form: Signal<bool>) -> Element {
spawn(async move {
// Necessary for a smooth not instant height transition.
async_std::task::sleep(std::time::Duration::from_millis(500)).await;
/* 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<bool>) -> Element {
},
_ => rsx! {
TaskForm {
task: task_being_edited(),
on_successful_submit: move |_| {
display_form.set(false);
task_being_edited.set(None);
}
}
}

View File

@ -74,7 +74,7 @@ pub(crate) fn CategoryInput(selected_category: Signal<Category>, class: Option<&
),
onclick: move |_| {
selected_category.set(Category::Calendar {
date: NaiveDate::default(),
date: chrono::Local::now().date_naive(),
reoccurrence: None,
time: None,
});

View File

@ -1,9 +1,11 @@
use dioxus::prelude::*;
use crate::models::project::Project;
use crate::models::task::Task;
use dioxus::prelude::*;
#[component]
pub(crate) fn FormOpenButton(opened: Signal<bool>) -> Element {
let mut project_being_edited = use_context::<Signal<Option<Project>>>();
let mut task_being_edited = use_context::<Signal<Option<Task>>>();
rsx! {
button {
@ -11,6 +13,7 @@ pub(crate) fn FormOpenButton(opened: Signal<bool>) -> Element {
onclick: move |_| {
if opened() {
project_being_edited.set(None);
task_being_edited.set(None);
}
opened.set(!opened());
coderabbitai[bot] commented 2024-09-07 07:59:55 +00:00 (Migrated from github.com)
Review

Enhanced State Management in FormOpenButton

The addition of task_being_edited alongside project_being_edited effectively allows the component to manage the state of both projects and tasks. The updated onclick handler correctly resets both states, ensuring a comprehensive reset of the component's state when toggled.

Consider adding comments to clarify the purpose of each state management line, especially for new developers or for future maintenance.

**Enhanced State Management in `FormOpenButton`** The addition of `task_being_edited` alongside `project_being_edited` effectively allows the component to manage the state of both projects and tasks. The updated `onclick` handler correctly resets both states, ensuring a comprehensive reset of the component's state when toggled. Consider adding comments to clarify the purpose of each state management line, especially for new developers or for future maintenance. <!-- This is an auto-generated comment by CodeRabbit -->
},

View File

@ -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<Option<Project>>>(
|| Signal::new(None)
);
let task_being_edited = use_context_provider::<Signal<Option<Task>>>(
|| 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! {

View File

@ -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<Duration>; 17] = [
None,
@ -33,31 +34,54 @@ const REMINDER_OFFSETS: [Option<Duration>; 17] = [
];
#[component]
pub(crate) fn TaskForm(on_successful_submit: EventHandler<()>) -> Element {
pub(crate) fn TaskForm(task: Option<Task>, on_successful_submit: EventHandler<()>) -> Element {
let projects = use_server_future(get_projects)?.unwrap().unwrap();
let route = use_route::<Route>();
let selected_category = use_signal(|| match route {
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: NaiveDate::default(),
date: chrono::Local::now().date_naive(),
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 category_calendar_reoccurrence_interval = use_signal(|| task.as_ref().and_then(|task|
if let Category::Calendar { reoccurrence: Some(reoccurrence), .. } = task.category() {
coderabbitai[bot] commented 2024-09-07 07:59:56 +00:00 (Migrated from github.com)
Review

Refactor suggestion: Simplify function signature.

The function TaskForm now takes an optional Task parameter to handle both task creation and editing. Consider refactoring to separate concerns, potentially splitting this into two distinct components or functions for clarity and maintainability.

**Refactor suggestion: Simplify function signature.** The function `TaskForm` now takes an optional `Task` parameter to handle both task creation and editing. Consider refactoring to separate concerns, potentially splitting this into two distinct components or functions for clarity and maintainability. <!-- This is an auto-generated comment by CodeRabbit -->
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::<QueryValue, QueryErrors, QueryKey>();
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::<i32>().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())
@ -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| {

View File

@ -6,6 +6,8 @@ use dioxus::prelude::*;
#[component]
pub(crate) fn TaskList(tasks: Vec<Task>, class: Option<&'static str>) -> Element {
let mut task_being_edited = use_context::<Signal<Option<Task>>>();
rsx! {
div {
class: format!("flex flex-col {}", class.unwrap_or("")),
@ -13,7 +15,7 @@ pub(crate) fn TaskList(tasks: Vec<Task>, 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<Task>, 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",
},

View File

@ -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<ValidationErrors> for ErrorVec<TaskCreateError> {
impl From<ValidationErrors> for ErrorVec<TaskError> {
fn from(validation_errors: ValidationErrors) -> Self {
validation_errors.errors()
.iter()
@ -22,31 +22,49 @@ impl From<ValidationErrors> for ErrorVec<TaskCreateError> {
.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::<Vec<TaskCreateError>>(),
.collect::<Vec<TaskError>>(),
_ => panic!("Unexpected validation error kind."),
},
_ => panic!("Unexpected validation field name: `{field}`."),
})
.collect::<Vec<TaskCreateError>>()
.collect::<Vec<TaskError>>()
.into()
}
}
impl From<diesel::result::Error> 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<Self, Self::Err> {
Ok(TaskCreateError::Error(Error::ServerInternal))
Ok(TaskError::Error(Error::ServerInternal))
}
}

View File

@ -2,45 +2,30 @@ 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<Task, ServerFnError<ErrorVec<TaskCreateError>>> {
-> Result<Task, ServerFnError<ErrorVec<TaskError>>> {
use crate::schema::tasks;
new_task.validate()
.map_err::<ErrorVec<TaskCreateError>, _>(|errors| errors.into())?;
.map_err::<ErrorVec<TaskError>, _>(|errors| errors.into())?;
let mut connection = establish_database_connection()
.map_err::<ErrorVec<TaskCreateError>, _>(
|_| vec![TaskCreateError::Error(Error::ServerInternal)].into()
.map_err::<ErrorVec<TaskError>, _>(
|_| 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::<ErrorVec<TaskCreateError>, _>(|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::<ErrorVec<TaskError>, _>(|error| vec![error.into()].into())?;
Ok(new_task)
}
@ -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<Task, ServerFnError<ErrorVec<TaskError>>> {
use crate::schema::tasks::dsl::*;
new_task.validate()
.map_err::<ErrorVec<TaskError>, _>(|errors| errors.into())?;
let mut connection = establish_database_connection()
.map_err::<ErrorVec<TaskError>, _>(
|_| 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::<ErrorVec<TaskError>, _>(|error| vec![error.into()].into())?;
Ok(updated_task)
}