Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b6a3b11
Refactor download controls with shared mixin
karangattu Oct 7, 2025
80018f2
Add download button and link kitchen sink tests
karangattu Oct 7, 2025
c5dd290
Enhance set_filter to support multi-column filtering
karangattu Oct 7, 2025
4e8e4dc
Add type annotation for filter_items in OutputDataFrame
karangattu Oct 7, 2025
7f37c0c
Update changelog with multi-column filter support
karangattu Oct 7, 2025
797e5c9
Remove outdated TODO comment in set_filter method
karangattu Oct 7, 2025
438fedc
Refactor navset_bar panels into reusable function
karangattu Oct 8, 2025
21c9631
Update type hints for filter_items in OutputDataFrame
karangattu Oct 8, 2025
1d4534d
Add output_code UI component and tests
karangattu Oct 8, 2025
c106374
Remove unused import from app-express.py
karangattu Oct 8, 2025
2a84b36
Add Playwright test for OutputTextVerbatim component
karangattu Oct 8, 2025
32e8256
Name changes
schloerke Oct 9, 2025
1e598f6
Be sure to use `""` when the value is undefined, otherwise the field …
schloerke Oct 9, 2025
1579f11
Set it out of order to prove the filter order is consistent
schloerke Oct 9, 2025
bf8aea8
Update .gitignore
schloerke Oct 9, 2025
cecf011
Use consistent column filter shape
schloerke Oct 9, 2025
47b3ff2
Update changelog and tests for OutputDataFrame filter changes
karangattu Oct 10, 2025
ba89870
Fix import path for ListOrTuple in _output.py
karangattu Oct 10, 2025
37e2541
Reorder imports in _output.py for consistency
karangattu Oct 16, 2025
66a0590
Improve numeric filter input handling
karangattu Oct 17, 2025
09213a9
Ensure column label is visible before asserting filters
karangattu Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,4 @@ test-results.xml
results-inspect-ai/
test-results-inspect-ai/
tests/inspect-ai/scripts/test_metadata.json
.claude/
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to Shiny for Python will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Improvements

