Skip to content

feat(data frame): Add .data_view_rows(), .sort(), .filter(), .update_sort(), and .update_filter(); cell_selection() no longer returns None #1374

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 41 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5892661
Only subset the selected data if the index row exists!
schloerke May 10, 2024
7ef4f84
Update CHANGELOG.md
schloerke May 10, 2024
c8ed2c3
Make sure the index being subsetted is less than the row length, not …
schloerke May 13, 2024
437793a
Restore `input.<ID>_selected_rows()`. Add tests
schloerke May 13, 2024
0c39dd1
Update _renderer.py
schloerke May 13, 2024
e023993
Merge branch 'main' into render_df_bugs
schloerke May 13, 2024
c0a4253
Expose `.data_view_info()` for `@render.data_frame` obj
schloerke May 13, 2024
d6c0d39
Update CHANGELOG.md
schloerke May 13, 2024
fab4d8c
Merge branch 'render_df_bugs' into data_view_meta
schloerke May 13, 2024
f9a322e
Update CHANGELOG.md
schloerke May 13, 2024
102079c
Fix lints; Update Changelog
schloerke May 14, 2024
6b5bd52
Update _data_frame.py
schloerke May 14, 2024
43a5168
Apply suggestions from code review
schloerke May 14, 2024
31c4571
Merge branch 'main' into data_view_meta
schloerke May 15, 2024
34029f1
Have `.data_view()` use `.data_view_info()` information for consisten…
schloerke May 15, 2024
7da5921
Lints
schloerke May 15, 2024
0b03498
`ColumnFilter` and `ColumnSort` should use `col: num` and not `id: st…
schloerke May 15, 2024
bc10e8b
Add `update_sort()` and `update_filter()` to DF
schloerke May 20, 2024
46ad96b
Add demo apps for `update_sort()` and `update_filter()`
schloerke May 20, 2024
86b5e42
Merge branch 'main' into data_view_meta
schloerke May 20, 2024
5799782
Add `.data_view_rows()`
schloerke May 21, 2024
d48bdc7
Remove `.data_view_info()` method
schloerke May 21, 2024
f71e8b6
Update controls.py
schloerke May 21, 2024
237174f
Add type support for `serialize_numpy_dtype()`; Determine default sor…
schloerke May 21, 2024
db3ebcb
`.input_column_sort()` -> `.input_sort()`; `.input_column_filter()` -…
schloerke May 21, 2024
1a07fe0
feat(df): Make `.input_cell_selection()` return a consistent type sha…
schloerke May 21, 2024
16cd1ba
Merge branch 'main' into data_view_meta
schloerke May 21, 2024
435b916
tmp
schloerke May 21, 2024
f9158c4
merge main
schloerke May 29, 2024
f0c36a1
Clean up input_cell_selection method renaming
schloerke May 30, 2024
fcfb740
Make legacy `.input_cell_selection()` return `None` if `type=="none"`
schloerke May 30, 2024
67423e4
consolidate changelog changes before merging from main
schloerke May 30, 2024
45f92f0
Add `playwright` target and use `TEST_FILE` arg that contains full re…
schloerke May 30, 2024
0ee9c9c
Merge branch 'main' into data_view_meta
schloerke May 30, 2024
d5c2406
Spelling
schloerke May 30, 2024
f945b0d
Merge branch 'main' into data_view_meta
schloerke May 31, 2024
5f44e73
Update imports
schloerke Jun 3, 2024
52cacba
Merge branch 'main' into data_view_meta
schloerke Jun 3, 2024
cb04a14
Update _unsafe.py
schloerke Jun 3, 2024
c765781
Code review
schloerke Jun 3, 2024
a05b8ff
`input_sort` -> `sort`; `input_filter` -> `filter`
schloerke Jun 3, 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
24 changes: 20 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,30 @@ 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]
## [UNRELEASED]

### Breaking Changes
### Deprecations

* `@render.data_frame`'s `.cell_selection()` will no longer return `None` when the selection mode is `"none"`. In addition, missing `rows` or `cols` information will be populated with appropiate values. This allows for consistent handling of the cell selection object. (#1374)

* `@render.data_frame`'s input value `input.<ID>_data_view_indices()` has been deprecated. Please use `<ID>.data_view_rows()` to retrieve the same information. (#1377)

* `@render.data_frame`'s input value `input.<ID>_column_sort()` has been deprecated. Please use `<ID>.sort()` to retrieve the same information. (#1374)

* `@render.data_frame`'s input value `input.<ID>_column_filter()` has been deprecated. Please use `<ID>.filter()` to retrieve the same information. (#1374)

### New features

* `@render.data_frame` has added a few new methods:
* `.data_view_rows()` is a reactive value representing the sorted and filtered row numbers. This value wraps `input.<ID>_data_view_rows()`(#1374)
* `.sort()` is a reactive value representing the sorted column information (dictionaries containing `col: int` and `desc: bool`). This value wraps `input.<ID>_sort()`. (#1374)
* `.filter()` is a reactive value representing the filtered column information (dictionaries containing `col: int` and `value` which is either a string or a length 2 array of at least one non-`None` number). This value wraps `input.<ID>_filter()`. (#1374)
* `.update_sort(sort=)` allows app authors to programmatically update the sorting of the data frame. (#1374)
* `.update_filter(filter=)` allows app authors to programmatically update the filtering of the data frame. (#1374)

* `@render.data_frame`'s `<ID>.cell_selection()` no longer returns a `None` value and now always returns a dictionary containing both the `rows` and `cols` keys. This is done to achieve more consistent author code when working with cell selection. When the value's `type="none"`, both `rows` and `cols` are empty tuples. When `type="row"`, `cols` represents all column numbers of the data. In the future, when `type="col"`, `rows` will represent all row numbers of the data. These extra values are not available in `input.<ID>_cell_selection()` as they are independent of cells being selected and are removed to reduce information being sent to and from the browser. (#1376)


### Bug fixes

* Fixed #1440: When a Shiny Express app with a `www/` subdirectory was deployed to shinyapps.io or a Connect server, it would not start correctly. (#1442)
Expand All @@ -37,8 +55,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* `@render.data_frame`'s method `.input_cell_selection()` has been renamed to `.cell_selection()`. Please use `.cell_selection()` and consider `.input_cell_selection()` deprecated. (#1407)

* `@render.data_frame`'s input value `input.<ID>_data_view_indices` has been renamed to `input.<ID>_data_view_rows` for consistent naming. Please use `input.<ID>_data_view_rows` and consider `input.<ID>_data_view_indices` deprecated. (#1377)

### New features

* Added busy indicators to provide users with a visual cue when the server is busy calculating outputs or otherwise serving requests to the client. More specifically, a spinner is shown on each calculating/recalculating output, and a pulsing banner is shown at the top of the page when the app is otherwise busy. Use the new `ui.busy_indicator.options()` function to customize the appearance of the busy indicators and `ui.busy_indicator.use()` to disable/enable them. (#918)
Expand Down
30 changes: 18 additions & 12 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -158,23 +158,29 @@ install-trcli: FORCE
install-rsconnect: FORCE
pip install git+https://github.com/rstudio/rsconnect-python.git#egg=rsconnect-python

# Full test path to playwright tests
TEST_FILE:=tests/playwright/$(SUB_FILE)
# All end-to-end tests with playwright
playwright: install-playwright ## All end-to-end tests with playwright; (TEST_FILE="" from root of repo)
pytest $(TEST_FILE) $(PYTEST_BROWSERS)

playwright-debug: install-playwright ## All end-to-end tests, chrome only, headed; (TEST_FILE="" from root of repo)
pytest -c tests/playwright/playwright-pytest.ini $(TEST_FILE)

playwright-show-trace: ## Show trace of failed tests
npx playwright show-trace test-results/*/trace.zip

# end-to-end tests with playwright; (SUB_FILE="" within tests/playwright/shiny/)
playwright-shiny: install-playwright
pytest tests/playwright/shiny/$(SUB_FILE) $(PYTEST_BROWSERS)
playwright-shiny: FORCE
$(MAKE) playwright TEST_FILE=tests/playwright/shiny/$(SUB_FILE)

# end-to-end tests on deployed apps with playwright; (SUB_FILE="" within tests/playwright/deploys/)
playwright-deploys: install-playwright install-rsconnect
pytest tests/playwright/deploys/$(SUB_FILE) $(PYTEST_DEPLOYS_BROWSERS)
playwright-deploys: install-rsconnect
$(MAKE) playwright TEST_FILE=tests/playwright/deploys/$(SUB_FILE) PYTEST_BROWSERS="$(PYTEST_DEPLOYS_BROWSERS)"

# end-to-end tests on all py-shiny examples with playwright; (SUB_FILE="" within tests/playwright/examples/)
playwright-examples: install-playwright
pytest tests/playwright/examples/$(SUB_FILE) $(PYTEST_BROWSERS)

playwright-debug: install-playwright ## All end-to-end tests, chrome only, headed; (SUB_FILE="" within tests/playwright/)
pytest -c tests/playwright/playwright-pytest.ini tests/playwright/$(SUB_FILE)

playwright-show-trace: ## Show trace of failed tests
npx playwright show-trace test-results/*/trace.zip
playwright-examples: FORCE
$(MAKE) playwright TEST_FILE=tests/playwright/examples/$(SUB_FILE)

# end-to-end tests with playwright and generate junit report
testrail-junit: install-playwright install-trcli
Expand Down
3 changes: 1 addition & 2 deletions examples/dataframe/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,8 @@ def handle_edit():

@render.code
def detail():
selected_rows = (grid.cell_selection() or {}).get("rows", ())
selected_rows = grid.cell_selection()["rows"]
if len(selected_rows) > 0:
# "split", "records", "index", "columns", "values", "table"
return df().iloc[list(selected_rows)]


Expand Down
13 changes: 10 additions & 3 deletions js/data-frame/filter-numeric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ interface FilterNumericImplProps {
const FilterNumericImpl: React.FC<FilterNumericImplProps> = (props) => {
const [min, max] = props.value;
const { editing, onFocus } = props;
const [rangeMin, rangeMax] = props.range();

const minInputRef = useRef<HTMLInputElement>(null);
const maxInputRef = useRef<HTMLInputElement>(null);
Expand All @@ -77,11 +78,14 @@ const FilterNumericImpl: React.FC<FilterNumericImplProps> = (props) => {
}`}
style={{ flex: "1 1 0", width: "0" }}
type="number"
placeholder={createPlaceholder(editing, "Min", props.range()[0])}
placeholder={createPlaceholder(editing, "Min", rangeMin)}
defaultValue={min}
// min={rangeMin}
// max={rangeMax}
step="any"
onChange={(e) => {
const value = coerceToNum(e.target.value);
if (!minInputRef.current) return;
minInputRef.current.classList.toggle(
"is-invalid",
!e.target.checkValidity()
Expand All @@ -96,11 +100,14 @@ const FilterNumericImpl: React.FC<FilterNumericImplProps> = (props) => {
}`}
style={{ flex: "1 1 0", width: "0" }}
type="number"
placeholder={createPlaceholder(editing, "Max", props.range()[1])}
placeholder={createPlaceholder(editing, "Max", rangeMax)}
defaultValue={max}
// min={rangeMin}
// max={rangeMax}
step="any"
onChange={(e) => {
const value = coerceToNum(e.target.value);
if (!maxInputRef.current) return;
maxInputRef.current.classList.toggle(
"is-invalid",
!e.target.checkValidity()
Expand All @@ -118,7 +125,7 @@ function createPlaceholder(
value: number | undefined
) {
if (!editing) {
return null;
return undefined;
} else if (typeof value === "undefined") {
return label;
} else {
Expand Down
23 changes: 18 additions & 5 deletions js/data-frame/filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ import React, {
} from "react";
import { FilterNumeric } from "./filter-numeric";

type FilterValueString = string;
type FilterValueNumeric =
| [number, number]
| [number | undefined, number]
| [number, number | undefined];
type FilterValue = FilterValueString | FilterValueNumeric;

export type { ColumnFiltersState, FilterValue };

export function useFilters<TData>(enabled: boolean | undefined): {
columnFilters: ColumnFiltersState;
setColumnFilters: React.Dispatch<React.SetStateAction<ColumnFiltersState>>;
Expand Down Expand Up @@ -59,13 +68,16 @@ export interface FilterProps
export const Filter: FC<FilterProps> = ({ header, className, ...props }) => {
const typeHint = header.column.columnDef.meta?.typeHint;

if (typeHint.type === "html") {
// Do not filter on html types
return null;
}
// Do not filter on unknown types
if (!typeHint) return null;

// Do not filter on html types
if (typeHint.type === "html") return null;

if (typeHint.type === "numeric") {
const [from, to] = (header.column.getFilterValue() as
| [number | undefined, number | undefined]
| FilterValueNumeric
| [undefined, undefined]
| undefined) ?? [undefined, undefined];

const range = () => {
Expand All @@ -83,6 +95,7 @@ export const Filter: FC<FilterProps> = ({ header, className, ...props }) => {
return (
<input
{...props}
value={header.column.getFilterValue() as string}
className={`form-control form-control-sm ${className}`}
type="text"
onChange={(e) => header.column.setFilterValue(e.target.value)}
Expand Down
137 changes: 116 additions & 21 deletions js/data-frame/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
ColumnDef,
RowData,
RowModel,
SortingState,
TableOptions,
flexRender,
getCoreRowModel,
Expand All @@ -29,14 +28,14 @@ import { useImmer } from "use-immer";
import { TableBodyCell } from "./cell";
import { getCellEditMapObj, useCellEditMap } from "./cell-edit-map";
import { findFirstItemInView, getStyle } from "./dom-utils";
import { Filter, useFilters } from "./filter";
import { ColumnFiltersState, Filter, FilterValue, useFilters } from "./filter";
import type { CellSelection, SelectionModesProp } from "./selection";
import {
SelectionModes,
initRowSelectionModes,
useSelection,
} from "./selection";
import { useSort } from "./sort";
import { SortingState, useSort } from "./sort";
import { SortArrow } from "./sort-arrows";
import css from "./styles.scss";
import { useTabindexGroup } from "./tabindex-group";
Expand Down Expand Up @@ -175,10 +174,14 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
const dataOriginal = useMemo(() => rowData, [rowData]);
const [dataState, setData] = useImmer(rowData);

const { sorting, sortState, sortingTableOptions } = useSort();
const { sorting, sortState, sortingTableOptions, setSorting } = useSort();

const { columnFilters, columnFiltersState, filtersTableOptions } =
useFilters<unknown[]>(withFilters);
const {
columnFilters,
columnFiltersState,
filtersTableOptions,
setColumnFilters,
} = useFilters<unknown[]>(withFilters);

const options: TableOptions<unknown[]> = {
data: dataState,
Expand Down Expand Up @@ -278,7 +281,7 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
);

useEffect(() => {
const handleMessage = (
const handleCellSelection = (
event: CustomEvent<{ cellSelection: CellSelection }>
) => {
// We convert "None" to an empty tuple on the python side
Expand Down Expand Up @@ -307,17 +310,85 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({

element.addEventListener(
"updateCellSelection",
handleMessage as EventListener
handleCellSelection as EventListener
);

return () => {
element.removeEventListener(
"updateCellSelection",
handleMessage as EventListener
handleCellSelection as EventListener
);
};
}, [id, rowSelection, rowData]);

useEffect(() => {
const handleColumnSort = (
event: CustomEvent<{ sort: { col: number; desc: boolean }[] }>
) => {
const shinySorting = event.detail.sort;
const columnSorting: SortingState = [];

shinySorting.map((sort) => {
columnSorting.push({
id: columns[sort.col],
desc: sort.desc,
});
});
setSorting(columnSorting);
};

if (!id) return;

const element = document.getElementById(id);
if (!element) return;

element.addEventListener(
"updateColumnSort",
handleColumnSort as EventListener
);

return () => {
element.removeEventListener(
"updateColumnSort",
handleColumnSort as EventListener
);
};
}, [columns, id, setSorting]);

useEffect(() => {
const handleColumnFilter = (
event: CustomEvent<{ filter: { col: number; value: FilterValue }[] }>
) => {
const shinyFilters = event.detail.filter;

const columnFilters: ColumnFiltersState = [];
shinyFilters.map((filter) => {
columnFilters.push({
id: columns[filter.col],
value: filter.value,
});
});
setColumnFilters(columnFilters);
};

if (!id) return;

const element = document.getElementById(id);
if (!element) return;

element.addEventListener(
"updateColumnFilter",
handleColumnFilter as EventListener
);

return () => {
element.removeEventListener(
"updateColumnFilter",
handleColumnFilter as EventListener
);
};
}, [columns, id, setColumnFilters]);

useEffect(() => {
if (!id) return;
let shinyValue: CellSelection | null = null;
Expand All @@ -335,8 +406,7 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
}
return rowsById[key].index;
})
.filter((x): x is number => x !== null)
.sort(),
.filter((x): x is number => x !== null),
};
} else {
console.error("Unhandled row selection mode:", rowSelectionModes);
Expand All @@ -346,23 +416,48 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({

useEffect(() => {
if (!id) return;
Shiny.setInputValue!(`${id}_column_sort`, sorting);
}, [id, sorting]);
const shinySort: { col: number; desc: boolean }[] = [];
sorting.map((sortObj) => {
const columnNum = columns.indexOf(sortObj.id);
shinySort.push({
col: columnNum,
desc: sortObj.desc,
});
});
Shiny.setInputValue!(`${id}_sort`, shinySort);

// Deprecated as of 2024-05-21
Shiny.setInputValue!(`${id}_column_sort`, shinySort);
}, [columns, id, sorting]);
useEffect(() => {
if (!id) return;
Shiny.setInputValue!(`${id}_column_filter`, columnFilters);
}, [id, columnFilters]);
const shinyFilter: {
col: number;
value: FilterValue;
}[] = [];
columnFilters.map((filterObj) => {
const columnNum = columns.indexOf(filterObj.id);
shinyFilter.push({
col: columnNum,
value: filterObj.value as FilterValue,
});
});
Shiny.setInputValue!(`${id}_filter`, shinyFilter);

// Deprecated as of 2024-05-21
Shiny.setInputValue!(`${id}_column_filter`, shinyFilter);
}, [id, columnFilters, columns]);
useEffect(() => {
if (!id) return;

// Already prefiltered rows!
const shinyValue: RowModel<unknown[]> = table.getSortedRowModel();

const rowIndices = table.getSortedRowModel().rows.map((row) => row.index);
Shiny.setInputValue!(`${id}_data_view_rows`, rowIndices);
const shinyRows: number[] = table
// Already prefiltered rows!
.getSortedRowModel()
.rows.map((row) => row.index);
Shiny.setInputValue!(`${id}_data_view_rows`, shinyRows);

// Legacy value as of 2024-05-13
Shiny.setInputValue!(`${id}_data_view_indices`, rowIndices);
Shiny.setInputValue!(`${id}_data_view_indices`, shinyRows);
}, [
id,
table,
Expand Down
Loading
Loading