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()} } } } } } } - })} + } } } }