* Playwright's `OutputDataFrame.set_filter()` controller now supports multi-column filters (#2093)
* Add api-example for `ui.output_code` (#2093)
* Update controllers for `DownloadLink` and `DownloadButton` (#2093)

### Bug fixes

* `ui.output_data_frame` will now consistently order the filtered columns in ascending column order. (#2093)
* When resetting a `ui.output_data_frame` filter, numeric range filters will now reset both values. (#2093)

## [1.5.0] - 2025-09-11

### New features
Expand Down
25 changes: 21 additions & 4 deletions js/data-frame/filter-numeric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ const FilterNumericImpl: React.FC<FilterNumericImplProps> = (props) => {
const minInputRef = useRef<HTMLInputElement>(null);
const maxInputRef = useRef<HTMLInputElement>(null);

// Local state to track the string value while typing
const [minInputValue, setMinInputValue] = useState<string>("");
const [maxInputValue, setMaxInputValue] = useState<string>("");

// Update local string state when prop values change
useEffect(() => {
setMinInputValue(min !== undefined ? String(min) : "");
}, [min]);

useEffect(() => {
setMaxInputValue(max !== undefined ? String(max) : "");
}, [max]);

return (
<div
onBlur={(e) => {
Expand All @@ -79,12 +92,14 @@ const FilterNumericImpl: React.FC<FilterNumericImplProps> = (props) => {
style={{ flex: "1 1 0", width: "0" }}
type="number"
placeholder={createPlaceholder(editing, "Min", rangeMin)}
defaultValue={min}
value={minInputValue}
// min={rangeMin}
// max={rangeMax}
step="any"
onChange={(e) => {
const value = coerceToNum(e.target.value);
const inputValue = e.target.value;
setMinInputValue(inputValue);
const value = coerceToNum(inputValue);
if (!minInputRef.current) return;
minInputRef.current.classList.toggle(
"is-invalid",
Expand All @@ -101,12 +116,14 @@ const FilterNumericImpl: React.FC<FilterNumericImplProps> = (props) => {
style={{ flex: "1 1 0", width: "0" }}
type="number"
placeholder={createPlaceholder(editing, "Max", rangeMax)}
defaultValue={max}
value={maxInputValue}
// min={rangeMin}
// max={rangeMax}
step="any"
onChange={(e) => {
const value = coerceToNum(e.target.value);
const inputValue = e.target.value;
setMaxInputValue(inputValue);
const value = coerceToNum(inputValue);
if (!maxInputRef.current) return;
maxInputRef.current.classList.toggle(
"is-invalid",
Expand Down
1 change: 1 addition & 0 deletions js/data-frame/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,7 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
value: filterObj.value as FilterValue,
});
});
shinyFilter.sort((a, b) => a.col - b.col);
window.Shiny.setInputValue!(`${id}_filter`, shinyFilter);

// Deprecated as of 2024-05-21
Expand Down
29 changes: 29 additions & 0 deletions shiny/api-examples/output_code/app-core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from shiny import App, Inputs, Outputs, Session, render, ui

app_ui = ui.page_fluid(
ui.input_text_area(
"source",
"Enter code to display below:",
"print('Hello, Shiny!')\nfor i in range(3):\n print(i)",
rows=8,
),
ui.card(
ui.output_code("code_default"),
),
ui.card(
ui.output_code("code_no_placeholder", placeholder=False),
),
)


def server(input: Inputs, output: Outputs, session: Session):
@render.code
def code_default():
return input.source()

@render.code
def code_no_placeholder():
return input.source()


app = App(app_ui, server)
21 changes: 21 additions & 0 deletions shiny/api-examples/output_code/app-express.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from shiny.express import input, render, ui

ui.input_text_area(
"source",
"Enter code to display below:",
"print('Hello, Shiny!')\nfor i in range(3):\n print(i)",
rows=8,
)

with ui.card():

@render.code
def code_default():
return input.source()


with ui.card():

@render.code(placeholder=False)
def code_no_placeholder():
return input.source()
2 changes: 1 addition & 1 deletion shiny/playwright/controller/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ def expect_label(
playwright_expect(self.loc_label).to_have_text(value, timeout=timeout)


class WidthLocStlyeM:
class WidthLocStyleM:
"""
A mixin class that provides methods to control the width of input action buttons and action links.

Expand Down
48 changes: 31 additions & 17 deletions shiny/playwright/controller/_file.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,37 @@
from __future__ import annotations

from playwright.sync_api import Page
from playwright.sync_api import expect as playwright_expect

from ._base import InputActionBase, WidthLocM
from .._types import PatternOrStr, Timeout
from ._base import InputActionBase, WidthLocStyleM


# TODO: Use mixin for dowloadlink and download button
class DownloadLink(InputActionBase):
class _DownloadBase(
WidthLocStyleM,
InputActionBase,
):
"""Mixin for download controls."""

def __init__(self, page: Page, id: str, *, loc_suffix: str) -> None:
super().__init__(
page,
id=id,
loc=f"#{id}.shiny-download-link{loc_suffix}",
)

def expect_label(
self,
value: PatternOrStr,
*,
timeout: Timeout = None,
) -> None:
"""Expect the anchor itself to contain the provided label text."""

playwright_expect(self.loc).to_have_text(value, timeout=timeout)


class DownloadLink(_DownloadBase):
"""
Controller for :func:`shiny.ui.download_link`.
"""
Expand All @@ -22,17 +47,10 @@ def __init__(self, page: Page, id: str) -> None:
id
The ID of the download link.
"""
super().__init__(
page,
id=id,
loc=f"#{id}.shiny-download-link:not(.btn)",
)
super().__init__(page, id=id, loc_suffix=":not(.btn)")


class DownloadButton(
WidthLocM,
InputActionBase,
):
class DownloadButton(_DownloadBase):
"""
Controller for :func:`shiny.ui.download_button`
"""
Expand All @@ -48,8 +66,4 @@ def __init__(self, page: Page, id: str) -> None:
id
The ID of the download button.
"""
super().__init__(
page,
id=id,
loc=f"#{id}.btn.shiny-download-link",
)
super().__init__(page, id=id, loc_suffix=".btn")
6 changes: 3 additions & 3 deletions shiny/playwright/controller/_input_buttons.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@
InputActionBase,
UiBase,
UiWithLabel,
WidthLocStlyeM,
WidthLocStyleM,
_expect_multiple,
)


class InputActionButton(
WidthLocStlyeM,
WidthLocStyleM,
InputActionBase,
):
"""Controller for :func:`shiny.ui.input_action_button`."""
Expand Down Expand Up @@ -192,7 +192,7 @@ def expect_attribute(self, value: str, *, timeout: Timeout = None):


class InputTaskButton(
WidthLocStlyeM,
WidthLocStyleM,
InputActionBase,
):
"""Controller for :func:`shiny.ui.input_task_button`."""
Expand Down
76 changes: 44 additions & 32 deletions shiny/playwright/controller/_output.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from __future__ import annotations

import platform
from typing import Literal, Protocol
from typing import Any, Literal, Protocol, Sequence, cast

from playwright.sync_api import Locator, Page
from playwright.sync_api import expect as playwright_expect

from ...render._data_frame import ColumnFilter, ColumnSort
from ...render._data_frame import ColumnFilter, ColumnSort, assert_column_filters
from ...types import ListOrTuple
from .._types import AttrValue, ListPatternOrStr, PatternOrStr, StyleValue, Timeout
from ..expect import expect_not_to_have_class, expect_to_have_class
from ..expect._internal import (
Expand Down Expand Up @@ -1211,11 +1212,9 @@ def click_loc(loc: Locator, *, shift: bool = False):
break
click_loc(sort_col, shift=shift)

# TODO-karan-test: Add support for a list of columns ? If so, all other columns should be reset
def set_filter(
self,
# TODO-barret support array of filters
filter: ColumnFilter | list[ColumnFilter] | None,
filter: ColumnFilter | ListOrTuple[ColumnFilter] | None,
*,
timeout: Timeout = None,
):
Expand All @@ -1230,6 +1229,7 @@ def set_filter(
* `None`: Resets all filters.
* `ColumnFilterStr`: A dictionary specifying a string filter with 'col' and 'value' keys.
* `ColumnFilterNumber`: A dictionary specifying a numeric range filter with 'col' and 'value' keys.
* A sequence of `ColumnFilterStr` or `ColumnFilterNumber` dictionaries, for multiple filters.
timeout
The maximum time to wait for the action to complete. Defaults to `None`.
"""
Expand All @@ -1242,41 +1242,53 @@ def set_filter(
if filter is None:
return

filter_items: ListOrTuple[ColumnFilter]
if isinstance(filter, dict):
filter = [filter]

if not isinstance(filter, list):
filter_items = [filter]
elif isinstance(filter, (list, tuple)):
filter_items = filter
else:
raise ValueError(
"Invalid filter value. Must be a ColumnFilter, list[ColumnFilter], or None."
"Invalid filter value. Must be a ColumnFilter, "
"list[ColumnFilter], or None."
)

for filterInfo in filter:
if "col" not in filterInfo:
raise ValueError("Column index (`col`) is required for filtering.")
playwright_expect(self.loc_column_label.first).to_be_visible(timeout=timeout)
assert_column_filters(filter_items, self.loc_column_label.count())

if "value" not in filterInfo:
raise ValueError("Filter value (`value`) is required for filtering.")
for filter_item in filter_items:
col_idx = filter_item["col"]
value = filter_item.get("value", None)

filterColumn = self.loc_column_filter.nth(filterInfo["col"])
filterColumn = self.loc_column_filter.nth(col_idx)

if isinstance(filterInfo["value"], str):
filterColumn.locator("> input").fill(filterInfo["value"])
elif isinstance(filterInfo["value"], (tuple, list)):
header_inputs = filterColumn.locator("> div > input")
if filterInfo["value"][0] is not None:
header_inputs.nth(0).fill(
str(filterInfo["value"][0]),
timeout=timeout,
)
if filterInfo["value"][1] is not None:
header_inputs.nth(1).fill(
str(filterInfo["value"][1]),
timeout=timeout,
if isinstance(value, (str, int, float)):
filterColumn.locator("> input").fill(str(value), timeout=timeout)
continue

if isinstance(value, (list, tuple)):
range_values = cast(Sequence[Any], value)
if len(range_values) != 2:
raise ValueError(
"Numeric range filters must provide exactly two "
"values (min, max)."
)
else:
raise ValueError(
"Invalid filter value. Must be a string or a tuple/list of two numbers."
)

header_inputs = filterColumn.locator("> div > input")
lower = range_values[0]
upper = range_values[1]
if lower is not None:
header_inputs.nth(0).fill(str(lower), timeout=timeout)
else:
header_inputs.nth(0).fill("", timeout=timeout)

if upper is not None:
header_inputs.nth(1).fill(str(upper), timeout=timeout)
else:
header_inputs.nth(1).fill("", timeout=timeout)
continue

raise ValueError("Invalid filter value.")

def set_cell(
self,
Expand Down
Loading