Skip to content

Commit b110c32

Browse files
authored
Filtering feature for data grid/table (#592)
1 parent a50c637 commit b110c32

File tree

16 files changed

+1843
-588
lines changed

16 files changed

+1843
-588
lines changed

e2e/conftest.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import datetime
4+
import functools
45
import logging
56
import subprocess
67
import sys
@@ -24,6 +25,7 @@
2425
"local_app",
2526
"run_shiny_app",
2627
"expect_to_change",
28+
"retry_with_timeout",
2729
)
2830

2931
here = PurePath(__file__).parent
@@ -245,11 +247,57 @@ def expect_to_change(
245247
page.keyboard.send_keys("hello")
246248
247249
"""
250+
248251
original_value = func()
249252
yield
250-
start = time.time()
251-
while time.time() - start < timeoutSecs:
252-
time.sleep(0.1)
253-
if func() != original_value:
254-
return
255-
raise TimeoutError("Timeout while waiting for change")
253+
254+
@retry_with_timeout(timeoutSecs)
255+
def wait_for_change():
256+
if func() == original_value:
257+
raise AssertionError("Value did not change")
258+
259+
wait_for_change()
260+
261+
262+
def retry_with_timeout(timeout: float = 30):
263+
"""
264+
Decorator that retries a function until 1) it succeeds, 2) fails with a
265+
non-assertion error, or 3) repeatedly fails with an AssertionError for longer than
266+
the timeout. If the timeout elapses, the last AssertionError is raised.
267+
268+
Parameters
269+
----------
270+
timeout
271+
How long to wait for the function to succeed before raising the last
272+
AssertionError.
273+
274+
Returns
275+
-------
276+
A decorator that can be applied to a function.
277+
278+
Example
279+
-------
280+
281+
@retry_with_timeout(30)
282+
def try_to_find_element():
283+
if not page.locator("#name").exists():
284+
raise AssertionError("Element not found")
285+
286+
try_to_find_element()
287+
"""
288+
289+
def decorator(func: Callable[[], None]) -> Callable[[], None]:
290+
@functools.wraps(func)
291+
def wrapper() -> None:
292+
start = time.time()
293+
while True:
294+
try:
295+
return func()
296+
except AssertionError as e:
297+
if time.time() - start > timeout:
298+
raise e
299+
time.sleep(0.1)
300+
301+
return wrapper
302+
303+
return decorator

e2e/data_frame/test_data_frame.py

Lines changed: 136 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33

44
import re
5-
import time
65
from typing import Any, Callable
76

87
import pytest
98
from conftest import ShinyAppProc, create_example_fixture, expect_to_change
109
from controls import InputSelectize, InputSwitch
1110
from playwright.sync_api import Locator, Page, expect
1211

12+
RERUNS = 3
13+
1314
data_frame_app = create_example_fixture("dataframe")
1415

1516

@@ -37,7 +38,7 @@ def do():
3738
return do
3839

3940

40-
@pytest.mark.flaky
41+
@pytest.mark.flaky(reruns=RERUNS)
4142
def test_grid_mode(
4243
page: Page, data_frame_app: ShinyAppProc, grid: Locator, grid_container: Locator
4344
):
@@ -51,23 +52,23 @@ def test_grid_mode(
5152
expect(grid_container).to_have_class(re.compile(r"\bshiny-data-grid-grid\b"))
5253

5354

54-
@pytest.mark.flaky
55+
@pytest.mark.flaky(reruns=RERUNS)
5556
def test_summary_navigation(
5657
page: Page, data_frame_app: ShinyAppProc, grid_container: Locator, summary: Locator
5758
):
5859
page.goto(data_frame_app.url)
5960

6061
# Check that summary responds to navigation
61-
expect(summary).to_have_text("Viewing rows 1 through 10 of 20")
62+
expect(summary).to_have_text(re.compile("^Viewing rows 1 through \\d+ of 20$"))
6263
# Put focus in the table and hit End keystroke
6364
grid_container.locator("tbody tr:first-child td:first-child").click()
6465
with expect_to_change(lambda: summary.inner_text()):
6566
page.keyboard.press("End")
6667
# Ensure that summary updated
67-
expect(summary).to_have_text("Viewing rows 11 through 20 of 20")
68+
expect(summary).to_have_text(re.compile("^Viewing rows \\d+ through 20 of 20$"))
6869

6970

70-
@pytest.mark.flaky
71+
@pytest.mark.flaky(reruns=RERUNS)
7172
def test_full_width(page: Page, data_frame_app: ShinyAppProc, grid_container: Locator):
7273
page.goto(data_frame_app.url)
7374

@@ -87,7 +88,7 @@ def get_width() -> float:
8788
InputSwitch(page, "fullwidth").toggle()
8889

8990

90-
@pytest.mark.flaky
91+
@pytest.mark.flaky(reruns=RERUNS)
9192
def test_table_switch(
9293
page: Page,
9394
data_frame_app: ShinyAppProc,
@@ -107,17 +108,18 @@ def test_table_switch(
107108
expect(grid_container).to_have_class(re.compile(r"\bshiny-data-grid-table\b"))
108109

109110
# Switching modes resets scroll
110-
expect(summary).to_have_text("Viewing rows 1 through 10 of 20")
111+
expect(summary).to_have_text(re.compile("^Viewing rows 1 through \\d+ of 20$"))
111112

112113
scroll_to_end()
113-
expect(summary).to_have_text("Viewing rows 11 through 20 of 20")
114+
expect(summary).to_have_text(re.compile("^Viewing rows \\d+ through 20 of 20$"))
114115

115116
# Switch datasets to much longer one
116117
select_dataset.set("diamonds")
117-
expect(summary).to_have_text("Viewing rows 1 through 10 of 53940")
118+
select_dataset.expect.to_have_value("diamonds")
119+
expect(summary).to_have_text(re.compile("^Viewing rows 1 through \\d+ of 53940$"))
118120

119121

120-
@pytest.mark.flaky
122+
@pytest.mark.flaky(reruns=RERUNS)
121123
def test_sort(
122124
page: Page,
123125
data_frame_app: ShinyAppProc,
@@ -126,6 +128,7 @@ def test_sort(
126128
page.goto(data_frame_app.url)
127129
select_dataset = InputSelectize(page, "dataset")
128130
select_dataset.set("diamonds")
131+
select_dataset.expect.to_have_value("diamonds")
129132

130133
# Test sorting
131134
header_clarity = grid_container.locator("tr:first-child th:nth-child(4)")
@@ -143,7 +146,7 @@ def test_sort(
143146
expect(first_cell_depth).to_have_text("67.6")
144147

145148

146-
@pytest.mark.flaky
149+
@pytest.mark.flaky(reruns=RERUNS)
147150
def test_multi_selection(
148151
page: Page, data_frame_app: ShinyAppProc, grid_container: Locator, snapshot: Any
149152
):
@@ -173,7 +176,7 @@ def detail_text():
173176
assert detail_text() == snapshot
174177

175178

176-
@pytest.mark.flaky
179+
@pytest.mark.flaky(reruns=RERUNS)
177180
def test_single_selection(
178181
page: Page, data_frame_app: ShinyAppProc, grid_container: Locator, snapshot: Any
179182
):
@@ -204,18 +207,125 @@ def detail_text():
204207
assert detail_text() == snapshot
205208

206209

207-
def retry_with_timeout(timeout: float = 30):
208-
def decorator(func: Callable[[], None]) -> None:
209-
def exec() -> None:
210-
start = time.time()
211-
while True:
212-
try:
213-
return func()
214-
except AssertionError as e:
215-
if time.time() - start > timeout:
216-
raise e
217-
time.sleep(0.1)
210+
def test_filter_grid(
211+
page: Page,
212+
data_frame_app: ShinyAppProc,
213+
grid: Locator,
214+
summary: Locator,
215+
snapshot: Any,
216+
):
217+
page.goto(data_frame_app.url)
218+
_filter_test_impl(page, data_frame_app, grid, summary, snapshot)
219+
220+
221+
def test_filter_table(
222+
page: Page,
223+
data_frame_app: ShinyAppProc,
224+
grid: Locator,
225+
grid_container: Locator,
226+
summary: Locator,
227+
snapshot: Any,
228+
):
229+
page.goto(data_frame_app.url)
218230

219-
exec()
231+
InputSwitch(page, "gridstyle").toggle()
232+
expect(grid_container).not_to_have_class(re.compile(r"\bshiny-data-grid-grid\b"))
233+
expect(grid_container).to_have_class(re.compile(r"\bshiny-data-grid-table\b"))
234+
235+
_filter_test_impl(page, data_frame_app, grid, summary, snapshot)
236+
237+
238+
def _filter_test_impl(
239+
page: Page,
240+
data_frame_app: ShinyAppProc,
241+
grid: Locator,
242+
summary: Locator,
243+
snapshot: Any,
244+
):
245+
filters = grid.locator("tr.filters")
246+
247+
filter_subidir_min = filters.locator("> th:nth-child(1) > div > input:nth-child(1)")
248+
filter_subidir_max = filters.locator("> th:nth-child(1) > div > input:nth-child(2)")
249+
filter_attnr = filters.locator("> th:nth-child(2) > input")
250+
filter_num1_min = filters.locator("> th:nth-child(3) > div > input:nth-child(1)")
251+
filter_num1_max = filters.locator("> th:nth-child(3) > div > input:nth-child(2)")
252+
253+
expect(filter_subidir_min).to_be_visible()
254+
expect(filter_subidir_max).to_be_visible()
255+
expect(filter_attnr).to_be_visible()
256+
expect(filter_num1_min).to_be_visible()
257+
expect(filter_num1_max).to_be_visible()
258+
259+
expect(summary).to_be_visible()
260+
expect(summary).to_have_text(re.compile(" of 20$"))
261+
262+
# Placeholder text only appears when filter is focused
263+
expect(page.get_by_placeholder("Min (1)", exact=True)).not_to_be_attached()
264+
expect(page.get_by_placeholder("Max (20)", exact=True)).not_to_be_attached()
265+
filter_subidir_min.focus()
266+
expect(page.get_by_placeholder("Min (1)", exact=True)).to_be_attached()
267+
expect(page.get_by_placeholder("Max (20)", exact=True)).to_be_attached()
268+
filter_subidir_min.blur()
269+
expect(page.get_by_placeholder("Min (1)", exact=True)).not_to_be_attached()
270+
expect(page.get_by_placeholder("Max (20)", exact=True)).not_to_be_attached()
271+
272+
# Make sure that filtering input results in correct number of rows
273+
274+
# Test only min
275+
filter_subidir_min.fill("5")
276+
expect(summary).to_have_text(re.compile(" of 16$"))
277+
# Test min and max
278+
filter_subidir_max.fill("14")
279+
expect(summary).to_have_text(re.compile(" of 10$"))
280+
281+
# When filtering results in all rows being shown, the summary should not be visible
282+
filter_subidir_max.fill("11")
283+
expect(summary).not_to_be_attached()
284+
285+
# Test only max
286+
filter_subidir_min.fill("")
287+
expect(summary).to_have_text(re.compile(" of 11"))
288+
289+
filter_subidir_min.clear()
290+
filter_subidir_max.clear()
291+
292+
# Try substring search
293+
filter_attnr.fill("oc")
294+
expect(summary).to_have_text(re.compile(" of 10"))
295+
filter_num1_min.focus()
296+
# Ensure other columns' filter placeholders show faceted results
297+
expect(page.get_by_placeholder("Min (5)", exact=True)).to_be_attached()
298+
expect(page.get_by_placeholder("Max (8)", exact=True)).to_be_attached()
299+
300+
# Filter down to zero matching rows
301+
filter_attnr.fill("q")
302+
# Summary should be gone
303+
expect(summary).not_to_be_attached()
304+
filter_num1_min.focus()
305+
# Placeholders should not have values
306+
expect(page.get_by_placeholder("Min", exact=True)).to_be_attached()
307+
expect(page.get_by_placeholder("Max", exact=True)).to_be_attached()
308+
309+
filter_attnr.clear()
310+
311+
# Apply multiple filters, make sure we get the correct results
312+
filter_subidir_max.fill("8")
313+
filter_num1_min.fill("4")
314+
expect(grid.locator("tbody tr")).to_have_count(5)
315+
316+
# Ensure changing dataset resets filters
317+
select_dataset = InputSelectize(page, "dataset")
318+
select_dataset.set("attention")
319+
select_dataset.expect.to_have_value("attention")
320+
expect(page.get_by_text("Unnamed: 0")).to_be_attached()
321+
select_dataset.set("anagrams")
322+
select_dataset.expect.to_have_value("anagrams")
323+
expect(summary).to_have_text(re.compile(" of 20"))
324+
325+
326+
def test_filter_disable(page: Page, data_frame_app: ShinyAppProc):
327+
page.goto(data_frame_app.url)
220328

221-
return decorator
329+
expect(page.locator("tr.filters")).to_be_attached()
330+
InputSwitch(page, "filters").toggle()
331+
expect(page.locator("tr.filters")).not_to_be_attached()

e2e/inputs/test_input_slider.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,15 @@ def test_input_slider_kitchen(page: Page, slider_app: ShinyAppProc) -> None:
4242

4343
# # Duplicate logic of next test. Only difference is `max_err_values=15`
4444
# try:
45-
# obs.set("not-a-number", timeout=200)
45+
# obs.set("not-a-number", timeout=800)
4646
# except ValueError as e:
4747
# values_found = '"10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", ...'
4848
# assert values_found in str(
4949
# e
5050
# ), "Error message should contain the list of first 15 valid values"
5151

5252
try:
53-
obs.set("not-a-number", timeout=200, max_err_values=4)
53+
obs.set("not-a-number", timeout=800, max_err_values=4)
5454
except ValueError as e:
5555
values_found = '"10", "11", "12", "13", ...'
5656
assert values_found in str(

examples/dataframe/app.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def app_ui(req):
2121
{"none": "(None)", "single": "Single", "multiple": "Multiple"},
2222
selected="multiple",
2323
),
24+
ui.input_switch("filters", "Filters", True),
2425
ui.input_switch("gridstyle", "Grid", True),
2526
ui.input_switch("fullwidth", "Take full width", True),
2627
ui.output_data_frame("grid"),
@@ -68,16 +69,18 @@ def grid():
6869
if input.gridstyle():
6970
return render.DataGrid(
7071
df(),
71-
row_selection_mode=input.selection_mode(),
72-
height=height,
7372
width=width,
73+
height=height,
74+
filters=input.filters(),
75+
row_selection_mode=input.selection_mode(),
7476
)
7577
else:
7678
return render.DataTable(
7779
df(),
78-
row_selection_mode=input.selection_mode(),
79-
height=height,
8080
width=width,
81+
height=height,
82+
filters=input.filters(),
83+
row_selection_mode=input.selection_mode(),
8184
)
8285

8386
@reactive.Effect

0 commit comments

Comments
 (0)