feat: list sorting (#42)

This commit is contained in:
Matouš Volf 2024-09-09 19:09:21 +02:00 committed by GitHub
commit fa68b05738
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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, _, true) => "h-[130px]",
(true, Route::ProjectsPage, _) => "h-[130px]",
(true, _, _) => "h-[448px]",
(true, _, _) => "h-[506px]",
}
),
if expanded() {

View File

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

View File

@ -6,11 +6,12 @@ use crate::query::QueryValue;
use chrono::{Local, Locale};
use dioxus::prelude::*;
use dioxus_query::prelude::QueryResult;
use crate::components::task_list_item::TaskListItem;
#[component]
pub(crate) fn CategoryTodayPage() -> Element {
let today_date = Local::now().date_naive();
let calendar_tasks_query = use_tasks_with_subtasks_in_category_query(Category::Calendar {
date: today_date,
reoccurrence: None,
@ -26,48 +27,36 @@ pub(crate) fn CategoryTodayPage() -> Element {
class: "pt-4 flex flex-col gap-8",
match long_term_tasks_query_result.value() {
QueryResult::Ok(QueryValue::TasksWithSubtasks(tasks))
| QueryResult::Loading(Some(QueryValue::TasksWithSubtasks(tasks))) => rsx! {
div {
class: "flex flex-col gap-4",
| QueryResult::Loading(Some(QueryValue::TasksWithSubtasks(tasks))) => {
let mut tasks = tasks.clone();
tasks.sort();
rsx! {
div {
class: "px-8 flex flex-row items-center gap-2 font-bold",
i {
class: "fa-solid fa-water text-xl w-6 text-center"
class: "flex flex-col gap-4",
div {
class: "px-8 flex flex-row items-center gap-2 font-bold",
i {
class: "fa-solid fa-water text-xl w-6 text-center"
}
div {
class: "mt-1",
"Long-term"
}
}
div {
class: "mt-1",
"Long-term"
}
}
div {
for task in tasks {
div {
key: "{task.task().id()}",
class: format!(
"px-8 pt-5 {} flex flex-row gap-4",
if task.task().deadline().is_some() {
"pb-0.5"
} else {
"pb-5"
}
),
for task in tasks {
div {
class: "flex flex-col",
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()}
}
key: "{task.task().id()}",
class: format!(
"px-8 pt-5 {} flex flex-row gap-4",
if task.task().deadline().is_some() {
"pb-0.5"
} else {
"pb-5"
}
),
TaskListItem {
task: task.clone()
}
}
}

View File

@ -12,20 +12,24 @@ pub(crate) fn ProjectsPage() -> Element {
rsx! {
match projects_query.result().value() {
QueryResult::Ok(QueryValue::Projects(projects))
| QueryResult::Loading(Some(QueryValue::Projects(projects))) => rsx! {
div {
class: "flex flex-col",
for project in projects.clone() {
div {
key: "{project.id()}",
class: format!(
"px-8 py-4 select-none {}",
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())),
{project.title()}
| QueryResult::Loading(Some(QueryValue::Projects(projects))) => {
let mut projects = projects.clone();
projects.sort();
rsx! {
div {
class: "flex flex-col",
for project in projects {
div {
key: "{project.id()}",
class: format!(
"px-8 py-4 select-none {}",
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())),
{project.title()}
}
}
}
}

View File

@ -64,8 +64,10 @@ pub(crate) fn SubtasksForm(task: Task) -> Element {
match subtasks_query.result().value() {
QueryResult::Ok(QueryValue::Subtasks(subtasks))
| QueryResult::Loading(Some(QueryValue::Subtasks(subtasks))) => {
let mut subtasks = subtasks.clone();
subtasks.sort();
rsx! {
for subtask in subtasks.clone() {
for subtask in subtasks {
div {
key: "{subtask.id()}",
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::prelude::*;
use dioxus_query::prelude::use_query_client;
use crate::components::task_list_item::TaskListItem;
use crate::query::{QueryErrors, QueryKey, QueryValue};
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 mut task_being_edited = use_context::<Signal<Option<Task>>>();
tasks.sort();
rsx! {
div {
class: format!("flex flex-col {}", class.unwrap_or("")),
@ -74,50 +77,8 @@ pub(crate) fn TaskList(tasks: Vec<TaskWithSubtasks>, class: Option<&'static str>
}
}
},
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(" %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()
)}
}
}
}
TaskListItem {
task: task.clone()
}
}
}

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.
impl Display for SubtaskError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {

View File

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

View File

@ -1,3 +1,4 @@
use std::cmp::Ordering;
use chrono::NaiveDateTime;
use crate::schema::projects;
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)]
#[diesel(table_name = projects)]
pub struct NewProject {

View File

@ -1,3 +1,4 @@
use std::cmp::Ordering;
use crate::models::task::Task;
use crate::schema::subtasks;
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)]
#[diesel(table_name = subtasks)]
pub struct NewSubtask {

View File

@ -1,10 +1,12 @@
use chrono::NaiveDateTime;
use crate::models::category::Category;
use crate::models::subtask::Subtask;
use crate::schema::tasks;
use crate::utils::reverse_ord_option::ReverseOrdOption;
use chrono::NaiveDateTime;
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use validator::Validate;
use crate::models::subtask::Subtask;
const TITLE_LENGTH_MIN: u64 = 1;
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)]
pub struct TaskWithSubtasks {
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)]
#[diesel(table_name = tasks)]
pub struct NewTask {

View File

@ -6,6 +6,7 @@ use crate::server::database_connection::establish_database_connection;
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper};
use dioxus::prelude::*;
use validator::Validate;
use crate::server::tasks::trigger_task_updated_at;
#[server]
pub(crate) async fn create_subtask(new_subtask: NewSubtask)
@ -25,6 +26,9 @@ pub(crate) async fn create_subtask(new_subtask: NewSubtask)
.returning(Subtask::as_returning())
.get_result(&mut connection)
.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)
}
@ -35,17 +39,13 @@ pub(crate) async fn get_subtasks_of_task(filtered_task_id: i32)
use crate::schema::subtasks::dsl::*;
let mut connection = establish_database_connection()
.map_err::<ErrorVec<Error>, _>(
|_| vec![Error::ServerInternal].into()
)?;
.map_err::<ErrorVec<Error>, _>(|_| vec![Error::ServerInternal].into())?;
let results = subtasks
.select(Subtask::as_select())
.filter(task_id.eq(filtered_task_id))
.load::<Subtask>(&mut connection)
.map_err::<ErrorVec<Error>, _>(
|_| vec![Error::ServerInternal].into()
)?;
.map_err::<ErrorVec<Error>, _>(|_| vec![Error::ServerInternal].into())?;
Ok(results)
}
@ -73,27 +73,28 @@ pub(crate) async fn edit_subtask(subtask_id: i32, new_subtask: NewSubtask)
.get_result(&mut connection)
.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)
}
#[server]
pub(crate) async fn restore_subtasks_of_task(filtered_task_id: i32) -> Result<
Vec<Subtask>,
ServerFnError<ErrorVec<SubtaskError>>
ServerFnError<ErrorVec<Error>>
> {
use crate::schema::subtasks::dsl::*;
let mut connection = establish_database_connection()
.map_err::<ErrorVec<SubtaskError>, _>(
|_| vec![SubtaskError::Error(Error::ServerInternal)].into()
)?;
.map_err::<ErrorVec<Error>, _>(|_| vec![Error::ServerInternal].into())?;
let updated_subtasks = diesel::update(subtasks)
.filter(task_id.eq(filtered_task_id))
.set(is_completed.eq(false))
.returning(Subtask::as_returning())
.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)
}
@ -108,8 +109,12 @@ pub(crate) async fn delete_subtask(subtask_id: i32)
let mut connection = establish_database_connection()
.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())?;
trigger_task_updated_at(deleted_subtask.task_id()).await?;
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_vec::ErrorVec;
use crate::models::task::{NewTask, Task, TaskWithSubtasks};
@ -80,7 +80,7 @@ pub(crate) async fn get_tasks_with_subtasks_in_category(filtered_category: Categ
ServerFnError<ErrorVec<Error>>
> {
use crate::schema::tasks;
let mut connection = establish_database_connection()
.map_err::<ErrorVec<Error>, _>(|_| vec![Error::ServerInternal].into())?;
@ -160,8 +160,7 @@ pub(crate) async fn complete_task(task_id: i32) -> Result<Task, ServerFnError<Er
).unwrap()
}
}
restore_subtasks_of_task(task_id).await
.map_err::<ErrorVec<Error>, _>(|_| vec![Error::ServerInternal].into())?;
restore_subtasks_of_task(task_id).await?;
} else {
new_task.category = Category::Done;
}
@ -187,3 +186,21 @@ pub(crate) async fn delete_task(task_id: i32)
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)
}
}