Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ 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)

## [1.5.0] - 2025-09-11

### New features
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()
45 changes: 28 additions & 17 deletions shiny/playwright/controller/_file.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
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, WidthLocStlyeM


# TODO: Use mixin for dowloadlink and download button
class DownloadLink(InputActionBase):
class _DownloadMixin(WidthLocStlyeM, 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(_DownloadMixin):
"""
Controller for :func:`shiny.ui.download_link`.
"""
Expand All @@ -22,17 +44,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(_DownloadMixin):
"""
Controller for :func:`shiny.ui.download_button`
"""
Expand All @@ -48,8 +63,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")
101 changes: 79 additions & 22 deletions shiny/playwright/controller/_output.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

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

from playwright.sync_api import Locator, Page
from playwright.sync_api import expect as playwright_expect
Expand Down Expand Up @@ -1211,10 +1211,8 @@ 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,
*,
timeout: Timeout = None,
Expand Down Expand Up @@ -1242,40 +1240,99 @@ def set_filter(
if filter is None:
return

filter_items: Sequence[Union[ColumnFilter, dict[str, Any]]]
if isinstance(filter, dict):
filter = [filter]

if not isinstance(filter, list):
filter_items = [filter]
elif isinstance(filter, (list, tuple)):
filter_items = cast(Sequence[Union[ColumnFilter, dict[str, Any]]], 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:
for filterInfo in filter_items:
if "col" not in filterInfo:
raise ValueError("Column index (`col`) is required for filtering.")

if "value" not in filterInfo:
raise ValueError("Filter value (`value`) is required for filtering.")

filterColumn = self.loc_column_filter.nth(filterInfo["col"])
raw_cols = filterInfo["col"]
if isinstance(raw_cols, int):
column_indices: list[int] = [raw_cols]
elif isinstance(raw_cols, (list, tuple)):
cols_iter = cast(Sequence[Any], raw_cols)
if len(cols_iter) == 0:
raise ValueError(
"Column index list (`col`) must contain at least one " "entry."
)
column_indices = []
for col_idx in cols_iter:
if not isinstance(col_idx, int):
raise ValueError(
"Column index (`col`) values must be integers "
"when specifying multiple columns."
)
column_indices.append(col_idx)
else:
raise ValueError(
"Column index (`col`) must be an int or a list/tuple of " "ints."
)

raw_value = filterInfo["value"]

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 len(column_indices) > 1:
if not isinstance(raw_value, (list, tuple)):
raise ValueError(
"When filtering multiple columns, `value` must be a "
"list or tuple with one entry per column."
)
if filterInfo["value"][1] is not None:
header_inputs.nth(1).fill(
str(filterInfo["value"][1]),
timeout=timeout,
value_sequence = cast(Sequence[Any], raw_value)
if len(value_sequence) != len(column_indices):
raise ValueError(
"The number of filter values must match the number of "
"target columns."
)
value_iter = list(value_sequence)
else:
value_iter = [raw_value]

for col_idx, value in zip(column_indices, value_iter):
filterColumn = self.loc_column_filter.nth(col_idx)

if value is None:
continue

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)."
)

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. Must be a string or a tuple/list of two numbers."
"Invalid filter value. Must be a string/number, a "
"tuple/list of two numbers, or None."
)

def set_cell(
Expand Down
7 changes: 2 additions & 5 deletions shiny/ui/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from htmltools import Tag, TagAttrValue, TagFunction, css, div, tags

from .._docstring import add_example, no_example
from .._docstring import add_example
from ..module import resolve_id
from ..types import MISSING, MISSING_TYPE
from ._plot_output_opts import (
Expand Down Expand Up @@ -271,7 +271,7 @@ def output_text(
return container(id=resolve_id(id), class_="shiny-text-output")


@no_example()
@add_example()
def output_code(id: str, placeholder: bool = True) -> Tag:
"""
Create a output container for code (monospaced text).
Expand Down Expand Up @@ -304,9 +304,6 @@ def output_code(id: str, placeholder: bool = True) -> Tag:
* :func:`~shiny.ui.output_text`
* :func:`~shiny.ui.output_text_verbatim`

Example
-------
See :func:`~shiny.ui.output_text`
"""

cls = "shiny-text-output" + (" noplaceholder" if not placeholder else "")
Expand Down
Loading
Loading