feat: list sorting (#42)

This commit is contained in:
Matouš Volf 2024-09-09 19:09:21 +02:00 committed by GitHub
commit f037264ecd
16 changed files with 277 additions and 116 deletions

View File

@ -41,7 +41,7 @@ pub(crate) fn BottomPanel(display_form: Signal<bool>) -> Element {
(false, _, false) => "h-[66px]", (false, _, false) => "h-[66px]",
(false, _, true) => "h-[130px]", (false, _, true) => "h-[130px]",
(true, Route::ProjectsPage, _) => "h-[130px]", (true, Route::ProjectsPage, _) => "h-[130px]",
(true, _, _) => "h-[448px]", (true, _, _) => "h-[506px]",
} }
), ),
if expanded() { if expanded() {

View File

@ -13,3 +13,4 @@ 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; pub(crate) mod subtasks_form;
pub(crate) mod task_list_item;

View File

@ -6,6 +6,7 @@ use crate::query::QueryValue;
use chrono::{Local, Locale}; use chrono::{Local, Locale};
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_query::prelude::QueryResult; use dioxus_query::prelude::QueryResult;
use crate::components::task_list_item::TaskListItem;
#[component] #[component]
pub(crate) fn CategoryTodayPage() -> Element { pub(crate) fn CategoryTodayPage() -> Element {
@ -26,7 +27,10 @@ pub(crate) fn CategoryTodayPage() -> Element {
class: "pt-4 flex flex-col gap-8", class: "pt-4 flex flex-col gap-8",
match long_term_tasks_query_result.value() { match long_term_tasks_query_result.value() {
QueryResult::Ok(QueryValue::TasksWithSubtasks(tasks)) QueryResult::Ok(QueryValue::TasksWithSubtasks(tasks))
| QueryResult::Loading(Some(QueryValue::TasksWithSubtasks(tasks))) => rsx! { | QueryResult::Loading(Some(QueryValue::TasksWithSubtasks(tasks))) => {
let mut tasks = tasks.clone();
tasks.sort();
rsx! {
div { div {
class: "flex flex-col gap-4", class: "flex flex-col gap-4",
div { div {
@ -51,23 +55,8 @@ pub(crate) fn CategoryTodayPage() -> Element {
"pb-5" "pb-5"
} }
), ),
div { TaskListItem {
class: "flex flex-col", task: task.clone()
div {
class: "mt grow font-medium",
{task.task().title()}
},
div {
class: "flex flex-row gap-3",
if let Some(deadline) = task.task().deadline() {
div {
class: "text-sm text-zinc-400",
i {
class: "fa-solid fa-bomb"
},
{deadline.format(" %m. %d.").to_string()}
}
}
} }
} }
} }

View File

@ -12,10 +12,13 @@ pub(crate) fn ProjectsPage() -> Element {
rsx! { rsx! {
match projects_query.result().value() { match projects_query.result().value() {
QueryResult::Ok(QueryValue::Projects(projects)) QueryResult::Ok(QueryValue::Projects(projects))
| QueryResult::Loading(Some(QueryValue::Projects(projects))) => rsx! { | QueryResult::Loading(Some(QueryValue::Projects(projects))) => {
let mut projects = projects.clone();
projects.sort();
rsx! {
div { div {
class: "flex flex-col", class: "flex flex-col",
for project in projects.clone() { for project in projects {
div { div {
key: "{project.id()}", key: "{project.id()}",
class: format!( class: format!(
@ -29,6 +32,7 @@ pub(crate) fn ProjectsPage() -> Element {
} }
} }
} }
}
}, },
QueryResult::Loading(None) => rsx! { QueryResult::Loading(None) => rsx! {
// TODO: Add a loading indicator. // TODO: Add a loading indicator.

View File

@ -64,8 +64,10 @@ pub(crate) fn SubtasksForm(task: Task) -> Element {
match subtasks_query.result().value() { match subtasks_query.result().value() {
QueryResult::Ok(QueryValue::Subtasks(subtasks)) QueryResult::Ok(QueryValue::Subtasks(subtasks))
| QueryResult::Loading(Some(QueryValue::Subtasks(subtasks))) => { | QueryResult::Loading(Some(QueryValue::Subtasks(subtasks))) => {
let mut subtasks = subtasks.clone();
subtasks.sort();
rsx! { rsx! {
for subtask in subtasks.clone() { for subtask in subtasks {
div { div {
key: "{subtask.id()}", key: "{subtask.id()}",
class: "flex flex-row items-center gap-3", class: "flex flex-row items-center gap-3",

View File

@ -4,6 +4,7 @@ use dioxus::core_macro::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::task_list_item::TaskListItem;
use crate::query::{QueryErrors, QueryKey, QueryValue}; use crate::query::{QueryErrors, QueryKey, QueryValue};
use crate::server::tasks::complete_task; use crate::server::tasks::complete_task;
@ -12,6 +13,8 @@ pub(crate) fn TaskList(tasks: Vec<TaskWithSubtasks>, class: Option<&'static str>
let query_client = use_query_client::<QueryValue, QueryErrors, QueryKey>(); let query_client = use_query_client::<QueryValue, QueryErrors, QueryKey>();
let mut task_being_edited = use_context::<Signal<Option<Task>>>(); let mut task_being_edited = use_context::<Signal<Option<Task>>>();
tasks.sort();
rsx! { rsx! {
div { div {
class: format!("flex flex-col {}", class.unwrap_or("")), class: format!("flex flex-col {}", class.unwrap_or("")),
@ -74,50 +77,8 @@ pub(crate) fn TaskList(tasks: Vec<TaskWithSubtasks>, class: Option<&'static str>
} }
} }
}, },
div { TaskListItem {
class: "flex flex-col", task: task.clone()
div {
class: "mt-1 grow font-medium",
{task.task().title()}
},
div {
class: "flex flex-row gap-4",
if let Some(deadline) = task.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.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()}
}
}
}
if !task.subtasks().is_empty() {
div {
class: "text-sm text-zinc-400",
i {
class: "fa-solid fa-list-check"
},
{format!(
" {}/{}",
task.subtasks().iter()
.filter(|subtask| subtask.is_completed())
.count(),
task.subtasks().len()
)}
}
}
}
} }
} }
} }

View File

@ -0,0 +1,61 @@
use chrono::{Datelike, Local};
use crate::models::category::Category;
use crate::models::task::TaskWithSubtasks;
use dioxus::core_macro::rsx;
use dioxus::dioxus_core::Element;
use dioxus::prelude::*;
#[component]
pub(crate) fn TaskListItem(task: TaskWithSubtasks) -> Element {
rsx! {
div {
class: "flex flex-col",
div {
class: "mt-1 grow font-medium",
{task.task().title()}
},
div {
class: "flex flex-row gap-4",
if let Some(deadline) = task.task().deadline() {
div {
class: "text-sm text-zinc-400",
i {
class: "fa-solid fa-bomb"
},
{deadline.format(if deadline.year() == Local::now().year() {
" %m. %-d."
} else {
" %m. %-d. %Y"
}).to_string()}
}
}
if let Category::Calendar { time, .. } = task.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()}
}
}
}
if !task.subtasks().is_empty() {
div {
class: "text-sm text-zinc-400",
i {
class: "fa-solid fa-list-check"
},
{format!(
" {}/{}",
task.subtasks().iter()
.filter(|subtask| subtask.is_completed())
.count(),
task.subtasks().len()
)}
}
}
}
}
}
}

View File

@ -53,6 +53,13 @@ impl From<diesel::result::Error> for SubtaskError {
} }
} }
impl From<ErrorVec<Error>> for ErrorVec<SubtaskError> {
fn from(error_vec: ErrorVec<Error>) -> Self {
Vec::from(error_vec).into_iter()
.map(SubtaskError::Error).collect::<Vec<SubtaskError>>().into()
}
}
// Has to be implemented for Dioxus server functions. // Has to be implemented for Dioxus server functions.
impl Display for SubtaskError { impl Display for SubtaskError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {

View File

@ -5,6 +5,7 @@ mod route;
mod schema; mod schema;
mod server; mod server;
mod query; mod query;
mod utils;
use components::app::App; use components::app::App;
use dioxus::prelude::*; use dioxus::prelude::*;

View File

@ -1,3 +1,4 @@
use std::cmp::Ordering;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use crate::schema::projects; use crate::schema::projects;
use diesel::prelude::*; use diesel::prelude::*;
@ -35,6 +36,20 @@ impl Project {
} }
} }
impl Eq for Project {}
impl PartialOrd<Self> for Project {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Project {
fn cmp(&self, other: &Self) -> Ordering {
self.title().cmp(other.title())
}
}
#[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)] #[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)]
#[diesel(table_name = projects)] #[diesel(table_name = projects)]
pub struct NewProject { pub struct NewProject {

View File

@ -1,3 +1,4 @@
use std::cmp::Ordering;
use crate::models::task::Task; use crate::models::task::Task;
use crate::schema::subtasks; use crate::schema::subtasks;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
@ -48,6 +49,21 @@ impl Subtask {
} }
} }
impl Eq for Subtask {}
impl PartialOrd<Self> for Subtask {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Subtask {
fn cmp(&self, other: &Self) -> Ordering {
self.is_completed().cmp(&other.is_completed())
.then(self.created_at().cmp(&other.created_at()))
}
}
#[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)] #[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)]
#[diesel(table_name = subtasks)] #[diesel(table_name = subtasks)]
pub struct NewSubtask { pub struct NewSubtask {

View File

@ -1,10 +1,12 @@
use chrono::NaiveDateTime;
use crate::models::category::Category; use crate::models::category::Category;
use crate::models::subtask::Subtask;
use crate::schema::tasks; use crate::schema::tasks;
use crate::utils::reverse_ord_option::ReverseOrdOption;
use chrono::NaiveDateTime;
use diesel::prelude::*; use diesel::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use validator::Validate; use validator::Validate;
use crate::models::subtask::Subtask;
const TITLE_LENGTH_MIN: u64 = 1; const TITLE_LENGTH_MIN: u64 = 1;
const TITLE_LENGTH_MAX: u64 = 255; const TITLE_LENGTH_MAX: u64 = 255;
@ -52,6 +54,40 @@ impl Task {
} }
} }
impl Eq for Task {}
impl PartialOrd<Self> for Task {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Task {
fn cmp(&self, other: &Self) -> Ordering {
match (&self.category, &other.category) {
(Category::Inbox, Category::Inbox) => self.created_at.cmp(&other.created_at),
(
Category::Calendar { date: self_date, time: self_time, .. },
Category::Calendar { date: other_date, time: other_time, .. }
) => self_date.cmp(other_date)
.then(ReverseOrdOption::from(
&self_time.as_ref().map(|calendar_time| calendar_time.time())
).cmp(&ReverseOrdOption::from(
&other_time.as_ref().map(|calendar_time| calendar_time.time())
)))
.then(ReverseOrdOption::from(&self.deadline()).cmp(
&ReverseOrdOption::from(&other.deadline())
))
.then(self.created_at.cmp(&other.created_at)),
(Category::Done, Category::Done) | (Category::Trash, Category::Trash)
=> self.updated_at.cmp(&other.updated_at).reverse(),
(_, _) => ReverseOrdOption::from(&self.deadline()).cmp(
&ReverseOrdOption::from(&other.deadline())
).then(self.created_at.cmp(&other.created_at)),
}
}
}
#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] #[derive(Serialize, Deserialize, PartialEq, Clone, Debug)]
pub struct TaskWithSubtasks { pub struct TaskWithSubtasks {
task: Task, task: Task,
@ -72,6 +108,20 @@ impl TaskWithSubtasks {
} }
} }
impl Eq for TaskWithSubtasks {}
impl PartialOrd<Self> for TaskWithSubtasks {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TaskWithSubtasks {
fn cmp(&self, other: &Self) -> Ordering {
self.task().cmp(other.task())
}
}
#[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)] #[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)]
#[diesel(table_name = tasks)] #[diesel(table_name = tasks)]
pub struct NewTask { pub struct NewTask {

View File

@ -6,6 +6,7 @@ use crate::server::database_connection::establish_database_connection;
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper};
use dioxus::prelude::*; use dioxus::prelude::*;
use validator::Validate; use validator::Validate;
use crate::server::tasks::trigger_task_updated_at;
#[server] #[server]
pub(crate) async fn create_subtask(new_subtask: NewSubtask) pub(crate) async fn create_subtask(new_subtask: NewSubtask)
@ -26,6 +27,9 @@ pub(crate) async fn create_subtask(new_subtask: NewSubtask)
.get_result(&mut connection) .get_result(&mut connection)
.map_err::<ErrorVec<SubtaskError>, _>(|error| vec![error.into()].into())?; .map_err::<ErrorVec<SubtaskError>, _>(|error| vec![error.into()].into())?;
trigger_task_updated_at(new_subtask.task_id).await
.map_err::<ErrorVec<SubtaskError>, _>(|error_vec| error_vec.into())?;
Ok(created_subtask) Ok(created_subtask)
} }
@ -35,17 +39,13 @@ pub(crate) async fn get_subtasks_of_task(filtered_task_id: i32)
use crate::schema::subtasks::dsl::*; use crate::schema::subtasks::dsl::*;
let mut connection = establish_database_connection() let mut connection = establish_database_connection()
.map_err::<ErrorVec<Error>, _>( .map_err::<ErrorVec<Error>, _>(|_| vec![Error::ServerInternal].into())?;
|_| vec![Error::ServerInternal].into()
)?;
let results = subtasks let results = subtasks
.select(Subtask::as_select()) .select(Subtask::as_select())
.filter(task_id.eq(filtered_task_id)) .filter(task_id.eq(filtered_task_id))
.load::<Subtask>(&mut connection) .load::<Subtask>(&mut connection)
.map_err::<ErrorVec<Error>, _>( .map_err::<ErrorVec<Error>, _>(|_| vec![Error::ServerInternal].into())?;
|_| vec![Error::ServerInternal].into()
)?;
Ok(results) Ok(results)
} }
@ -73,27 +73,28 @@ pub(crate) async fn edit_subtask(subtask_id: i32, new_subtask: NewSubtask)
.get_result(&mut connection) .get_result(&mut connection)
.map_err::<ErrorVec<SubtaskError>, _>(|error| vec![error.into()].into())?; .map_err::<ErrorVec<SubtaskError>, _>(|error| vec![error.into()].into())?;
trigger_task_updated_at(new_subtask.task_id).await
.map_err::<ErrorVec<SubtaskError>, _>(|error_vec| error_vec.into())?;
Ok(updated_task) Ok(updated_task)
} }
#[server] #[server]
pub(crate) async fn restore_subtasks_of_task(filtered_task_id: i32) -> Result< pub(crate) async fn restore_subtasks_of_task(filtered_task_id: i32) -> Result<
Vec<Subtask>, Vec<Subtask>,
ServerFnError<ErrorVec<SubtaskError>> ServerFnError<ErrorVec<Error>>
> { > {
use crate::schema::subtasks::dsl::*; use crate::schema::subtasks::dsl::*;
let mut connection = establish_database_connection() let mut connection = establish_database_connection()
.map_err::<ErrorVec<SubtaskError>, _>( .map_err::<ErrorVec<Error>, _>(|_| vec![Error::ServerInternal].into())?;
|_| vec![SubtaskError::Error(Error::ServerInternal)].into()
)?;
let updated_subtasks = diesel::update(subtasks) let updated_subtasks = diesel::update(subtasks)
.filter(task_id.eq(filtered_task_id)) .filter(task_id.eq(filtered_task_id))
.set(is_completed.eq(false)) .set(is_completed.eq(false))
.returning(Subtask::as_returning()) .returning(Subtask::as_returning())
.get_results(&mut connection) .get_results(&mut connection)
.map_err::<ErrorVec<SubtaskError>, _>(|error| vec![error.into()].into())?; .map_err::<ErrorVec<Error>, _>(|error| vec![error.into()].into())?;
Ok(updated_subtasks) Ok(updated_subtasks)
} }
@ -108,8 +109,12 @@ pub(crate) async fn delete_subtask(subtask_id: i32)
let mut connection = establish_database_connection() let mut connection = establish_database_connection()
.map_err::<ErrorVec<Error>, _>(|_| vec![Error::ServerInternal].into())?; .map_err::<ErrorVec<Error>, _>(|_| vec![Error::ServerInternal].into())?;
diesel::delete(subtasks.filter(id.eq(subtask_id))).execute(&mut connection) let deleted_subtask = diesel::delete(subtasks.filter(id.eq(subtask_id)))
.returning(Subtask::as_returning())
.get_result(&mut connection)
.map_err::<ErrorVec<Error>, _>(|error| vec![error.into()].into())?; .map_err::<ErrorVec<Error>, _>(|error| vec![error.into()].into())?;
trigger_task_updated_at(deleted_subtask.task_id()).await?;
Ok(()) Ok(())
} }

View File

@ -1,4 +1,4 @@
use chrono::{Datelike, Days, Months, NaiveDate}; use chrono::{Datelike, Days, Local, Months, NaiveDate};
use crate::errors::error::Error; use crate::errors::error::Error;
use crate::errors::error_vec::ErrorVec; use crate::errors::error_vec::ErrorVec;
use crate::models::task::{NewTask, Task, TaskWithSubtasks}; use crate::models::task::{NewTask, Task, TaskWithSubtasks};
@ -160,8 +160,7 @@ pub(crate) async fn complete_task(task_id: i32) -> Result<Task, ServerFnError<Er
).unwrap() ).unwrap()
} }
} }
restore_subtasks_of_task(task_id).await restore_subtasks_of_task(task_id).await?;
.map_err::<ErrorVec<Error>, _>(|_| vec![Error::ServerInternal].into())?;
} else { } else {
new_task.category = Category::Done; new_task.category = Category::Done;
} }
@ -187,3 +186,21 @@ pub(crate) async fn delete_task(task_id: i32)
Ok(()) Ok(())
} }
pub(crate) async fn trigger_task_updated_at(task_id: i32) -> Result<Task, ErrorVec<Error>> {
use crate::schema::tasks::dsl::*;
let mut connection = establish_database_connection()
.map_err::<ErrorVec<Error>, _>(
|_| vec![Error::ServerInternal].into()
)?;
let updated_task = diesel::update(tasks)
.filter(id.eq(task_id))
.set(updated_at.eq(Local::now().naive_local()))
.returning(Task::as_returning())
.get_result(&mut connection)
.map_err::<ErrorVec<Error>, _>(|error| vec![error.into()].into())?;
Ok(updated_task)
}

1
src/utils/mod.rs Normal file
View File

@ -0,0 +1 @@
pub(crate) mod reverse_ord_option;

View File

@ -0,0 +1,31 @@
use std::cmp::Ordering;
/* The default ordering of `Option`s is `None` being less than `Some`. The purpose of this struct is
to reverse that. */
#[derive(PartialEq)]
pub(crate) struct ReverseOrdOption<'a, T>(&'a Option<T>);
impl<'a, T: Ord> Eq for ReverseOrdOption<'a, T> {}
impl<'a, T: Ord> PartialOrd<Self> for ReverseOrdOption<'a, T> {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl<'a, T: Ord> Ord for ReverseOrdOption<'a, T> {
fn cmp(&self, other: &Self) -> Ordering {
match (self.0.as_ref(), other.0.as_ref()) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(self_time), Some(other_time)) => self_time.cmp(other_time)
}
}
}
impl<'a, T> From<&'a Option<T>> for ReverseOrdOption<'a, T> {
fn from(value: &'a Option<T>) -> Self {
ReverseOrdOption(value)
}
}