Skip to content

Commit

Permalink
test(controllers): Refactor column sort and filter methods for Datafr…
Browse files Browse the repository at this point in the history
…ame class (#1496)

Co-authored-by: Barret Schloerke <barret@posit.co>
  • Loading branch information
karangattu and schloerke committed Jul 5, 2024
1 parent 46f7ee4 commit a00a350
Show file tree
Hide file tree
Showing 15 changed files with 256 additions and 114 deletions.
8 changes: 4 additions & 4 deletions js/data-frame/cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,11 +434,13 @@ export const TableBodyCell: FC<TableBodyCellProps> = ({
// style={{ width: "100%", height: "100%" }}
/>
);
} else if (isHtmlColumn) {
addToTableCellClass("cell-html");
} else {
// `isEditing` is `false`
// `isEditing` is `false` and not an HTML column, so we can allow for double clicks to go into edit mode

// Only allow transition to edit mode if the cell can be edited
if (editCellsIsAllowed && !isHtmlColumn) {
if (editCellsIsAllowed) {
addToTableCellClass("cell-editable");
onCellDoubleClick = (e: ReactMouseEvent<HTMLTableCellElement>) => {
// Do not prevent default or stop propagation here!
Expand All @@ -452,8 +454,6 @@ export const TableBodyCell: FC<TableBodyCellProps> = ({
obj_draft.editValue = getCellValueText(cellValue) as string;
});
};
} else {
addToTableCellClass("cell-html");
}
}
if (isShinyHtml(cellValue)) {
Expand Down
4 changes: 3 additions & 1 deletion js/data-frame/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
* Determines if the user is allowed to edit cells in the table.
*/
const editCellsIsAllowed = payloadOptions["editable"] === true;
("Barret");

/**
* Determines if any cell is currently being edited
Expand Down Expand Up @@ -711,6 +710,9 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
tabIndex={0}
onClick={header.column.getToggleSortingHandler()}
onKeyDown={makeHeaderKeyDown(header.column)}
className={
header.column.getCanSort() ? undefined : "header-html"
}
>
{headerContent}
</th>
Expand Down
12 changes: 9 additions & 3 deletions js/data-frame/sort-arrows.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { SortDirection } from "@tanstack/react-table";
import React, { FC } from "react";

const sortClassName = "sort-arrow";
const sortCommonProps = {
className: "sort-arrow",
viewBox: [-1, -1, 2, 2].map((x) => x * 1.4).join(" "),
width: "100%",
height: "100%",
Expand All @@ -16,7 +16,10 @@ const sortPathCommonProps = {
};

const sortArrowUp = (
<svg xmlns="http://www.w3.org/2000/svg" {...sortCommonProps}>
<svg
xmlns="http://www.w3.org/2000/svg"
{...{ ...sortCommonProps, className: `${sortClassName} sort-arrow-up` }}
>
<path
d="M -1 0.5 L 0 -0.5 L 1 0.5"
{...sortPathCommonProps}
Expand All @@ -26,7 +29,10 @@ const sortArrowUp = (
);

const sortArrowDown = (
<svg xmlns="http://www.w3.org/2000/svg" {...sortCommonProps}>
<svg
xmlns="http://www.w3.org/2000/svg"
{...{ ...sortCommonProps, className: `${sortClassName} sort-arrow-down` }}
>
<path
d="M -1 -0.5 L 0 0.5 L 1 -0.5"
{...sortPathCommonProps}
Expand Down
210 changes: 151 additions & 59 deletions shiny/playwright/controller/_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from playwright.sync_api import FilePayload, FloatRect, Locator, Page, Position
from playwright.sync_api import expect as playwright_expect

from shiny.render._data_frame import ColumnFilter, ColumnSort

# Import `shiny`'s typing extentions.
# Since this is a private file, tell pyright to ignore the import
from ..._typing_extensions import TypeGuard, assert_type
Expand Down Expand Up @@ -4302,34 +4304,25 @@ class _CardValueBoxFullScreenM:
Represents a class for managing full screen functionality of a Card or Value Box.
"""

# TODO-karan-test: Convert `open_full_screen` and `close_full_screen` to `set_full_screen(open:bool)`
def open_full_screen(
self: _CardValueBoxFullScreenLayoutP, *, timeout: Timeout = None
) -> None:
"""
Opens the element in full screen mode.
Parameters
----------
timeout
The maximum time to wait for full screen mode to open. Defaults to `None`.
"""
self.loc_title.hover(timeout=timeout)
self._loc_fullscreen.wait_for(state="visible", timeout=timeout)
self._loc_fullscreen.click(timeout=timeout)

def close_full_screen(
self: _CardValueBoxFullScreenLayoutP, *, timeout: Timeout = None
def set_full_screen(
self: _CardValueBoxFullScreenLayoutP, open: bool, *, timeout: Timeout = None
) -> None:
"""
Exits full screen mode.
Sets the element to full screen mode or exits full screen mode.
Parameters
----------
open
`True` to open the element in full screen mode, `False` to exit full screen mode.
timeout
The maximum time to wait to wait for full screen mode to exit. Defaults to `None`.
The maximum time to wait for the operation to complete. Defaults to `None`.
"""
self._loc_close_button.click(timeout=timeout)
if open:
self.loc_title.hover(timeout=timeout)
self._loc_fullscreen.wait_for(state="visible", timeout=timeout)
self._loc_fullscreen.click(timeout=timeout)
else:
self._loc_close_button.click(timeout=timeout)

def expect_full_screen(
self: _CardValueBoxFullScreenLayoutP, value: bool, *, timeout: Timeout = None
Expand Down Expand Up @@ -6269,68 +6262,167 @@ def edit_cell(
cell.dblclick(timeout=timeout)
cell.locator("> textarea").fill(text)

# TODO-karan-test: Rename to `set_sort?`
# TODO-karan-test: Add support for a list of columns
# TODO-karan-test: Add support for direction
# TODO-karan-test: Add method for testing direction
def set_column_sort(
def set_sort(
self,
col: int,
sort: int | ColumnSort | list[int | ColumnSort] | None,
*,
timeout: Timeout = None,
) -> None:
):
"""
Sorts the column in the data frame.
Set or modify the sorting of columns in a table or grid component.
This method allows setting single or multiple column sorts, or resetting the sort order.
Parameters
----------
col
The column number to sort.
sort
The sorting configuration to apply. Can be one of the following:
int: Index of the column to sort by (ascending order by default).
ColumnSort: A dictionary specifying a single column sort with 'col' and 'desc' keys.
list[ColumnSort]: A list of dictionaries for multi-column sorting.
None: No sorting applied (not implemented in the current code).
timeout
The maximum time to wait for the action to complete. Defaults to `None`.
"""
self.loc_column_label.nth(col).click(timeout=timeout)

# TODO-karan-test: Rename to `set_filter?`
def click_loc(loc: Locator, *, shift: bool = False):
clickModifier: list[Literal["Shift"]] | None = (
["Shift"] if bool(shift) else None
)
loc.click(
timeout=timeout,
modifiers=clickModifier,
)
# Wait for arrows to react a little bit
# This could possible be changed to a `wait_for_change`, but 150ms should be fine
self.page.wait_for_timeout(150)

# Reset arrow sorting by clicking on the arrows until none are found
sortingArrows = self.loc_column_label.locator("svg.sort-arrow")
while sortingArrows.count() > 0:
click_loc(sortingArrows.first)

# Quit early if no sorting is needed
if sort is None:
return

if isinstance(sort, int) | isinstance(sort, dict) and not isinstance(
sort, list
):
sort = [sort]

if not isinstance(sort, list):
raise ValueError(
"Invalid sort value. Must be an int, ColumnSort, list[ColumnSort], or None."
)

# For every sorting info...
for sort_info, i in zip(sort, range(len(sort))):
# TODO-barret-future; assert column does not have `cell-html` class
shift = i > 0

if isinstance(sort_info, int):
sort_info = {"col": sort_info}

# Verify ColumnSortInfo
assert isinstance(
sort_info, dict
), f"Invalid sort value at position {i}. Must be an int, ColumnSort, list[ColumnSort], or None."
assert (
"col" in sort_info
), f"Column index (`col`) at position {i} is required for sorting."

sort_col = self.loc_column_label.nth(sort_info["col"])
expect_not_to_have_class(sort_col, "header-html")

# If no `desc` key is found, click the column to sort and move on
if "desc" not in sort_info:
click_loc(sort_col, shift=shift)
continue

# "desc" in sort_info
desc_val = bool(sort_info["desc"])
sort_col.scroll_into_view_if_needed()
for _ in range(2):
if desc_val:
# If a descending is found, stop clicking
if sort_col.locator("svg.sort-arrow-down").count() > 0:
break
else:
# If a ascending is found, stop clicking
if sort_col.locator("svg.sort-arrow-up").count() > 0:
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
# TODO-karan-test: Add support for a None value reset all filters
# TODO-karan-test: Add method for testing direction
def set_column_filter(
def set_filter(
self,
col: int,
# TODO-barret support array of filters
filter: ColumnFilter | list[ColumnFilter] | None,
*,
text: str | list[str] | tuple[str, str],
timeout: Timeout = None,
) -> None:
):
"""
Filters the column in the data frame.
Set or reset filters for columns in a table or grid component.
This method allows setting string filters, numeric range filters, or clearing all filters.
Parameters
----------
col
The column number to filter.
text
The text to filter the column.
filter
The filter to apply. Can be one of the following:
None: Resets all filters.
str: A string filter (deprecated, use ColumnFilterStr instead).
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.
timeout
The maximum time to wait for the action to complete. Defaults to `None`.
"""
if isinstance(text, str):
self.loc_column_filter.nth(col).locator("> input").fill(
text,
timeout=timeout,
)
else:
assert len(text) == 2
header_inputs = self.loc_column_filter.nth(col).locator("> div > input")
header_inputs.nth(0).fill(
text[0],
timeout=timeout,
)
header_inputs.nth(1).fill(
text[1],
timeout=timeout,
# reset all filters
all_input_handles = self.loc_column_filter.locator(
"> input, > div > input"
).element_handles()
for input_handle in all_input_handles:
input_handle.scroll_into_view_if_needed()
input_handle.fill("", timeout=timeout)

if filter is None:
return

if isinstance(filter, dict):
filter = [filter]

if not isinstance(filter, list):
raise ValueError(
"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.")

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

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

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,
)
else:
raise ValueError(
"Invalid filter value. Must be a string or a tuple/list of two numbers."
)

def save_cell(
self,
text: str,
Expand Down
8 changes: 4 additions & 4 deletions shiny/www/shared/py-shiny/data-frame/data-frame.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions shiny/www/shared/py-shiny/data-frame/data-frame.js.map

Large diffs are not rendered by default.

Loading

0 comments on commit a00a350

Please sign in to comment.