AutoFilter: a selected header row filters the data below it; search box in the filter menu (#201)

Turning the filter on with a multi-cell selection used the selection as the
filter range. With only the header row selected, the filter had no data rows:
the dropdown list was empty and filtering hid nothing. A one-row selection
now extends down over the current region below it. An explicit `range`
parameter is still taken as given.

The filter menu gets a search box, as asked for in the issue. It narrows
the value list; Select All then reads "(Select All Search Results)" and
covers only the matches, and Apply keeps only the checked matches. The box
takes focus when the menu opens, so typing no longer lands in the active
cell.

generated with Claude Code
This commit is contained in:
Alexander Alber
2026-10-11 09:49:46 +02:00
parent 9f896c1249
commit 4e6e0d6c31
3 changed files with 53 additions and 7 deletions
+10 -1
View File
@@ -431,7 +431,16 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
Ok(json!({"filter": false}))
});
}
let r = data_range(s, p)?;
let mut r = data_range(s, p)?;
// A selected header row filters the data below it (#201).
if p.get("range").is_none() && r.height() == 1 {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let below = current_region(sh, r.start).end.row;
if below > r.end.row {
r = RangeRef::new(r.start, CellRef::new(below, r.end.col));
}
}
edit(s, |cx| {
cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] });
Ok(json!({"filter": true, "range": r.a1()}))
+20
View File
@@ -512,6 +512,26 @@ fn sort_and_filter() {
assert_eq!(r["hiddenRows"], 1);
}
/// Turning the filter on with only the header row selected filters the data below it (#201).
#[test]
fn filter_on_a_selected_header_row_covers_the_data_below() {
let mut s = s();
s.execute(
"range.setValues",
json!({"range": "A1", "values": [["Name", "City"], ["Ana", "SP"], ["Bia", "RJ"], ["Caio", "SP"], ["Duda", "BH"], ["Eva", "RJ"]]}),
)
.unwrap();
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
let r = s.execute("data.filter", json!({})).unwrap();
assert_eq!(r["range"], "A1:B6");
let r = s.execute("data.filterBy", json!({"column": "B", "values": ["SP"]})).unwrap();
assert_eq!(r["hiddenRows"], 3);
// An explicit range is taken as given.
s.execute("data.filter", json!({})).unwrap();
let r = s.execute("data.filter", json!({"range": "A1:B1"})).unwrap();
assert_eq!(r["range"], "A1:B1");
}
/// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected.
fn protected(extra: serde_json::Value) -> Session {
let mut s = s();
+23 -6
View File
@@ -1803,6 +1803,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb);
let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col);
let key = egui::Id::new(("filter_sel", col));
let search_key = egui::Id::new(("filter_search", col));
let mut close = false;
let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
let response = area.show(ui.ctx(), |ui| {
@@ -1819,19 +1820,32 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
ui.separator();
ui.label(egui::RichText::new("Filter").strong());
let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::<Vec<(String, bool)>>(key)).unwrap_or(values.clone());
let all = checks.iter().all(|(_, b)| *b);
// A search narrows the list; Select All and Apply then only cover the matches.
let mut search: String = ui.ctx().data_mut(|d| d.get_temp(search_key)).unwrap_or_default();
let field = ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search").desired_width(f32::INFINITY));
if !was_open {
field.request_focus();
}
let needle = search.trim().to_lowercase();
let matches = |v: &str| needle.is_empty() || v.to_lowercase().contains(&needle);
let all = checks.iter().filter(|(v, _)| matches(v)).all(|(_, b)| *b);
let mut all_new = all;
if ui.checkbox(&mut all_new, "(Select All)").changed() {
for c in checks.iter_mut() {
let label = if needle.is_empty() { "(Select All)" } else { "(Select All Search Results)" };
if ui.checkbox(&mut all_new, label).changed() {
for c in checks.iter_mut().filter(|(v, _)| matches(v)) {
c.1 = all_new;
}
}
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
for (v, on) in checks.iter_mut() {
for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) {
ui.checkbox(on, v.as_str());
}
});
ui.ctx().data_mut(|d| d.insert_temp(key, checks.clone()));
ui.ctx().data_mut(|d| {
d.insert_temp(key, checks.clone());
d.insert_temp(search_key, search.clone());
});
let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect();
ui.separator();
ui.horizontal(|ui| {
if ui.button("Clear Filter").clicked() {
@@ -1853,7 +1867,10 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
});
});
if close || popup_dismissed(&response.response, was_open) {
ui.ctx().data_mut(|d| d.remove::<Vec<(String, bool)>>(key));
ui.ctx().data_mut(|d| {
d.remove::<Vec<(String, bool)>>(key);
d.remove::<String>(search_key);
});
app.grid.filter_menu = None;
}
}