Skip to content

Commit a6a83fe

Browse files
authored
feat(data frame): Add .data_view_rows(), .sort(), .filter(), .update_sort(), and .update_filter(); cell_selection() no longer returns None (#1374)
1 parent bd685bb commit a6a83fe

File tree

36 files changed

+1041
-289
lines changed

36 files changed

+1041
-289
lines changed

CHANGELOG.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,30 @@ All notable changes to Shiny for Python will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
### [UNRELEASED]
8+
## [UNRELEASED]
99

10-
### Breaking Changes
10+
### Deprecations
11+
12+
* `@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)
13+
14+
* `@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)
15+
16+
* `@render.data_frame`'s input value `input.<ID>_column_sort()` has been deprecated. Please use `<ID>.sort()` to retrieve the same information. (#1374)
17+
18+
* `@render.data_frame`'s input value `input.<ID>_column_filter()` has been deprecated. Please use `<ID>.filter()` to retrieve the same information. (#1374)
1119

1220
### New features
1321

22+
* `@render.data_frame` has added a few new methods:
23+
* `.data_view_rows()` is a reactive value representing the sorted and filtered row numbers. This value wraps `input.<ID>_data_view_rows()`(#1374)
24+
* `.sort()` is a reactive value representing the sorted column information (dictionaries containing `col: int` and `desc: bool`). This value wraps `input.<ID>_sort()`. (#1374)
25+
* `.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)
26+
* `.update_sort(sort=)` allows app authors to programmatically update the sorting of the data frame. (#1374)
27+
* `.update_filter(filter=)` allows app authors to programmatically update the filtering of the data frame. (#1374)
28+
29+
* `@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)
30+
31+
1432
### Bug fixes
1533

1634
* 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)
@@ -37,8 +55,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3755

3856
* `@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)
3957

40-
* `@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)
41-
4258
### New features
4359

4460
* 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)

Makefile

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -158,23 +158,29 @@ install-trcli: FORCE
158158
install-rsconnect: FORCE
159159
pip install git+https://github.com/rstudio/rsconnect-python.git#egg=rsconnect-python
160160

161+
# Full test path to playwright tests
162+
TEST_FILE:=tests/playwright/$(SUB_FILE)
163+
# All end-to-end tests with playwright
164+
playwright: install-playwright ## All end-to-end tests with playwright; (TEST_FILE="" from root of repo)
165+
pytest $(TEST_FILE) $(PYTEST_BROWSERS)
166+
167+
playwright-debug: install-playwright ## All end-to-end tests, chrome only, headed; (TEST_FILE="" from root of repo)
168+
pytest -c tests/playwright/playwright-pytest.ini $(TEST_FILE)
169+
170+
playwright-show-trace: ## Show trace of failed tests
171+
npx playwright show-trace test-results/*/trace.zip
172+
161173
# end-to-end tests with playwright; (SUB_FILE="" within tests/playwright/shiny/)
162-
playwright-shiny: install-playwright
163-
pytest tests/playwright/shiny/$(SUB_FILE) $(PYTEST_BROWSERS)
174+
playwright-shiny: FORCE
175+
$(MAKE) playwright TEST_FILE=tests/playwright/shiny/$(SUB_FILE)
164176

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

169181
# end-to-end tests on all py-shiny examples with playwright; (SUB_FILE="" within tests/playwright/examples/)
170-
playwright-examples: install-playwright
171-
pytest tests/playwright/examples/$(SUB_FILE) $(PYTEST_BROWSERS)
172-
173-
playwright-debug: install-playwright ## All end-to-end tests, chrome only, headed; (SUB_FILE="" within tests/playwright/)
174-
pytest -c tests/playwright/playwright-pytest.ini tests/playwright/$(SUB_FILE)
175-
176-
playwright-show-trace: ## Show trace of failed tests
177-
npx playwright show-trace test-results/*/trace.zip
182+
playwright-examples: FORCE
183+
$(MAKE) playwright TEST_FILE=tests/playwright/examples/$(SUB_FILE)
178184

179185
# end-to-end tests with playwright and generate junit report
180186
testrail-junit: install-playwright install-trcli

examples/dataframe/app.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,8 @@ def handle_edit():
9999

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

107106

