diff --git a/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs b/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs index 8324b165ad..92aef2d5ed 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs @@ -252,7 +252,7 @@ impl Component for ConfigSelector { false }, ConfigSelectorMsg::Close(index, DragTarget::Filter) => { - self.filter_dropdown.hide().unwrap(); + self.filter_dropdown.reset(); let mut filter = ctx.props().view_config.filter.clone(); filter.remove(index); let config = ViewConfigUpdate { diff --git a/rust/perspective-viewer/src/rust/components/column_selector/filter_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/filter_column.rs index c2ef5b2951..07491e6c50 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/filter_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/filter_column.rs @@ -162,13 +162,13 @@ impl Component for FilterColumn { false }, FilterColumnMsg::Close => { - ctx.props().filter_dropdown.hide().unwrap(); + ctx.props().filter_dropdown.reset(); false }, FilterColumnMsg::FilterKeyDown(13) => { if ctx.props().is_suggestable() { ctx.props().filter_dropdown.item_select(); - ctx.props().filter_dropdown.hide().unwrap(); + ctx.props().filter_dropdown.dismiss(); } false }, diff --git a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/row_selector.rs b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/row_selector.rs index a00cca1ccf..e595bfa67c 100644 --- a/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/row_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/row_selector.rs @@ -38,7 +38,7 @@ pub fn row_selector(props: &RowSelectorProps) -> Html { let on_select = { clone!(props.dropdown, props.index, props.set_focused_index); props.on_select.reform(move |key| { - dropdown.hide().unwrap(); + dropdown.dismiss(); set_focused_index.emit(Some(index + 1)); key }) diff --git a/rust/perspective-viewer/src/rust/components/empty_row.rs b/rust/perspective-viewer/src/rust/components/empty_row.rs index 795eafe5e2..4e51926537 100644 --- a/rust/perspective-viewer/src/rust/components/empty_row.rs +++ b/rust/perspective-viewer/src/rust/components/empty_row.rs @@ -106,7 +106,7 @@ impl Component for EmptyRow { } fn destroy(&mut self, ctx: &Context) { - ctx.props().dropdown.hide().unwrap(); + ctx.props().dropdown.reset(); } fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { @@ -121,7 +121,7 @@ impl Component for EmptyRow { false }, Blur => { - p.dropdown.hide().unwrap(); + p.dropdown.reset(); if let Some(elem) = self.input_ref.cast::() { elem.set_value(""); } @@ -139,7 +139,7 @@ impl Component for EmptyRow { }, KeyDown(13) => { p.dropdown.item_select(); - p.dropdown.hide().unwrap(); + p.dropdown.dismiss(); p.set_focused_index.emit(Some(p.index + 1)); true }, diff --git a/rust/perspective-viewer/src/rust/components/filter_dropdown.rs b/rust/perspective-viewer/src/rust/components/filter_dropdown.rs index 5ad2012f66..7a7b60bc9d 100644 --- a/rust/perspective-viewer/src/rust/components/filter_dropdown.rs +++ b/rust/perspective-viewer/src/rust/components/filter_dropdown.rs @@ -14,7 +14,6 @@ use std::cell::RefCell; use std::collections::HashSet; use std::rc::Rc; -use perspective_client::clone; use web_sys::*; use yew::html::ImplicitClone; use yew::prelude::*; @@ -25,12 +24,19 @@ use crate::ui::PortalModal; use crate::utils::*; use crate::*; -#[derive(Default)] -struct FilterDropDownState { +/// The suggestion list while it is on screen, the only state a key may act on. +struct OpenState { values: Vec, selected: usize, - on_select: Option>, - target: Option, + on_select: Callback, + target: HtmlElement, +} + +#[derive(Default)] +enum FilterDropDownState { + #[default] + Hidden, + Open(OpenState), } #[derive(Clone)] @@ -62,7 +68,6 @@ impl FilterDropDownElement { } pub fn reautocomplete(&self) { - // Re-open portal with current target self.notify.emit(()); } @@ -78,97 +83,94 @@ impl FilterDropDownElement { match current_column { Some(filter_col) if filter_col == column => { let values = filter_values(&input, &self.all_values, &exclude); - if values.len() == 1 && values[0] == input { - let _ = self.hide(); - } else { - let mut s = self.state.borrow_mut(); - s.values = values; - s.selected = 0; - s.on_select = Some(callback); - if s.target.is_none() { - s.target = Some(target); - } - - drop(s); - self.notify.emit(()); - } + self.show(values, &input, target, callback); }, _ => { - clone!( - self.state, - self.session, - self.all_values, - self.notify, - old_column = self.column - ); + let this = self.clone(); ApiFuture::spawn(async move { let fetched = - crate::queries::get_column_values(&session, column.1.clone()).await?; - *all_values.borrow_mut() = Some(fetched); - let values = filter_values(&input, &all_values, &exclude); - let should_hide = values.len() == 1 && values[0] == input; - - *old_column.borrow_mut() = Some(column); - { - let mut s = state.borrow_mut(); - s.on_select = Some(callback); - if should_hide { - let fv = self::filter_values("", &all_values, &exclude); - s.values = fv; - s.target = Some(target); - } else { - s.values = values; - s.target = Some(target); - } - s.selected = 0; - } - if should_hide { - state.borrow_mut().target = None; - } - - notify.emit(()); + crate::queries::get_column_values(&this.session, column.1.clone()).await?; + *this.all_values.borrow_mut() = Some(fetched); + *this.column.borrow_mut() = Some(column); + let values = filter_values(&input, &this.all_values, &exclude); + this.show(values, &input, target, callback); Ok(()) }); }, } } - pub fn item_select(&self) { - let state = self.state.borrow(); - if let Some(value) = state.values.get(state.selected) - && let Some(ref cb) = state.on_select - { - cb.emit(value.clone()); + /// Open the list, or dismiss it when `input` is its one exact match. + fn show( + &self, + values: Vec, + input: &str, + target: HtmlElement, + on_select: Callback, + ) { + if values.len() == 1 && values[0] == input { + self.dismiss(); + } else { + *self.state.borrow_mut() = FilterDropDownState::Open(OpenState { + values, + selected: 0, + on_select, + target, + }); + + self.notify.emit(()); } } - pub fn item_down(&self) { - let mut state = self.state.borrow_mut(); - state.selected += 1; - if state.selected >= state.values.len() { - state.selected = 0; + /// Accept a visible list's highlighted suggestion; a hidden list has none. + pub fn item_select(&self) { + let selected = match &*self.state.borrow() { + FilterDropDownState::Open(open) => open + .values + .get(open.selected) + .map(|value| (value.clone(), open.on_select.clone())), + FilterDropDownState::Hidden => None, + }; + + if let Some((value, on_select)) = selected { + on_select.emit(value); } + } - drop(state); - self.notify.emit(()); + pub fn item_down(&self) { + self.step(1); } pub fn item_up(&self) { - let mut state = self.state.borrow_mut(); - if state.selected < 1 { - state.selected = state.values.len(); + self.step(-1); + } + + fn step(&self, delta: isize) { + let moved = match &mut *self.state.borrow_mut() { + FilterDropDownState::Open(open) if !open.values.is_empty() => { + let len = open.values.len() as isize; + open.selected = (open.selected as isize + delta).rem_euclid(len) as usize; + true + }, + _ => false, + }; + + if moved { + self.notify.emit(()); } + } - state.selected -= 1; - drop(state); + /// Close the list, keeping the column's values for the next keystroke. + pub fn dismiss(&self) { + *self.state.borrow_mut() = FilterDropDownState::Hidden; self.notify.emit(()); } - pub fn hide(&self) -> ApiResult<()> { - self.state.borrow_mut().target = None; + /// Close the list and forget the column so the next focus refetches it. + pub fn reset(&self) { self.column.borrow_mut().take(); - self.notify.emit(()); - Ok(()) + self.all_values.borrow_mut().take(); + self.dismiss(); } } @@ -201,35 +203,32 @@ impl Component for FilterDropDownPortal { } fn view(&self, ctx: &Context) -> Html { - let state = ctx.props().element.state.borrow(); - let target = state.target.clone(); + let element = &ctx.props().element; + let state = element.state.borrow(); + let FilterDropDownState::Open(open) = &*state else { + return html! {}; + }; + let on_close = { - let element = ctx.props().element.clone(); - Callback::from(move |()| { - let _ = element.hide(); - }) + let element = element.clone(); + Callback::from(move |()| element.dismiss()) }; - if target.is_some() { - let values = state.values.clone(); - let selected = state.selected; - let on_select = state.on_select.clone(); - drop(state); - - html! { - - - - } - } else { - html! {} + html! { + + + } } } @@ -238,7 +237,7 @@ impl Component for FilterDropDownPortal { struct FilterDropDownViewProps { values: Vec, selected: usize, - on_select: Option>, + on_select: Callback, } #[function_component] @@ -249,7 +248,7 @@ fn FilterDropDownView(props: &FilterDropDownViewProps) -> Html { .iter() .enumerate() .map(|(idx, value)| { - let click = props.on_select.as_ref().unwrap().reform({ + let click = props.on_select.reform({ let value = value.clone(); move |_: MouseEvent| value.clone() }); diff --git a/rust/perspective-viewer/test/js/viewer_config/filter.spec.ts b/rust/perspective-viewer/test/js/viewer_config/filter.spec.ts index 4790f5fa67..112da97c99 100644 --- a/rust/perspective-viewer/test/js/viewer_config/filter.spec.ts +++ b/rust/perspective-viewer/test/js/viewer_config/filter.spec.ts @@ -131,4 +131,102 @@ test.describe("Filter Config", () => { const contents = await get_contents(page); await compareContentsToSnapshot(contents); }); + + test.describe("string autocomplete", () => { + const search = (page) => + page.locator("perspective-viewer input[type=search]"); + + const dropdown = (page) => page.locator("perspective-dropdown"); + + async function saved_filter(page) { + return await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.flush(); + return (await viewer.save()).filter; + }); + } + + test.beforeEach(async ({ page }) => { + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + const table = await window["__TEST_WORKER__"].table([ + { area: "EE" }, + { area: "LT" }, + { area: "LV" }, + ]); + + await viewer.load(table); + await viewer.restore({ + plugin: "Debug", + settings: true, + filter: [["area", "==", ""]], + }); + }); + }); + + test("Enter after typing an exact value keeps it", async ({ page }) => { + await search(page).focus(); + await search(page).pressSequentially("L"); + await expect(dropdown(page)).toBeVisible(); + await search(page).pressSequentially("V"); + await expect(dropdown(page)).toBeHidden(); + await search(page).press("Enter"); + expect(await saved_filter(page)).toEqual([["area", "==", "LV"]]); + await expect(search(page)).toHaveValue("LV"); + }); + + test("ArrowDown after typing an exact value is a no-op", async ({ + page, + }) => { + await search(page).focus(); + await search(page).pressSequentially("LV"); + await expect(dropdown(page)).toBeHidden(); + await search(page).press("ArrowDown"); + expect(await saved_filter(page)).toEqual([["area", "==", "LV"]]); + await expect(search(page)).toHaveValue("LV"); + }); + + test("Enter on a visible list accepts the highlighted suggestion", async ({ + page, + }) => { + await search(page).focus(); + await search(page).pressSequentially("L"); + const highlighted = await page + .locator("perspective-dropdown span.selected") + .textContent(); + + await search(page).press("Enter"); + expect(await saved_filter(page)).toEqual([ + ["area", "==", highlighted], + ]); + + await expect(dropdown(page)).toBeHidden(); + }); + + test("Tab dismisses the list without accepting", async ({ page }) => { + await search(page).focus(); + await search(page).pressSequentially("L"); + await expect(dropdown(page)).toBeVisible(); + await search(page).press("Tab"); + await expect(dropdown(page)).toBeHidden(); + expect(await saved_filter(page)).toEqual([["area", "==", "L"]]); + }); + + test("in > Enter after an exact last token keeps it", async ({ + page, + }) => { + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.restore({ filter: [["area", "in", []]] }); + }); + + await search(page).focus(); + await search(page).pressSequentially("E"); + await expect(dropdown(page)).toBeVisible(); + await search(page).pressSequentially("E"); + await expect(dropdown(page)).toBeHidden(); + await search(page).press("Enter"); + expect(await saved_filter(page)).toEqual([["area", "in", ["EE"]]]); + }); + }); });