Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions js/data-frame/filter-numeric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const FilterNumericImpl: React.FC<FilterNumericImplProps> = (props) => {
style={{ flex: "1 1 0", width: "0" }}
type="number"
placeholder={createPlaceholder(editing, "Min", rangeMin)}
defaultValue={min}
value={min ?? ""}
// min={rangeMin}
// max={rangeMax}
step="any"
Expand All @@ -101,7 +101,7 @@ const FilterNumericImpl: React.FC<FilterNumericImplProps> = (props) => {
style={{ flex: "1 1 0", width: "0" }}
type="number"
placeholder={createPlaceholder(editing, "Max", rangeMax)}
defaultValue={max}
value={max ?? ""}
// min={rangeMin}
// max={rangeMax}
step="any"
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,14 @@
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 ...types import ListOrTuple

from ...render._data_frame import ColumnFilter, ColumnSort, assert_column_filters
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 +1213,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 +1230,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 +1243,52 @@ 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.")
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
47 changes: 22 additions & 25 deletions shiny/render/_data_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -1172,31 +1172,7 @@ async def update_filter(
with reactive.isolate():
ncol = self._nw_data().shape[1]

for column_filter, i in zip(filter, range(len(filter))):
assert isinstance(column_filter, dict)
assert isinstance(column_filter["col"], int)
assert 0 <= column_filter["col"] < ncol
if isinstance(column_filter["value"], str):
...
elif isinstance(column_filter["value"], (list, tuple)):
assert len(column_filter["value"]) == 2
if (
column_filter["value"][0] is None
and column_filter["value"][1] is None
):
raise TypeError(
"Expected `filter[{i}]['value']` to be a `str` or a `list`/`tuple` of type `int` or `None`. Received `None` for both values."
)
assert isinstance(
column_filter["value"][0], (int, float, type(None))
)
assert isinstance(
column_filter["value"][1], (int, float, type(None))
)
else:
raise TypeError(
f"Expected `filter[{i}]['value']` to be a `str` or a `list`/`tuple` of type `int` or `None`. Received `{type(column_filter['value'])}`"
)
assert_column_filters(filter, ncol)

await self._send_message_to_browser(
"updateColumnFilter",
Expand All @@ -1223,6 +1199,27 @@ def input_cell_selection(self) -> CellSelection | None:
return self.cell_selection()


def assert_column_filters(filters: ListOrTuple[ColumnFilter], ncol: int) -> None:
for column_filter, i in zip(filters, range(len(filters))):
assert isinstance(column_filter, dict)
assert isinstance(column_filter["col"], int)
assert 0 <= column_filter["col"] < ncol
if isinstance(column_filter["value"], str):
...
elif isinstance(column_filter["value"], (list, tuple)):
assert len(column_filter["value"]) == 2
if column_filter["value"][0] is None and column_filter["value"][1] is None:
raise TypeError(
"Expected `filter[{i}]['value']` to be a `str` or a `list`/`tuple` of type `int` or `None`. Received `None` for both values."
)
assert isinstance(column_filter["value"][0], (int, float, type(None)))
assert isinstance(column_filter["value"][1], (int, float, type(None)))
else:
raise TypeError(
f"Expected `filter[{i}]['value']` to be a `str` or a `list`/`tuple` of type `int` or `None`. Received `{type(column_filter['value'])}`"
)


# TODO-barret; Make request to GT: Add class for gt location

# TODO-barret; Are GT formatters eager or lazy?
Expand Down
Loading
Loading