feat: ability to manage subtasks #40

Merged
matous-volf merged 6 commits from feat/subtasks into main 2024-09-08 18:38:51 +00:00
4 changed files with 475 additions and 294 deletions
Showing only changes of commit a05b4f9f66 - Show all commits

View File

@ -12,3 +12,4 @@ pub(crate) mod category_input;
pub(crate) mod reoccurrence_input; pub(crate) mod reoccurrence_input;
pub(crate) mod layout; pub(crate) mod layout;
pub(crate) mod navigation_item; pub(crate) mod navigation_item;
pub(crate) mod subtasks_form;

View File

@ -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::<QueryValue, QueryErrors, QueryKey>();
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:?}")
}
}
}

View File

@ -12,6 +12,7 @@ use dioxus::core_macro::{component, rsx};
use dioxus::dioxus_core::Element; use dioxus::dioxus_core::Element;
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_query::prelude::use_query_client; use dioxus_query::prelude::use_query_client;
use crate::components::subtasks_form::SubtasksForm;
const REMINDER_OFFSETS: [Option<Duration>; 17] = [ const REMINDER_OFFSETS: [Option<Duration>; 17] = [
None, None,
@ -79,262 +80,274 @@ pub(crate) fn TaskForm(task: Option<Task>, on_successful_submit: EventHandler<()
let task_for_submit = task.clone(); let task_for_submit = task.clone();
rsx! { rsx! {
form { div {
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::<usize>().unwrap()
]
)
)
},
category => category.clone()
},
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())
]);
on_successful_submit.call(());
}
},
class: "p-4 flex flex-col gap-4", class: "p-4 flex flex-col gap-4",
div { form {
class: "flex flex-row items-center gap-3", class: "flex flex-col gap-4",
label { id: "form_task",
r#for: "input_title", onsubmit: move |event| {
class: "min-w-6 text-center", let task = task_for_submit.clone();
i { async move {
class: "fa-solid fa-pen-clip text-zinc-400/50" let new_task = NewTask::new(
}, event.values().get("title").unwrap().as_value(),
}, event.values().get("deadline").unwrap().as_value().parse().ok(),
input { match &selected_category() {
name: "title", Category::WaitingFor(_) => Category::WaitingFor(
required: true, event.values().get("category_waiting_for").unwrap()
initial_value: task.as_ref().map(|task| task.title().to_owned()), .as_value()
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()
), ),
class: "py-2 px-3 bg-zinc-800/50 rounded-lg grow", Category::Calendar { .. } => Category::Calendar {
id: "input_category_calendar_time", date: event.values().get("category_calendar_date").unwrap()
oninput: move |event| { .as_value().parse().unwrap(),
category_calendar_has_time.set(!event.value().is_empty()); reoccurrence: category_calendar_reoccurrence_interval().map(
} |reoccurrence_interval| Reoccurrence::new(
} event.values().get("category_calendar_date").unwrap()
} .as_value().parse().unwrap(),
}, reoccurrence_interval,
div { event.values()
class: "flex flex-row items-center gap-3", .get("category_calendar_reoccurrence_length")
label { .unwrap().as_value().parse().unwrap()
r#for: "category_calendar_reoccurrence_length", )
class: "min-w-6 text-center", ),
i { time: event.values().get("category_calendar_time").unwrap()
class: "fa-solid fa-repeat text-zinc-400/50" .as_value().parse().ok().map(|time|
} CalendarTime::new(
}, time,
div { REMINDER_OFFSETS[
class: "grow grid grid-cols-6 gap-2", event.values()
ReoccurrenceIntervalInput { .get("category_calendar_reminder_offset_index")
reoccurrence_interval: category_calendar_reoccurrence_interval .unwrap().as_value().parse::<usize>().unwrap()
]
)
)
},
category => category.clone()
}, },
input { event.values().get("project_id").unwrap()
r#type: "number", .as_value().parse::<i32>().ok().filter(|&id| id > 0),
inputmode: "numeric", );
name: "category_calendar_reoccurrence_length", if let Some(task) = task {
disabled: category_calendar_reoccurrence_interval().is_none(), let _ = edit_task(task.id(), new_task).await;
required: true, } else {
min: 1, let _ = create_task(new_task).await;
initial_value: category_calendar_reoccurrence_interval() }
.map_or(String::new(), |_| reoccurrence.map_or(1, |reoccurrence| query_client.invalidate_queries(&[
reoccurrence.length()).to_string()), QueryKey::Tasks,
class: "py-2 px-3 bg-zinc-800/50 rounded-lg col-span-2 text-right", QueryKey::TasksInCategory(selected_category())
id: "category_calendar_reoccurrence_length" ]);
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 { div {
class: "flex flex-row items-center gap-3", class: "flex flex-row items-center gap-3",
label { label {
r#for: "category_calendar_reminder_offset_index", r#for: "input_deadline",
class: "min-w-6 text-center", class: "min-w-6 text-center",
i { i {
class: "fa-solid fa-bell text-zinc-400/50" class: "fa-solid fa-hourglass-end text-zinc-400/50"
} }
}, },
input { input {
r#type: "range", name: "category_waiting_for",
name: "category_calendar_reminder_offset_index", required: true,
min: 0, initial_value: waiting_for,
max: REMINDER_OFFSETS.len() as i64 - 1, r#type: "text",
initial_value: category_calendar_reminder_offset_index() class: "py-2 px-3 bg-zinc-800/50 rounded-lg grow",
.to_string(), id: "input_category_waiting_for"
class: "grow input-range-reverse", },
id: "category_calendar_has_reminder", }
oninput: move |event| { },
category_calendar_reminder_offset_index.set( Category::Calendar { date, reoccurrence, time } => rsx! {
event.value().parse().unwrap() 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 { div {
r#for: "category_calendar_reminder_offset_index", class: "grow flex flex-row gap-2",
class: "pr-3 min-w-16 text-right", input {
{REMINDER_OFFSETS[category_calendar_reminder_offset_index()].map( r#type: "date",
|offset| if offset.num_hours() < 1 { name: "category_calendar_date",
format!("{} min", offset.num_minutes()) required: true,
} else { initial_value: date.format("%Y-%m-%d").to_string(),
format!("{} h", offset.num_hours()) 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 { div {
class: "flex flex-row justify-between mt-auto", class: "flex flex-row justify-between mt-auto",
button { button {
@ -370,6 +383,7 @@ pub(crate) fn TaskForm(task: Option<Task>, on_successful_submit: EventHandler<()
} }
} }
button { button {
form: "form_task",
r#type: "submit", r#type: "submit",
class: "py-2 px-4 bg-zinc-300/50 rounded-lg", class: "py-2 px-4 bg-zinc-300/50 rounded-lg",
i { i {

View File

@ -15,86 +15,94 @@ pub(crate) fn TaskList(tasks: Vec<Task>, class: Option<&'static str>) -> Element
rsx! { rsx! {
div { div {
class: format!("flex flex-col {}", class.unwrap_or("")), class: format!("flex flex-col {}", class.unwrap_or("")),
{tasks.iter().cloned().map(|task| { for task in tasks.clone() {
let task_clone = task.clone(); div {
rsx! { key: "{task.id()}",
div { class: format!(
key: "{task.id()}", "px-8 pt-5 {} flex flex-row gap-4 select-none {}",
class: format!( if task.deadline().is_some() {
"px-8 pt-5 {} flex flex-row gap-4 select-none {}", "pb-0.5"
if task.deadline().is_some() { } else if let Category::Calendar { time, .. } = task.category() {
if time.is_some() {
coderabbitai[bot] commented 2024-09-08 17:59:20 +00:00 (Migrated from github.com)
Review

Consider avoiding cloning the entire tasks vector.

Cloning the entire tasks vector might lead to performance issues, especially if the vector is large. Consider iterating over references if the task objects do not need to be owned within the loop.

**Consider avoiding cloning the entire tasks vector.** Cloning the entire `tasks` vector might lead to performance issues, especially if the vector is large. Consider iterating over references if the task objects do not need to be owned within the loop. <!-- This is an auto-generated comment by CodeRabbit -->
"pb-0.5" "pb-0.5"
} else if let Category::Calendar { time, .. } = task.category() {
if time.is_some() {
"pb-0.5"
} else {
"pb-5"
}
} else { } else {
"pb-5" "pb-5"
}, }
if task_being_edited().is_some_and(|t| t.id() == task.id()) { } else {
"bg-zinc-700" "pb-5"
} else { "" } },
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())), onclick: {
i { let task = task.clone();
class: format!( move |event| {
"{} text-3xl text-zinc-500",
if *(task_clone.category()) == Category::Done {
"fa solid fa-square-check"
} else {
"fa-regular fa-square"
}
),
onclick: move |event| {
// To prevent editing the task. // To prevent editing the task.
event.stop_propagation(); event.stop_propagation();
let task = task_clone.clone(); let task = task.clone();
async move { async move {
let completed_task = complete_task(task.id()).await; let completed_task = complete_task(task.id()).await;
query_client.invalidate_queries(&[ let mut query_keys = vec![
QueryKey::Tasks, QueryKey::Tasks,
QueryKey::TasksInCategory( QueryKey::TasksInCategory(
completed_task.unwrap().category().clone() 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 { div {
class: "flex flex-col", class: "flex flex-row gap-3",
div { if let Some(deadline) = task.deadline() {
class: "mt-1 grow font-medium", div {
{task.title()} class: "text-sm text-zinc-400",
}, i {
div { class: "fa-solid fa-bomb"
class: "flex flex-row gap-3", },
if let Some(deadline) = task.deadline() { {deadline.format(" %m. %d.").to_string()}
}
}
if let Category::Calendar { time, .. } = task.category() {
if let Some(calendar_time) = time {
div { div {
class: "text-sm text-zinc-400", class: "text-sm text-zinc-400",
i { i {
class: "fa-solid fa-bomb" class: "fa-solid fa-clock"
}, },
{deadline.format(" %m. %d.").to_string()} {calendar_time.time().format(" %k:%M").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()}
}
} }
} }
} }
} }
} }
} }
})} }
} }
} }
} }