js/data-frame/filter-numeric.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ interface FilterNumericImplProps {
5252
const FilterNumericImpl: React.FC<FilterNumericImplProps> = (props) => {
5353
const [min, max] = props.value;
5454
const { editing, onFocus } = props;
55+
const [rangeMin, rangeMax] = props.range();
5556

5657
const minInputRef = useRef<HTMLInputElement>(null);
5758
const maxInputRef = useRef<HTMLInputElement>(null);
@@ -77,11 +78,14 @@ const FilterNumericImpl: React.FC<FilterNumericImplProps> = (props) => {
7778
}`}
7879
style={{ flex: "1 1 0", width: "0" }}
7980
type="number"
80-
placeholder={createPlaceholder(editing, "Min", props.range()[0])}
81+
placeholder={createPlaceholder(editing, "Min", rangeMin)}
8182
defaultValue={min}
83+
// min={rangeMin}
84+
// max={rangeMax}
8285
step="any"
8386
onChange={(e) => {
8487
const value = coerceToNum(e.target.value);
88+
if (!minInputRef.current) return;
8589
minInputRef.current.classList.toggle(
8690
"is-invalid",
8791
!e.target.checkValidity()
@@ -96,11 +100,14 @@ const FilterNumericImpl: React.FC<FilterNumericImplProps> = (props) => {
96100
}`}
97101
style={{ flex: "1 1 0", width: "0" }}
98102
type="number"
99-
placeholder={createPlaceholder(editing, "Max", props.range()[1])}
103+
placeholder={createPlaceholder(editing, "Max", rangeMax)}
100104
defaultValue={max}
105+
// min={rangeMin}
106+
// max={rangeMax}
101107
step="any"
102108
onChange={(e) => {
103109
const value = coerceToNum(e.target.value);
110+
if (!maxInputRef.current) return;
104111
maxInputRef.current.classList.toggle(
105112
"is-invalid",
106113
!e.target.checkValidity()
@@ -118,7 +125,7 @@ function createPlaceholder(
118125
value: number | undefined
119126
) {
120127
if (!editing) {
121-
return null;
128+
return undefined;
122129
} else if (typeof value === "undefined") {
123130
return label;
124131
} else {

js/data-frame/filter.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ import React, {
1818
} from "react";
1919
import { FilterNumeric } from "./filter-numeric";
2020

21+
type FilterValueString = string;
22+
type FilterValueNumeric =
23+
| [number, number]
24+
| [number | undefined, number]
25+
| [number, number | undefined];
26+
type FilterValue = FilterValueString | FilterValueNumeric;
27+
28+
export type { ColumnFiltersState, FilterValue };
29+
2130
export function useFilters<TData>(enabled: boolean | undefined): {
2231
columnFilters: ColumnFiltersState;
2332
setColumnFilters: React.Dispatch<React.SetStateAction<ColumnFiltersState>>;
@@ -59,13 +68,16 @@ export interface FilterProps
5968
export const Filter: FC<FilterProps> = ({ header, className, ...props }) => {
6069
const typeHint = header.column.columnDef.meta?.typeHint;
6170

62-
if (typeHint.type === "html") {
63-
// Do not filter on html types
64-
return null;
65-
}
71+
// Do not filter on unknown types
72+
if (!typeHint) return null;
73+
74+
// Do not filter on html types
75+
if (typeHint.type === "html") return null;
76+
6677
if (typeHint.type === "numeric") {
6778
const [from, to] = (header.column.getFilterValue() as
68-
| [number | undefined, number | undefined]
79+
| FilterValueNumeric
80+
| [undefined, undefined]
6981
| undefined) ?? [undefined, undefined];
7082

7183
const range = () => {
@@ -83,6 +95,7 @@ export const Filter: FC<FilterProps> = ({ header, className, ...props }) => {
8395
return (
8496
<input
8597
{...props}
98+
value={header.column.getFilterValue() as string}
8699
className={`form-control form-control-sm ${className}`}
87100
type="text"
88101
onChange={(e) => header.column.setFilterValue(e.target.value)}

js/data-frame/index.tsx

Lines changed: 116 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
ColumnDef,
55
RowData,
66
RowModel,
7-
SortingState,
87
TableOptions,
98
flexRender,
109
getCoreRowModel,
@@ -29,14 +28,14 @@ import { useImmer } from "use-immer";
2928
import { TableBodyCell } from "./cell";
3029
import { getCellEditMapObj, useCellEditMap } from "./cell-edit-map";
3130
import { findFirstItemInView, getStyle } from "./dom-utils";
32-
import { Filter, useFilters } from "./filter";
31+
import { ColumnFiltersState, Filter, FilterValue, useFilters } from "./filter";
3332
import type { CellSelection, SelectionModesProp } from "./selection";
3433
import {
3534
SelectionModes,
3635
initRowSelectionModes,
3736
useSelection,
3837
} from "./selection";
39-
import { useSort } from "./sort";
38+
import { SortingState, useSort } from "./sort";
4039
import { SortArrow } from "./sort-arrows";
4140
import css from "./styles.scss";
4241
import { useTabindexGroup } from "./tabindex-group";
@@ -175,10 +174,14 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
175174
const dataOriginal = useMemo(() => rowData, [rowData]);
176175
const [dataState, setData] = useImmer(rowData);
177176

178-
const { sorting, sortState, sortingTableOptions } = useSort();
177+
const { sorting, sortState, sortingTableOptions, setSorting } = useSort();
179178

180-
const { columnFilters, columnFiltersState, filtersTableOptions } =
181-
useFilters<unknown[]>(withFilters);
179+
const {
180+
columnFilters,
181+
columnFiltersState,
182+
filtersTableOptions,
183+
setColumnFilters,
184+
} = useFilters<unknown[]>(withFilters);
182185

183186
const options: TableOptions<unknown[]> = {
184187
data: dataState,
@@ -278,7 +281,7 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
278281
);
279282

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

308311
element.addEventListener(
309312
"updateCellSelection",
310-
handleMessage as EventListener
313+
handleCellSelection as EventListener
311314
);
312315

313316
return () => {
314317
element.removeEventListener(
315318
"updateCellSelection",
316-
handleMessage as EventListener
319+
handleCellSelection as EventListener
317320
);
318321
};
319322
}, [id, rowSelection, rowData]);
320323

324+
useEffect(() => {
325+
const handleColumnSort = (
326+
event: CustomEvent<{ sort: { col: number; desc: boolean }[] }>
327+
) => {
328+
const shinySorting = event.detail.sort;
329+
const columnSorting: SortingState = [];
330+
331+
shinySorting.map((sort) => {
332+
columnSorting.push({
333+
id: columns[sort.col],
334+
desc: sort.desc,
335+
});
336+
});
337+
setSorting(columnSorting);
338+
};
339+
340+
if (!id) return;
341+
342+
const element = document.getElementById(id);
343+
if (!element) return;
344+
345+
element.addEventListener(
346+
"updateColumnSort",
347+
handleColumnSort as EventListener
348+
);
349+
350+
return () => {
351+
element.removeEventListener(
352+
"updateColumnSort",
353+
handleColumnSort as EventListener
354+
);
355+
};
356+
}, [columns, id, setSorting]);
357+
358+
useEffect(() => {
359+
const handleColumnFilter = (
360+
event: CustomEvent<{ filter: { col: number; value: FilterValue }[] }>
361+
) => {
362+
const shinyFilters = event.detail.filter;
363+
364+
const columnFilters: ColumnFiltersState = [];
365+
shinyFilters.map((filter) => {
366+
columnFilters.push({
367+
id: columns[filter.col],
368+
value: filter.value,
369+
});
370+
});
371+
setColumnFilters(columnFilters);
372+
};
373+
374+
if (!id) return;
375+
376+
const element = document.getElementById(id);
377+
if (!element) return;
378+
379+
element.addEventListener(
380+
"updateColumnFilter",
381+
handleColumnFilter as EventListener
382+
);
383+
384+
return () => {
385+
element.removeEventListener(
386+
"updateColumnFilter",
387+
handleColumnFilter as EventListener
388+
);
389+
};
390+
}, [columns, id, setColumnFilters]);
391+
321392
useEffect(() => {
322393
if (!id) return;
323394
let shinyValue: CellSelection | null = null;
@@ -335,8 +406,7 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
335406
}
336407
return rowsById[key].index;
337408
})
338-
.filter((x): x is number => x !== null)
339-
.sort(),
409+
.filter((x): x is number => x !== null),
340410
};
341411
} else {
342412
console.error("Unhandled row selection mode:", rowSelectionModes);
@@ -346,23 +416,48 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
346416

347417
useEffect(() => {
348418
if (!id) return;
349-
Shiny.setInputValue!(`${id}_column_sort`, sorting);
350-
}, [id, sorting]);
419+
const shinySort: { col: number; desc: boolean }[] = [];
420+
sorting.map((sortObj) => {
421+
const columnNum = columns.indexOf(sortObj.id);
422+
shinySort.push({
423+
col: columnNum,
424+
desc: sortObj.desc,
425+
});
426+
});
427+
Shiny.setInputValue!(`${id}_sort`, shinySort);
428+
429+
// Deprecated as of 2024-05-21
430+
Shiny.setInputValue!(`${id}_column_sort`, shinySort);
431+
}, [columns, id, sorting]);
351432
useEffect(() => {
352433
if (!id) return;
353-
Shiny.setInputValue!(`${id}_column_filter`, columnFilters);
354-
}, [id, columnFilters]);
434+
const shinyFilter: {
435+
col: number;
436+
value: FilterValue;
437+
}[] = [];
438+
columnFilters.map((filterObj) => {
439+
const columnNum = columns.indexOf(filterObj.id);
440+
shinyFilter.push({
441+
col: columnNum,
442+
value: filterObj.value as FilterValue,
443+
});
444+
});
445+
Shiny.setInputValue!(`${id}_filter`, shinyFilter);
446+
447+
// Deprecated as of 2024-05-21
448+
Shiny.setInputValue!(`${id}_column_filter`, shinyFilter);
449+
}, [id, columnFilters, columns]);
355450
useEffect(() => {
356451
if (!id) return;
357452

358-
// Already prefiltered rows!
359-
const shinyValue: RowModel<unknown[]> = table.getSortedRowModel();
360-
361-
const rowIndices = table.getSortedRowModel().rows.map((row) => row.index);
362-
Shiny.setInputValue!(`${id}_data_view_rows`, rowIndices);
453+
const shinyRows: number[] = table
454+
// Already prefiltered rows!
455+
.getSortedRowModel()
456+
.rows.map((row) => row.index);
457+
Shiny.setInputValue!(`${id}_data_view_rows`, shinyRows);
363458

364459
// Legacy value as of 2024-05-13
365-
Shiny.setInputValue!(`${id}_data_view_indices`, rowIndices);
460+
Shiny.setInputValue!(`${id}_data_view_indices`, shinyRows);
366461
}, [
367462
id,
368463
table,

0 commit comments

Comments
 (0)