feat: Refine filters and dropdown UI for task table

Update task table filters and dropdowns, including due-date options, layout tweaks, and style polish.
Keep panel style refinement intact so layout stays stable.
This commit is contained in:
Ignacio Perez
2025-12-27 16:00:38 -03:00
parent 45d1036fa7
commit 29a23017f2
8 changed files with 566 additions and 56 deletions
+313 -18
View File
@@ -1,10 +1,16 @@
use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::sync::Arc;
use gpui::prelude::*;
use crate::{
components,
models::FilterState,
components::{
self,
button::{Dropdown, DropdownItem},
input::Input,
},
models::{DueFilter, FilterState, PriorityFilter, StatusFilter},
task::{self, TaskFilter, TaskService},
theme::{self, ActiveTheme},
};
@@ -216,14 +222,93 @@ pub struct TaskTable {
selected_page_idx: Option<usize>,
selected_global_idx: Option<usize>,
need_reload: bool,
filter_bar_height: gpui::Pixels,
search_input: gpui::Entity<Input>,
status_dropdown: gpui::Entity<Dropdown>,
priority_dropdown: gpui::Entity<Dropdown>,
due_dropdown: gpui::Entity<Dropdown>,
}
impl TaskTable {
pub fn new(
id: impl Into<gpui::ElementId>,
filter_state: gpui::Entity<FilterState>,
_cx: &mut gpui::Context<Self>,
cx: &mut gpui::Context<Self>,
) -> Self {
let search_input = {
let filter_state = filter_state.clone();
cx.new(|cx| {
Input::new("filter-search", cx, "Search...").with_on_change(Arc::new(
move |value: &str, cx: &mut gpui::Context<Input>| {
cx.update_entity(&filter_state, |filter, cx| {
filter.search_text = value.to_string();
cx.notify();
});
},
))
})
};
let status_items = StatusFilter::all_variants()
.iter()
.map(|status| DropdownItem::new(status.as_str()))
.collect::<Vec<_>>();
let status_dropdown = {
let filter_state = filter_state.clone();
cx.new(|_cx| {
Dropdown::new("filter-status")
.items(status_items)
.label_prefix("Status")
.selected_index(StatusFilter::default().to_index())
.on_select(Arc::new(move |index, _item, cx| {
let selected = StatusFilter::from_index(index);
cx.update_entity(&filter_state, |filter, cx| {
filter.status_filter = selected;
cx.notify();
});
}))
})
};
let priority_items = PriorityFilter::all_variants()
.iter()
.map(|priority| DropdownItem::new(priority.as_str()))
.collect::<Vec<_>>();
let priority_dropdown = {
let filter_state = filter_state.clone();
cx.new(|_cx| {
Dropdown::new("filter-priority")
.items(priority_items)
.label_prefix("Priority")
.selected_index(PriorityFilter::default().to_index())
.on_select(Arc::new(move |index, _item, cx| {
let selected = PriorityFilter::from_index(index);
cx.update_entity(&filter_state, |filter, cx| {
filter.priority_filter = selected;
cx.notify();
});
}))
})
};
let due_dropdown = {
let filter_state = filter_state.clone();
cx.new(|_cx| {
Dropdown::new("filter-due")
.items(vec![DropdownItem::with_value("All", "all")])
.label_prefix("Due")
.selected_index(0)
.on_select(Arc::new(move |_index, item, cx| {
let selected =
DueFilter::from_value(item.value.as_ref()).unwrap_or(DueFilter::All);
cx.update_entity(&filter_state, |filter, cx| {
filter.due_filter = selected;
cx.notify();
});
}))
})
};
Self {
id: id.into(),
filter_state,
@@ -234,6 +319,11 @@ impl TaskTable {
selected_page_idx: None,
selected_global_idx: None,
need_reload: true,
filter_bar_height: gpui::px(52.0),
search_input,
status_dropdown,
priority_dropdown,
due_dropdown,
}
}
@@ -297,14 +387,17 @@ impl TaskTable {
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 all_tasks = task_service.get_all_tasks().unwrap_or_else(|e| {
log::error!("[TaskTable] Failed to load tasks: {}", e);
vec![]
});
let filtered_tasks = task_service
.get_filtered_tasks(&task_filter)
.unwrap_or_else(|e| {
log::error!("[TaskTable] Failed to load filtered tasks: {}", e);
vec![]
});
let task_filter = TaskFilter::from(&filter_state);
let mut due_filter = task_filter.clone();
due_filter.due_filter = None;
let filtered_tasks = task_filter.apply(&all_tasks);
let due_tasks = due_filter.apply(&all_tasks);
self.cached_tasks = filtered_tasks;
self.apply_sort();
@@ -314,6 +407,7 @@ impl TaskTable {
self.selected_page_idx = None;
self.recalculate_rows();
self.sync_filter_dropdowns(&due_tasks, &filter_state, cx);
self.need_reload = false;
@@ -324,6 +418,121 @@ impl TaskTable {
self.cached_rows = self.cached_tasks.iter().map(TaskRow::from).collect();
}
fn sync_filter_dropdowns(
&mut self,
due_tasks: &[task::Task],
filter_state: &FilterState,
cx: &mut gpui::Context<Self>,
) {
let status_index = filter_state.status_filter.to_index();
self.status_dropdown.update(cx, |dropdown, cx| {
dropdown.set_selected_index(status_index, cx);
});
let priority_index = filter_state.priority_filter.to_index();
self.priority_dropdown.update(cx, |dropdown, cx| {
dropdown.set_selected_index(priority_index, cx);
});
let mut due_items = Self::build_due_items(due_tasks);
let selected_key = filter_state.due_filter.value_key();
let mut selected_index = due_items
.iter()
.position(|item| item.value.as_ref() == selected_key);
if selected_index.is_none() && filter_state.due_filter != DueFilter::All {
if let Some(item) = Self::due_item_from_filter(&filter_state.due_filter) {
due_items.push(item);
selected_index = Some(due_items.len() - 1);
}
}
self.due_dropdown.update(cx, |dropdown, cx| {
dropdown.set_items(due_items, cx);
if let Some(index) = selected_index {
dropdown.set_selected_index(index, cx);
}
});
}
fn build_due_items(tasks: &[task::Task]) -> Vec<DropdownItem> {
let now = chrono::Utc::now();
let today = now.date_naive();
let week_end = now + chrono::Duration::days(7);
let mut dates = BTreeSet::new();
let mut has_no_date = false;
let mut has_overdue = false;
let mut has_today = false;
let mut has_this_week = false;
for task in tasks {
match task.due {
None => {
has_no_date = true;
}
Some(due) => {
let date = due.date_naive();
dates.insert(date);
if due < now {
has_overdue = true;
}
if date == today {
has_today = true;
}
if due >= now && due <= week_end {
has_this_week = true;
}
}
}
}
let mut items = vec![DropdownItem::with_value("All", "all")];
if has_no_date {
items.push(DropdownItem::with_value("No Date", "none"));
}
if has_overdue {
items.push(DropdownItem::with_value("Overdue", "overdue"));
}
if has_today {
items.push(DropdownItem::with_value("Today", "today"));
}
if has_this_week {
items.push(DropdownItem::with_value("This Week", "this_week"));
}
for date in dates {
if date == today {
continue;
}
let label = Self::format_due_label(date);
let value = format!("date:{}", date.format("%Y-%m-%d"));
items.push(DropdownItem::with_value(label, value));
}
items
}
fn due_item_from_filter(filter: &DueFilter) -> Option<DropdownItem> {
match filter {
DueFilter::All => None,
DueFilter::OnDate(date) => Some(DropdownItem::with_value(
Self::format_due_label(*date),
filter.value_key(),
)),
_ => Some(DropdownItem::with_value(filter.label(), filter.value_key())),
}
}
fn format_due_label(date: chrono::NaiveDate) -> String {
let today = chrono::Utc::now().date_naive();
if date == today {
"Today".to_string()
} else {
date.format("%d-%m-%Y").to_string()
}
}
fn priority_color(&self, row: &TaskRow, cx: &gpui::Context<Self>) -> theme::Color {
let theme = cx.theme();
@@ -376,6 +585,74 @@ impl TaskTable {
cx.notify();
}
fn handle_clear_filters(&mut self, cx: &mut gpui::Context<Self>) {
self.search_input.update(cx, |input, cx| {
input.clear(cx);
});
self.filter_state.update(cx, |filter, cx| {
filter.clear();
cx.notify();
});
}
fn render_filter_bar(&self, cx: &gpui::Context<Self>) -> impl gpui::IntoElement {
let theme = cx.theme();
let filter = self.filter_state.read(cx);
let has_filters = filter.has_active_filters();
let view = cx.entity().clone();
let bar = gpui::div()
.id("filter-bar")
.flex()
.flex_wrap()
.gap_2()
.items_center()
.px_4()
.py_2()
.bg(theme.panel)
.child(
gpui::div()
.w(gpui::rems(14.0))
.child(self.search_input.clone()),
)
.child(self.status_dropdown.clone())
.child(self.priority_dropdown.clone())
.child(self.due_dropdown.clone())
.when(has_filters, |this| {
this.child(
gpui::div()
.id("clear-all-filters")
.px_2()
.py_1()
.rounded_md()
.text_sm()
.text_color(theme.error)
.cursor_pointer()
.hover(|s| s.bg(theme.selection))
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|table, _, _, cx| {
table.handle_clear_filters(cx);
}),
)
.child("✕ Clear"),
)
});
gpui::div().child(bar).on_children_prepainted(move |bounds, _, cx| {
let Some(bounds) = bounds.first() else {
return;
};
let height = bounds.size.height;
cx.update_entity(&view, |table, cx| {
if table.filter_bar_height != height {
table.filter_bar_height = height;
cx.notify();
}
});
})
}
fn render_header_column(
&self,
column: SortColumn,
@@ -424,9 +701,10 @@ impl TaskTable {
.items_center()
.gap_2()
.px_4()
.py_1()
.border_b_1()
.py_2()
.border_t_1()
.border_color(theme.border)
.border_b_1()
.bg(theme.panel)
.text_sm()
.font_weight(gpui::FontWeight::MEDIUM)
@@ -632,12 +910,12 @@ impl TaskTable {
impl gpui::Render for TaskTable {
fn render(
&mut self,
window: &mut gpui::Window,
_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());
let panel = components::panel::Panel::new(self.id.clone()).flex().flex_col();
if self.need_reload {
return panel
@@ -656,13 +934,19 @@ impl gpui::Render for TaskTable {
.map(|(index, row)| self.render_row(index, row, cx))
.collect();
panel
let filter_bar = self.render_filter_bar(cx);
let header = self.render_header(cx);
let footer = self.render_footer(cx);
let body = gpui::div()
.flex()
.flex_col()
.size_full()
.flex_1()
.min_h_0()
.overflow_hidden()
.bg(theme.background)
.child(self.render_header(cx))
.child(gpui::div().h(self.filter_bar_height))
.child(header)
.child(
gpui::div()
.id("task-table-content")
@@ -671,6 +955,17 @@ impl gpui::Render for TaskTable {
.overflow_y_scroll()
.child(gpui::div().flex().flex_col().children(rows)),
)
.child(self.render_footer(cx))
.child(footer);
panel
.child(body)
.child(
gpui::div()
.absolute()
.top_0()
.left_0()
.right_0()
.child(filter_bar),
)
}
}