Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
16 changes: 13 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### New features

* Added [narwhals](https://posit-dev.github.io/py-narwhals) support for `@render.data_frame`. This allows for any eager data frame supported by narwhals to be returned from a `@render.data_frame` output method. All internal methods and helper methods leverage the `narwhals` API to be data frame agnostic. (#1570)
* New features for `@render.data_frame`:

* Added [narwhals](https://posit-dev.github.io/py-narwhals) support for `@render.data_frame`. This allows for any eager data frame supported by narwhals to be returned from a `@render.data_frame` output method. All internal methods and helper methods now leverage the `narwhals` API to be data frame agnostic. (#1570)

* Added `.data_patched()` reactive calculation that applies all `.cell_patches()` to `.data()`. (#1719)

* Added `.update_cell_value()` method to programmatically update the contents of a data frame cell. (#1719)

* Added `.update_data()` method to update the rendered data without resetting any user sort or filter. Note, all user edits will be forgotten. (#1719)

* Added [narwhals](https://posit-dev.github.io/py-narwhals) support for `@render.table`. This allows for any eager data frame supported by narwhals to be returned from a `@render.table` output method. (#1570)

### Other changes

Expand All @@ -41,8 +51,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* Added `.expect_class()` and `.expect_multiple()` for `Accordion` in `shiny.playwright.controllers` (#1710)

* Added [narwhals](https://posit-dev.github.io/py-narwhals) support for `@render.table`. This allows for any eager data frame supported by narwhals to be returned from a `@render.table` output method. (#1570)

### Bug fixes

* A few fixes for `ui.Chat()`, including:
Expand All @@ -66,6 +74,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
3 changes: 3 additions & 0 deletions js/data-frame/cell-edit-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export const useCellEditMap = () => {
cellEditMap,
// setCellEditMap,
setCellEditMapAtLoc,
resetCellEditMap: () => {
setCellEditMap(new Map<string, CellEdit>());
},
} as const;
};

Expand Down
96 changes: 63 additions & 33 deletions js/data-frame/data-update.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,62 @@ export type CellPatchPy = {
// prev: unknown;
};

type SetDataFn = (fn: (draft: unknown[][]) => void) => void;

export function addPatchToData({
setData,
newPatches,
setCellEditMapAtLoc,
}: {
setData: SetDataFn;
newPatches: CellPatch[];
setCellEditMapAtLoc: SetCellEditMapAtLoc;
}): void {
// Update data
setData((draft) => {
newPatches.forEach(({ rowIndex, columnIndex, value }) => {
draft[rowIndex]![columnIndex] = value;
});
});
// Set the new patches in cell edit map info
newPatches.forEach(({ rowIndex, columnIndex, value }) => {
setCellEditMapAtLoc(rowIndex, columnIndex, (obj_draft) => {
obj_draft.value = value;
obj_draft.state = CellStateEnum.EditSuccess;
// Remove save_error if it exists
obj_draft.errorTitle = undefined;
});
});
}

export function cellPatchPyArrToCellPatchArr(
patchesPy: CellPatchPy[]
): CellPatch[] {
const patches: CellPatch[] = patchesPy.map(
(patch: CellPatchPy): CellPatch => {
return {
rowIndex: patch.row_index,
columnIndex: patch.column_index,
value: patch.value,
};
}
);
return patches;
}

export function cellPatchArrToCellPatchPyArr(
patches: CellPatch[]
): CellPatchPy[] {
const patchesPy: CellPatchPy[] = patches.map((patch) => {
return {
row_index: patch.rowIndex,
column_index: patch.columnIndex,
value: patch.value,
};
});
return patchesPy;
}

export function updateCellsData({
patchInfo,
patches,
Expand All @@ -31,20 +87,13 @@ export function updateCellsData({
onSuccess: (values: CellPatch[]) => void;
onError: (err: string) => void;
columns: readonly string[];
setData: (fn: (draft: unknown[][]) => void) => void;
setData: SetDataFn;
setCellEditMapAtLoc: SetCellEditMapAtLoc;
}) {
// // Skip page index reset until after next rerender
// skipAutoResetPageIndex();

const patchesPy: CellPatchPy[] = patches.map((patch) => {
return {
row_index: patch.rowIndex,
column_index: patch.columnIndex,
value: patch.value,
// prev: patch.prev,
};
});
const patchesPy = cellPatchArrToCellPatchPyArr(patches);

makeRequestPromise({
method: patchInfo.key,
Expand All @@ -70,21 +119,7 @@ export function updateCellsData({
}
newPatchesPy = newPatchesPy as CellPatchPy[];

const newPatches: CellPatch[] = newPatchesPy.map(
(patch: CellPatchPy): CellPatch => {
return {
rowIndex: patch.row_index,
columnIndex: patch.column_index,
value: patch.value,
};
}
);

setData((draft) => {
newPatches.forEach(({ rowIndex, columnIndex, value }) => {
draft[rowIndex]![columnIndex] = value;
});
});
const newPatches = cellPatchPyArrToCellPatchArr(newPatchesPy);

// Set the old patches locations back to success state
// This may be overkill, but it guarantees that the incoming patches exit the saving state
Expand All @@ -99,15 +134,10 @@ export function updateCellsData({
obj_draft.errorTitle = undefined;
});
});
// Set the new patches
newPatches.forEach(({ rowIndex, columnIndex, value }) => {
setCellEditMapAtLoc(rowIndex, columnIndex, (obj_draft) => {
obj_draft.value = value;
obj_draft.state = CellStateEnum.EditSuccess;
// Remove save_error if it exists
obj_draft.errorTitle = undefined;
});
});

// Update data and cell edit map with new patches
addPatchToData({ setData, newPatches, setCellEditMapAtLoc });

onSuccess(newPatches);
})
.catch((err: string) => {
Expand Down
133 changes: 130 additions & 3 deletions js/data-frame/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

// TODO-barret-future; Try to group all related code into a file and make index.tsx as small as possible. Try to move all logic into files and keep the main full of `useFOO` functions.

// TODO-barret-future; Instead of deconstructinng all of the use state objects, keep it as a dictionary and shorten the method names. The docs can live on the _useFoo` function return type. Ex: CellEditMapReturnObject 's setCellEditMapAtLoc should contain JSDoc on it's objects. Then we'd have a `cellEditMap.setAtLoc` method.

import {
Column,
ColumnDef,
Expand All @@ -28,6 +30,11 @@ import { ErrorsMessageValue } from "rstudio-shiny/srcts/types/src/shiny/shinyapp
import { useImmer } from "use-immer";
import { TableBodyCell } from "./cell";
import { getCellEditMapObj, useCellEditMap } from "./cell-edit-map";
import {
addPatchToData,
cellPatchPyArrToCellPatchArr,
type CellPatchPy,
} from "./data-update";
import { findFirstItemInView, getStyle } from "./dom-utils";
import { ColumnFiltersState, Filter, FilterValue, useFilters } from "./filter";
import type { CellSelection, SelectionModesProp } from "./selection";
Expand Down Expand Up @@ -106,8 +113,8 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
bgcolor,
}) => {
const {
columns,
typeHints,
columns: columnsProp,
typeHints: typeHintsProp,
data: tableDataProp,
options: payloadOptions = {
width: undefined,
Expand All @@ -129,6 +136,9 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
const theadRef = useRef<HTMLTableSectionElement>(null);
const tbodyRef = useRef<HTMLTableSectionElement>(null);

const [columns, setColumns] = useImmer(columnsProp);
const [typeHints, setTypeHints] = useImmer(typeHintsProp);

const _useStyleInfo = useStyleInfoMap({
initStyleInfos: initStyleInfos ?? [],
nrow: tableDataProp.length,
Expand All @@ -153,6 +163,10 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
* Set a cell's state or edit value in the `cellEditMap`
*/
const setCellEditMapAtLoc = _cellEditMap.setCellEditMapAtLoc;
/**
* Reset the `cellEditMap` to an empty state
*/
const resetCellEditMap = _cellEditMap.resetCellEditMap;

/**
* Determines if the user is allowed to edit cells in the table.
Expand Down Expand Up @@ -285,6 +299,59 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
setColumnFilters,
} = useFilters<unknown[]>(withFilters);

const updateData = useCallback(
({
data,
columns,
typeHints,
}: {
data: PandasData<unknown>["data"];
columns: readonly string[];
typeHints: readonly TypeHint[] | undefined;
}) => {
setColumns(columns);
setTableData(data);
setTypeHints(typeHints);
resetCellEditMap();

// Make map for quick lookup of type hints
const newTypeHintMap = new Map<string, TypeHint>();
typeHints?.forEach((hint, i) => {
newTypeHintMap.set(columns[i]!, hint);
});
// Filter out sorting and column filters that are no longer valid
const newSort = sorting.filter((sort) => newTypeHintMap.has(sort.id));
const newColumnFilter = columnFilters.filter((filter) => {
const typeHint = newTypeHintMap.get(filter.id);
if (!typeHint) return false;
// Maintain the filter if it's a numeric filter
// Drop if it's a string filter
if (typeHint.type === "numeric") {
return (
filter.value === null ||
(Array.isArray(filter.value) &&
filter.value.every((v) => v !== null))
);
}
// Maintain string filters
return typeof filter.value === "string";
});

setColumnFilters(newColumnFilter);
setSorting(newSort);
},
[
columnFilters,
resetCellEditMap,
setColumnFilters,
setColumns,
setSorting,
setTableData,
setTypeHints,
sorting,
]
);

const options: TableOptions<unknown[]> = {
data: tableData,
columns: coldefs,
Expand Down Expand Up @@ -463,6 +530,66 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
Shiny.renderDependenciesAsync([...htmlDeps]);
}, [htmlDeps]);

useEffect(() => {
const handleAddPatches = (
event: CustomEvent<{
patches: CellPatchPy[];
}>
) => {
const evtPatches = event.detail.patches;
const newPatches = cellPatchPyArrToCellPatchArr(evtPatches);

// Update data with extra patches
addPatchToData({
setData: setTableData,
newPatches,
setCellEditMapAtLoc,
});
};

if (!id) return;

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

element.addEventListener("addPatches", handleAddPatches as EventListener);

return () => {
element.removeEventListener(
"addPatches",
handleAddPatches as EventListener
);
};
}, [columns, id, setCellEditMapAtLoc, setSorting, setTableData]);

useEffect(() => {
const handleUpdateData = (
event: CustomEvent<{
data: PandasData<unknown>["data"];
columns: PandasData<unknown>["columns"];
typeHints: PandasData<unknown>["typeHints"];
}>
) => {
const evtData = event.detail;

updateData(evtData);
};

if (!id) return;

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

element.addEventListener("updateData", handleUpdateData as EventListener);

return () => {
element.removeEventListener(
"updateData",
handleUpdateData as EventListener
);
};
}, [columns, id, resetCellEditMap, setTableData, updateData]);

useEffect(() => {
const handleColumnSort = (
event: CustomEvent<{ sort: { col: number; desc: boolean }[] }>
Expand Down Expand Up @@ -734,7 +861,7 @@ const ShinyDataGrid: FC<ShinyDataGridProps<unknown>> = ({
>
<table
className={tableClass + (withFilters ? " filtering" : "")}
aria-rowcount={tableData.length}
aria-rowcount={table.getRowCount()}
aria-multiselectable={canMultiRowSelect}
style={{
width: width === null || width === "auto" ? undefined : "100%",
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
Loading
Loading