feat: Add TaskTable with sorting and pagination

Add a new TaskTable component that displays tasks in a sortable, paginated table.
Columns include ID, Description, Project, Due, Priority, and Status.
Clicking column headers sorts the data (toggles between ascending and descending).
Priority and status are color-coded based on theme colors.

Update Panel component to accept an ID and simplify child rendering.
Add warning, info, and priority colors (high/medium/low) to Theme.
Add Into<usize> and Into<String> implementations for TaskPriority and TaskStatus to support sorting and display.
This commit is contained in:
Ignacio Perez
2025-12-27 12:40:49 -03:00
parent 3c10f8fdb8
commit d132e51da2
8 changed files with 774 additions and 43 deletions
+686
View File
@@ -0,0 +1,686 @@
use std::cmp::Ordering;
use gpui::prelude::*;
use crate::{
components,
models::FilterState,
task::{self, TaskFilter, TaskService},
theme::{self, ActiveTheme},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortColumn {
Id,
Description,
Project,
Due,
Priority,
Status,
}
impl SortColumn {
pub fn label(&self) -> &'static str {
match self {
SortColumn::Id => "ID",
SortColumn::Description => "Description",
SortColumn::Project => "Project",
SortColumn::Due => "Due",
SortColumn::Priority => "Priority",
SortColumn::Status => "Status",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
Asc,
Desc,
}
impl SortDirection {
pub fn toggle(&self) -> Self {
match self {
SortDirection::Asc => SortDirection::Desc,
SortDirection::Desc => SortDirection::Asc,
}
}
pub fn arrow(&self) -> &'static str {
match self {
SortDirection::Asc => "",
SortDirection::Desc => "",
}
}
}
impl Default for SortDirection {
fn default() -> Self {
Self::Desc
}
}
#[derive(Debug, Clone, Copy)]
pub struct SortState {
pub column: SortColumn,
pub direction: SortDirection,
}
impl Default for SortState {
fn default() -> Self {
Self {
column: SortColumn::Priority,
direction: SortDirection::Desc,
}
}
}
pub struct PaginationState {
current_page: usize,
page_size: usize,
total_items: usize,
}
impl Default for PaginationState {
fn default() -> Self {
Self {
current_page: 1,
page_size: 20,
total_items: 0,
}
}
}
impl PaginationState {
pub fn new(current_page: usize, page_size: usize, total_items: usize) -> Self {
Self {
current_page,
page_size,
total_items,
}
}
pub fn total_items(&mut self, total_items: usize) {
self.total_items = total_items;
}
pub fn page_size(&mut self, page_size: usize) {
self.page_size = page_size;
}
pub fn current_page(&mut self, current_page: usize) {
self.current_page = current_page;
}
#[inline]
pub fn can_next(&self) -> bool {
self.current_page < self.total_items / self.page_size + 1
}
#[inline]
pub fn can_previous(&self) -> bool {
self.current_page > 1
}
pub fn next_page(&mut self) {
if self.current_page < self.total_items / self.page_size + 1 {
self.current_page += 1;
}
}
pub fn previous_page(&mut self) {
if self.current_page > 1 {
self.current_page -= 1;
}
}
pub fn first_item_index(&self) -> usize {
(self.current_page - 1) * self.page_size
}
pub fn last_item_index(&self) -> usize {
(self.first_item_index() + self.page_size).min(self.total_items)
}
pub fn last_item_display(&self) -> usize {
self.last_item_index()
}
}
pub struct TaskRow {
pub uuid: uuid::Uuid,
pub id_display: String,
pub description: String,
pub project: String,
pub due: String,
pub priority: String,
pub status: String,
pub is_due_today: bool,
pub is_overdue: bool,
pub is_active: bool,
}
impl TaskRow {
fn truncate(desc: &str, max_len: usize) -> String {
if desc.len() <= max_len {
desc.to_string()
} else {
format!("{}...", &desc[..max_len - 3])
}
}
fn format_date(due: &Option<chrono::DateTime<chrono::Utc>>, is_today: bool) -> String {
match due {
None => "-".to_string(),
Some(dt) => {
if is_today {
"Today".to_string()
} else {
dt.format("%d-%m-%Y").to_string()
}
}
}
}
}
impl From<&task::Task> for TaskRow {
fn from(value: &task::Task) -> Self {
let status = if value.is_active {
"Active".to_string()
} else {
value.status.clone().into()
};
Self {
uuid: value.uuid,
id_display: value.working_id.unwrap_or(0).to_string(),
description: Self::truncate(&value.description, 50),
project: value.project.clone().unwrap_or(String::new()),
due: Self::format_date(&value.due, value.is_due_today()),
priority: value.priority.into(),
status,
is_due_today: value.is_due_today(),
is_overdue: value.is_overdue(),
is_active: value.is_active,
}
}
}
pub struct TaskTable {
id: gpui::ElementId,
filter_state: gpui::Entity<FilterState>,
cached_tasks: Vec<task::Task>,
cached_rows: Vec<TaskRow>,
sort_state: SortState,
pagination: PaginationState,
selected_page_idx: Option<usize>,
selected_global_idx: Option<usize>,
need_reload: bool,
}
impl TaskTable {
pub fn new(
id: impl Into<gpui::ElementId>,
filter_state: gpui::Entity<FilterState>,
cx: &mut gpui::Context<Self>,
) -> Self {
cx.observe(&filter_state, |table, _filter, cx| {
table.need_reload = true;
cx.notify();
})
.detach();
Self {
id: id.into(),
filter_state,
cached_tasks: vec![],
cached_rows: vec![],
sort_state: SortState::default(),
pagination: PaginationState::default(),
selected_page_idx: None,
selected_global_idx: None,
need_reload: true,
}
}
pub fn set_sort(&mut self, column: SortColumn, cx: &mut gpui::Context<Self>) {
if self.sort_state.column == column {
self.sort_state.direction = self.sort_state.direction.toggle();
} else {
self.sort_state.column = column;
self.sort_state.direction = SortDirection::Desc;
}
self.apply_sort();
self.recalculate_rows();
cx.notify();
}
fn apply_sort(&mut self) {
let direction = self.sort_state.direction;
let column = self.sort_state.column;
self.cached_tasks.sort_by(|a, b| {
let ordering = match column {
SortColumn::Id => a.working_id.unwrap_or(0).cmp(&b.working_id.unwrap_or(0)),
SortColumn::Description => a.description.cmp(&b.description),
SortColumn::Project => {
let a_proj = a.project.as_deref().unwrap_or("");
let b_proj = b.project.as_deref().unwrap_or("");
a_proj.cmp(b_proj)
}
SortColumn::Due => match (&a.due, &b.due) {
(Some(a_due), Some(b_due)) => a_due.cmp(b_due),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
},
SortColumn::Priority => {
let a_order: usize = a.priority.into();
let b_order: usize = b.priority.into();
a_order.cmp(&b_order)
}
SortColumn::Status => {
let a_status: String = a.status.clone().into();
let b_status: String = b.status.clone().into();
a_status.cmp(&b_status)
}
};
match direction {
SortDirection::Asc => ordering,
SortDirection::Desc => ordering.reverse(),
}
});
}
fn get_current_page_rows(&self) -> &[TaskRow] {
let start = self.pagination.first_item_index();
let end = self.pagination.last_item_index();
&self.cached_rows[start..end]
}
pub fn reload_tasks(&mut self, task_service: &mut TaskService, cx: &mut gpui::Context<Self>) {
let filter_state = self.filter_state.read(cx).clone();
let task_filter = TaskFilter::from(&filter_state);
let filtered_tasks = task_service
.get_filtered_tasks(&task_filter)
.unwrap_or_else(|e| {
log::error!("[TaskTable] Failed to load filtered tasks: {}", e);
vec![]
});
self.cached_tasks = filtered_tasks;
self.apply_sort();
self.pagination.total_items(self.cached_tasks.len());
if let Some(idx) = self.selected_global_idx {
if idx >= self.cached_tasks.len() {
self.selected_global_idx = None;
self.selected_page_idx = None;
}
}
self.recalculate_rows();
self.need_reload = false;
cx.notify();
}
fn recalculate_rows(&mut self) {
self.cached_rows = self.cached_tasks.iter().map(TaskRow::from).collect();
}
fn priority_color(&self, row: &TaskRow, cx: &gpui::Context<Self>) -> theme::Color {
let theme = cx.theme();
match row.priority.as_str() {
"High" => theme.high,
"Medium" => theme.medium,
"Low" => theme.low,
_ => theme.foreground,
}
}
fn due_color(&self, row: &TaskRow, cx: &gpui::Context<Self>) -> theme::Color {
let theme = cx.theme();
if row.is_due_today {
theme.accent
} else if row.is_overdue {
theme.error
} else {
theme.foreground
}
}
fn status_color(&self, row: &TaskRow, cx: &gpui::Context<Self>) -> theme::Color {
let theme = cx.theme();
match row.status.as_str() {
"Active" => theme.success,
"Pending" => theme.warning,
"Completed" => theme.muted,
"Deleted" => theme.error,
"Recurring" => theme.info,
_ => theme.muted,
}
}
pub fn select_row(&mut self, idx: usize, cx: &mut gpui::Context<Self>) {
self.selected_page_idx = Some(idx);
self.selected_global_idx = Some(self.pagination.first_item_index() + idx);
cx.notify();
}
pub fn go_previous_page(&mut self, cx: &mut gpui::Context<Self>) {
self.pagination.previous_page();
cx.notify();
}
pub fn go_next_page(&mut self, cx: &mut gpui::Context<Self>) {
self.pagination.next_page();
cx.notify();
}
fn render_header_column(
&self,
column: SortColumn,
id: &'static str,
cx: &gpui::Context<Self>,
) -> impl gpui::IntoElement {
let theme = cx.theme();
let is_sorted = self.sort_state.column == column;
let arrow = if is_sorted {
self.sort_state.direction.arrow()
} else {
""
};
gpui::div()
.id(id)
.flex()
.items_center()
.gap_1()
.cursor_pointer()
.hover(|s| s.text_color(theme.foreground))
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(move |table, _, _, cx| {
table.set_sort(column, cx);
}),
)
.child(
components::label::Label::new(column.label()).text_color(if is_sorted {
theme.accent
} else {
theme.muted
}),
)
.when(!arrow.is_empty(), |div| {
div.child(components::label::Label::new(arrow).text_color(theme.accent))
})
}
fn render_header(&self, cx: &gpui::Context<Self>) -> gpui::Div {
let theme = cx.theme();
gpui::div()
.flex()
.flex_shrink_0()
.items_center()
.gap_2()
.px_4()
.py_1()
.border_b_1()
.border_color(theme.border)
.bg(theme.panel)
.text_sm()
.font_weight(gpui::FontWeight::MEDIUM)
.child(
gpui::div()
.min_w(gpui::rems(3.0))
.flex()
.items_center()
.gap_1()
.child(components::label::Label::new(" ").text_color(theme.muted))
.child(self.render_header_column(SortColumn::Id, "header-id", cx)),
)
.child(
gpui::div()
.flex_1()
.min_w(gpui::rems(10.0))
.child(self.render_header_column(SortColumn::Description, "header-desc", cx)),
)
.child(
gpui::div()
.w(gpui::rems(10.0))
.child(self.render_header_column(SortColumn::Project, "header-project", cx)),
)
.child(
gpui::div()
.w(gpui::rems(7.0))
.child(self.render_header_column(SortColumn::Due, "header-due", cx)),
)
.child(
gpui::div()
.w(gpui::rems(5.0))
.child(self.render_header_column(SortColumn::Priority, "header-priority", cx)),
)
.child(
gpui::div()
.w(gpui::rems(6.0))
.child(self.render_header_column(SortColumn::Status, "header-status", cx)),
)
}
fn render_row(&self, idx: usize, row: &TaskRow, cx: &gpui::Context<Self>) -> gpui::Div {
let theme = cx.theme();
let selected = self.selected_page_idx == Some(idx);
gpui::div()
.flex()
.items_center()
.gap_2()
.px_4()
.py_1()
.border_b_1()
.border_color(theme.border)
.text_color(theme.foreground)
.when(selected, |d| {
d.bg(theme.selection).text_color(theme.selection_foreground)
})
.when(!selected, |d| d.hover(|s| s.bg(theme.panel)))
.cursor_pointer()
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(move |table, _, _, cx| table.select_row(idx, cx)),
)
.child(
gpui::div()
.min_w(gpui::rems(3.0))
.flex()
.items_center()
.gap_1()
.child(
components::label::Label::new(if selected { ">" } else { " " })
.text_color(theme.accent),
)
.child(components::label::Label::new(row.id_display.clone())),
)
.child(
gpui::div()
.flex_1()
.min_w(gpui::rems(10.0))
.overflow_x_hidden()
.child(
components::label::Label::new(row.description.clone())
.text_ellipsis()
.whitespace_nowrap(),
),
)
.child(
gpui::div().w(gpui::rems(10.0)).overflow_x_hidden().child(
components::label::Label::new(row.project.clone())
.text_color(theme.muted)
.text_ellipsis()
.whitespace_nowrap(),
),
)
.child(gpui::div().w(gpui::rems(7.0)).child(
components::label::Label::new(row.due.clone()).text_color(self.due_color(row, cx)),
))
.child(
gpui::div().w(gpui::rems(5.0)).child(
components::label::Label::new(row.priority.clone())
.text_color(self.priority_color(row, cx))
.font_weight(gpui::FontWeight::BOLD),
),
)
.child(
gpui::div().w(gpui::rems(6.0)).child(
components::label::Label::new(row.status.clone())
.text_color(self.status_color(row, cx)),
),
)
}
fn render_footer(&self, cx: &gpui::Context<Self>) -> gpui::Div {
let theme = cx.theme();
let can_prev = self.pagination.can_previous();
let can_next = self.pagination.can_next();
let pages = (self.pagination.total_items + self.pagination.page_size - 1)
/ self.pagination.page_size.max(1);
gpui::div()
.flex()
.flex_shrink_0()
.justify_between()
.items_center()
.px_4()
.py_2()
.border_t_1()
.border_color(theme.border)
.bg(theme.panel)
.text_sm()
.child(
components::label::Label::new(format!(
"Showing {}-{} of {}",
self.pagination.first_item_index() + 1,
self.pagination.last_item_display(),
self.pagination.total_items
))
.text_color(theme.muted),
)
.child(
gpui::div()
.flex()
.items_center()
.gap_3()
.child(
components::label::Label::new(format!(
"Page {} of {}",
self.pagination.current_page,
pages.max(1)
))
.text_color(theme.muted),
)
.child(
gpui::div()
.flex()
.gap_1()
.child(
gpui::div()
.id("prev-btn")
.px_2()
.py_1()
.rounded_md()
.text_color(if can_prev {
theme.foreground
} else {
theme.muted
})
.when(can_prev, |d| {
d.cursor_pointer().hover(|s| s.bg(theme.selection))
})
.when(!can_prev, |d| d.cursor_not_allowed())
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|table, _, _, cx| table.go_previous_page(cx)),
)
.child(components::label::Label::new("← Prev")),
)
.child(
gpui::div()
.id("next-btn")
.px_2()
.py_1()
.rounded_md()
.text_color(if can_next {
theme.foreground
} else {
theme.muted
})
.when(can_next, |d| {
d.cursor_pointer().hover(|s| s.bg(theme.selection))
})
.when(!can_next, |d| d.cursor_not_allowed())
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|table, _, _, cx| table.go_next_page(cx)),
)
.child(components::label::Label::new("Next →")),
),
),
)
}
}
impl gpui::Render for TaskTable {
fn render(
&mut self,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
let theme = cx.theme();
let panel = components::panel::Panel::new(self.id.clone());
if self.need_reload {
return panel
.flex()
.flex_col()
.size_full()
.items_center()
.justify_center()
.child(components::label::Label::new("Loading...").text_color(theme.text));
}
let current_page = self.get_current_page_rows();
let rows: Vec<gpui::Div> = current_page
.iter()
.enumerate()
.map(|(index, row)| self.render_row(index, row, cx))
.collect();
panel
.flex()
.flex_col()
.size_full()
.overflow_hidden()
.bg(theme.background)
.child(self.render_header(cx))
.child(
gpui::div()
.id("task-table-content")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.child(gpui::div().flex().flex_col().children(rows)),
)
.child(self.render_footer(cx))
}
}