Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8eec04d
first pass at update_cell
schloerke Oct 4, 2024
65fe19f
Add support for column to be a string. Add assertions
schloerke Oct 4, 2024
a228b78
Rename params to `row` and `col`
schloerke Oct 4, 2024
f0d9c12
Remove unnecessary session context
schloerke Oct 4, 2024
da3aef8
Update app to work with new update code
schloerke Oct 7, 2024
03167d1
Merge branch 'main' into df_update_cell
schloerke Oct 7, 2024
2ba95de
Working version of `.update_data(self, data)`
schloerke Oct 7, 2024
10dec9c
Allow for new columns and type hints! (Needs testing)
schloerke Oct 7, 2024
5660a76
todos
schloerke Oct 7, 2024
d67c4c6
Require self session when serializing / registering any components
schloerke Oct 7, 2024
ad5fcf5
Clean up some python code
schloerke Oct 7, 2024
19a2a61
Fix app
schloerke Oct 8, 2024
e8e1a38
Do not call style update method until the cell patches or data updates
schloerke Oct 8, 2024
3173c4d
Remove print statements
schloerke Oct 8, 2024
7b44816
Clone data before processing it to avoid in-place updates; Raised nar…
schloerke Oct 8, 2024
01b5e36
Expose `.data_patched()`
schloerke Oct 8, 2024
bf2f6c2
Talk about the different data methods available to the shiny data frame
schloerke Oct 8, 2024
8adf597
Make reactives variable names clearer
schloerke Oct 8, 2024
75648f7
Add tests to `.update_cell_value()` and `.update_data()`
schloerke Oct 8, 2024
228986c
add assertions for update_cell row/col index values
schloerke Oct 9, 2024
e4fd17a
fix bug in `OutputDataFrame.expect_nrow()` to assert the number of vi…
schloerke Oct 9, 2024
d65e224
Add comment on how to do docs in the future
schloerke Oct 9, 2024
a72afc2
Remove commented code in `.update_data()`
schloerke Oct 9, 2024
308b2c2
Explicitly test for `data=None` values in `DataGrid`, `DataTable` and…
schloerke Oct 9, 2024
e6175b5
Update and distribute data method docs
schloerke Oct 9, 2024
40c1d5f
Update CHANGELOG.md
schloerke Oct 9, 2024
364ec0a
Fix dynamic `aria-rowcount` value. Previously fixed to full data size…
schloerke Oct 9, 2024
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* Fixed bug in `@render.data_frame` where `bool` or `object` columns were not being rendered. (#1570)

* Fixed output controller `OutputDataFrame` in `shiny.playwright.controller` to correctly assert the number of rows in `.expect_nrow()` as the total number of virtual rows, not the number of currently displaying rows. (#1719)


## [1.1.0] - 2024-09-03

Expand Down
11 changes: 7 additions & 4 deletions shiny/playwright/controller/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,8 +673,9 @@ def __init__(self, page: Page, id: str) -> None:
loc_container=f"#{id}.html-fill-item",
loc="> div > div.shiny-data-grid",
)
self.loc_head = self.loc.locator("> table > thead")
self.loc_body = self.loc.locator("> table > tbody")
self.loc_table = self.loc.locator("> table")
self.loc_head = self.loc_table.locator("> thead")
self.loc_body = self.loc_table.locator("> tbody")
self.loc_column_filter = self.loc_head.locator(
"> tr.filters > th:not(.table-corner)"
)
Expand Down Expand Up @@ -723,8 +724,10 @@ def expect_nrow(self, value: int, *, timeout: Timeout = None):
timeout
The maximum time to wait for the expectation to pass. Defaults to `None`.
"""
playwright_expect(self.loc_body.locator("> tr")).to_have_count(
value, timeout=timeout
playwright_expect(self.loc_table).to_have_attribute(
"aria-rowcount",
str(value),
timeout=timeout,
)

def expect_selected_num_rows(self, value: int, *, timeout: Timeout = None):
Expand Down
107 changes: 77 additions & 30 deletions shiny/render/_data_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,21 +109,45 @@ class data_frame(
be able to edit the cells in the table. After a cell has been edited, the edited
value will be sent to the server for processing. The handling methods are set via
`@<data_frame_renderer>.set_patch_fn` or `@<data_frame_renderer>.set_patches_fn`
decorators. By default, both decorators will return a string value.

To access the data viewed by the user, use `<data_frame_renderer>.data_view()`. This
method will sort, filter, and apply any patches to the data frame as viewed by the
user within the browser. This is a shallow copy of the original data frame. It is
possible that alterations to `data_view` could alter the original `data` data frame.

To access the original data, use `<data_frame_renderer>.data()`. This is a quick
reference to the original pandas or polars data frame that was returned from the
app's render function. If it is mutated in place, it **will** modify the original
data.

Note... if the data frame renderer is re-rendered due to reactivity, then
the user's edits, sorting, and filtering will be lost. We hope to improve upon this
in the future.
decorators. By default, both decorators will return the corresponding value as a
string.

Data methods
------------

There are several methods available to inspect and update data frame renderer. It is
important to know the side effects of each method to know how they interact with
each other.

* Data frame render method:
* When called, returns the original data frame (possibly wrapped in a `DataGrid`
or `DataTable`) that was returned from the app's render function.
* When this method is reactively executed, **all user state is reset**. This
includes the user's edits, sorting, and filtering.
* `.data()`:
* Returns the render method's underlying data frame or the data frame supplied to
`.update_data(data)`, which ever has been most recently set.
* `.cell_patches()`:
* Returns a list of user edits (or updated cell values) that will be applied to
the `.data_patched()` data frame.
* `.data_patched()`:
* Returns the `.data()` data frame with any user edits or `.update_cell_value()`
patches applied.
* `.data_view(*, selected: bool)`:
* Returns the `.data_patched()` data frame with the user's sorting and filtering
applied. It represents the data frame as seen in the browser.
* If `selected=True`, only the selected rows are returned.
* `.update_cell_value(value, row, col)`:
* Sets a new entry in `.cell_patches()`.
* Calling this method will **not** reset the user's sorting or filtering.
* `.update_data(data)`:
* Updates the `.data()` data frame with new data.
* Calling this method will remove all `.cell_patches()`.
* Calling this method will **not** reset the user's sorting or filtering.

Note: All data methods are shallow copies of each other. If they are mutated in
place, it **will modify** the underlying data object and possibly alter other data
objects.

Narwhals
-------------------
Expand All @@ -148,7 +172,6 @@ class data_frame(
it will not insert any tags, but it will cause the column to be interpreted as
`html` where possible. (tl/dr: Use consistent typing in your columns!)


Tip
---
This decorator should be applied **before** the ``@output`` decorator (if that
Expand Down Expand Up @@ -298,7 +321,7 @@ def cell_patches(self) -> list[CellPatch]:
return list(self._cell_patch_map().values())

@reactive_calc_method
def _data_patched(self) -> DataFrame[IntoDataFrameT]:
def _nw_data_patched(self) -> DataFrame[IntoDataFrameT]:
"""
Reactive calculation of the data frame's patched data.

Expand All @@ -309,6 +332,18 @@ def _data_patched(self) -> DataFrame[IntoDataFrameT]:
"""
return apply_frame_patches(self._nw_data(), self.cell_patches())

@reactive_calc_method
def data_patched(self) -> IntoDataFrameT:
"""
Get the patched data frame.

Returns
-------
:
The patched data frame.
"""
return self._nw_data_to_original_type(self._nw_data_patched())

# Apply filtering and sorting
# https://github.com/posit-dev/py-shiny/issues/1240
def _subset_data_view(self, selected: bool) -> IntoDataFrameT:
Expand Down Expand Up @@ -347,7 +382,7 @@ def _subset_data_view(self, selected: bool) -> IntoDataFrameT:
else:
rows = self.data_view_rows()

nw_data = subset_frame(self._data_patched(), rows=rows)
nw_data = subset_frame(self._nw_data_patched(), rows=rows)

patched_subsetted_into_data = self._nw_data_to_original_type(nw_data)

Expand Down Expand Up @@ -718,10 +753,10 @@ def _set_cell_patch_map_patches(

assert isinstance(
row_index, int
), f"Expected `row_index` to be an `int`, got {type(row_index)}"
), f"Expected `row_index` to be an `int`, received {type(row_index)}"
assert isinstance(
column_index, int
), f"Expected `column_index` to be an `int`, got {type(column_index)}"
), f"Expected `column_index` to be an `int`, received {type(column_index)}"

# TODO-render.data_frame; Possibly check for cell type and compare against type hints
# TODO-render.data_frame; The `value` should be coerced by pandas to the correct type
Expand Down Expand Up @@ -766,7 +801,7 @@ async def _attempt_update_cell_style(self) -> None:
if not callable(styles_fn):
return

patched_into_data = self._nw_data_to_original_type(self._data_patched())
patched_into_data = self._nw_data_to_original_type(self._nw_data_patched())
new_styles = as_browser_style_infos(styles_fn, into_data=patched_into_data)

await self._send_message_to_browser(
Expand Down Expand Up @@ -798,19 +833,31 @@ async def update_cell_value(
if isinstance(col, str):
with reactive.isolate():
column_names = self._nw_data().columns
assert col in column_names, f"Column '{col}' not found in data frame."
if col not in column_names:
raise ValueError(f"Column '{col}' not found in data frame.")
column_index = self._nw_data().columns.index(col)
else:
column_index = col
assert isinstance(
column_index, int
), f"Expected `column_index` to be an `int`, got {type(column_index)}"
assert (
column_index >= 0
), f"Expected `column_index` to be greater than or equal to 0, got {column_index}"
if (not isinstance(col, int)) or isinstance(col, bool):
raise TypeError(
f"Expected `col` to be an `int` or `str, received {type(column_index)}"
)
if column_index < 0:
raise ValueError(
f"Expected `col` to be greater than or equal to 0, received {column_index}"
)
column_length = self._nw_data().shape[1]
if column_index >= column_length:
raise ValueError(
f"Expected `col` to be less than {column_length}, received {column_index}"
)

assert isinstance(row, int), f"Expected `row` to be an `int`, got {type(row)}"
assert row >= 0, f"Expected `row` to be greater than or equal to 0, got {row}"
if (not isinstance(row, int)) or isinstance(row, bool):
raise TypeError(f"Expected `row` to be an `int`, received {type(row)}")
if row < 0:
raise ValueError(
f"Expected `row` to be greater than or equal to 0, received {row}"
)

patch: CellPatch = {
"value": value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,51 @@
from __future__ import annotations

import palmerpenguins # pyright: ignore[reportMissingTypeStubs]
import polars as pl

from shiny import reactive
from shiny.express import input, render, ui

pd_df = palmerpenguins.load_penguins_raw().iloc[0:2, 0:2]
pd_data = palmerpenguins.load_penguins_raw().iloc[0:2, 0:2]
pl_data = pl.DataFrame(pd_data)

with ui.card():
with ui.layout_column_wrap(width=1 / 2):

ui.input_action_button("update_btn", "Update cell")
with ui.card():

@render.data_frame
def dt():
return pd_df
ui.h3("Pandas")

ui.input_action_button("update_pd_btn", "Update cell")

@reactive.effect
@reactive.event(input.update_btn)
async def update_cell():
@render.data_frame
def pd_df():
return pd_data

await dt.update_cell_value(
"new_value - " + str(input.update_btn()),
row=0,
col=0,
)
@reactive.effect
@reactive.event(input.update_pd_btn)
async def _():

await pd_df.update_cell_value(
"pandas - " + str(input.update_pd_btn()),
row=0,
col=0,
)

with ui.card():

ui.h3("Polars")
ui.input_action_button("update_pl_btn", "Update cell")

@render.data_frame
def pl_df():
return pl_data

@reactive.effect
@reactive.event(input.update_pl_btn)
async def _():

await pl_df.update_cell_value(
"polars - " + str(input.update_pl_btn()),
row=0,
col=0,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

from playwright.sync_api import Page

from shiny.playwright import controller
from shiny.run import ShinyAppProc


def test_update_cell_value(page: Page, local_app: ShinyAppProc) -> None:
page.goto(local_app.url)

pd_df = controller.OutputDataFrame(page, "pd_df")
pd_df.expect_cell("PAL0708", row=0, col=0)

pl_df = controller.OutputDataFrame(page, "pl_df")
pl_df.expect_cell("PAL0708", row=0, col=0)

pd_btn = controller.InputActionButton(page, "update_pd_btn")
pl_btn = controller.InputActionButton(page, "update_pl_btn")

# Update pandas
pd_btn.click()
pd_df.expect_cell("pandas - 1", row=0, col=0)
pl_df.expect_cell("PAL0708", row=0, col=0)

# Update polars
pl_btn.click()
pl_btn.click()
pl_btn.click()

pd_df.expect_cell("pandas - 1", row=0, col=0)
pl_df.expect_cell("polars - 3", row=0, col=0)

# Verify other cells do not change
pd_df.expect_cell("PAL0708", row=1, col=0)
pd_df.expect_cell("1", row=0, col=1)
pd_df.expect_cell("2", row=1, col=1)

pl_df.expect_cell("PAL0708", row=1, col=0)
pl_df.expect_cell("1", row=0, col=1)
pl_df.expect_cell("2", row=1, col=1)
37 changes: 16 additions & 21 deletions tests/playwright/shiny/components/data_frame/update_data/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from shiny import reactive
from shiny.express import input, render, ui

pd_df = palmerpenguins.load_penguins_raw()
pd_data = palmerpenguins.load_penguins_raw()


with ui.card():
Expand All @@ -19,9 +19,9 @@
ui.h4("Data")

@render.data_frame
def dt():
def df():
return render.DataGrid(
pd_df.iloc[:, 0:2],
pd_data.iloc[:, 0:2],
selection_mode="rows",
filters=True,
editable=True,
Expand All @@ -30,17 +30,12 @@ def dt():
ui.h4("Selected data")

@render.data_frame
def dt_selected():
return dt.data_view(selected=True)

@reactive.effect
@reactive.event(dt.cell_selection)
def _on_cell_selection():
print("Cell selected", dt.cell_selection())
return
def df_selected():
return df.data_view(selected=True)


data_val = reactive.value(pd_df)
# Reactive value to store the un-subsetted data
full_data = reactive.value(pd_data)


@reactive.effect
Expand All @@ -51,8 +46,9 @@ async def shift_data():
raise ValueError("update_btn must exist")

k = 2
shift = (k * input.shift_btn()) % data_val().shape[0]
await dt.update_data(data_val().iloc[(0 + shift) : (k + shift), 0:2])
shift = (k * input.shift_btn()) % full_data().shape[0]
subsetted_data = full_data().iloc[(0 + shift) : (k + shift), 0:2]
await df.update_data(subsetted_data)
return


Expand All @@ -67,14 +63,13 @@ async def different_data():
"input.different_btn()", input.different_btn(), input.different_btn() % 2 == 0
)
if input.different_btn() % 2 == 0:
# await dt.update_data(pd_df.iloc[:, 0:2])
await dt.update_data(pd_df)
data_val.set(pd_df)
await df.update_data(pd_data)
full_data.set(pd_data)
return

new_df = pd.DataFrame(
{
"studyName": [
"Letter": [
"a",
"b",
"c",
Expand Down Expand Up @@ -102,9 +97,9 @@ async def different_data():
"y",
"z",
],
"Sample Number": [-1 * i for i in range(1, 27)],
"Negative index": [-1 * i for i in range(1, 27)],
},
)
await dt.update_data(new_df)
data_val.set(new_df)
await df.update_data(new_df)
full_data.set(new_df)
return
Loading
Loading