From ebdb97bb9f3ad06c022d7f25a152c8bd996a29fd Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 21 Mar 2024 16:02:32 -0400 Subject: [PATCH] feat: Editable data frame (#1198) Co-authored-by: Gordon Shotwell --- .github/workflows/pytest.yaml | 13 +- .prettierrc | 10 +- .vscode/extensions.json | 7 + .vscode/settings.json | 4 + CHANGELOG.md | 4 + Makefile | 120 ++-- examples/dataframe/app.py | 21 +- js/.eslintrc.js | 1 + js/build.ts | 10 +- js/dataframe/cell-edit-map.tsx | 42 ++ js/dataframe/cell.tsx | 420 +++++++++++++ js/dataframe/data-update.tsx | 121 ++++ js/dataframe/filter.tsx | 4 +- js/dataframe/index.tsx | 260 ++++++-- js/dataframe/request.ts | 64 ++ js/dataframe/selection.tsx | 60 +- js/dataframe/styles.scss | 150 +++-- js/dataframe/tabindex-group.ts | 5 +- js/dataframe/table-summary.tsx | 6 +- js/dataframe/types.ts | 10 +- js/package-lock.json | 54 +- js/package.json | 6 +- setup.cfg | 1 + shiny/_static.py | 13 +- shiny/api-examples/data_frame/app-core.py | 8 +- shiny/api-examples/data_frame/app-express.py | 10 +- shiny/express/_mock_session.py | 2 +- shiny/render/__init__.py | 13 +- shiny/render/_dataframe.py | 558 ++++++++++++++++-- shiny/render/_render.py | 3 +- shiny/render/renderer/__init__.py | 9 +- shiny/render/renderer/_dispatch.py | 84 +++ shiny/render/renderer/_renderer.py | 45 +- shiny/session/_session.py | 208 +++++-- shiny/types.py | 45 +- .../shared/py-shiny/dataframe/dataframe.js | 186 ++++-- .../py-shiny/dataframe/dataframe.js.map | 8 +- .../playwright/deploys/express-folium/app.py | 2 +- tests/playwright/deploys/plotly/app.py | 6 +- .../shiny/bugs/0676-row-selection/app.py | 4 +- .../shiny/components/data_frame/edit/app.py | 127 ++++ .../__snapshots__/test_data_frame.ambr | 0 .../{ => example}/test_data_frame.py | 3 +- .../data_frame/row_selection/app.py | 100 ++++ .../row_selection/test_row_selection.py | 53 ++ tests/pytest/test_renderer.py | 81 ++- 46 files changed, 2578 insertions(+), 383 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 js/dataframe/cell-edit-map.tsx create mode 100644 js/dataframe/cell.tsx create mode 100644 js/dataframe/data-update.tsx create mode 100644 js/dataframe/request.ts create mode 100644 shiny/render/renderer/_dispatch.py create mode 100644 tests/playwright/shiny/components/data_frame/edit/app.py rename tests/playwright/shiny/components/data_frame/{ => example}/__snapshots__/test_data_frame.ambr (100%) rename tests/playwright/shiny/components/data_frame/{ => example}/test_data_frame.py (99%) create mode 100644 tests/playwright/shiny/components/data_frame/row_selection/app.py create mode 100644 tests/playwright/shiny/components/data_frame/row_selection/test_row_selection.py diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index b0ac38ed0..833afe9e9 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -33,25 +33,20 @@ jobs: run: | make check-tests - - name: Type check with pyright + - name: Type check if: steps.install.outcome == 'success' && (success() || failure()) run: | make check-types - - name: Lint with flake8 + - name: Lint code if: steps.install.outcome == 'success' && (success() || failure()) run: | make check-lint - - name: black + - name: Verify code formatting if: steps.install.outcome == 'success' && (success() || failure()) run: | - make check-black - - - name: isort - if: steps.install.outcome == 'success' && (success() || failure()) - run: | - make check-isort + make check-format playwright-shiny: runs-on: ${{ matrix.os }} diff --git a/.prettierrc b/.prettierrc index 522fca5d9..29f9e0e33 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,11 @@ { - "organizeImportsSkipDestructiveCodeActions": true + "organizeImportsSkipDestructiveCodeActions": true, + "overrides": [ + { + "files": "**/*.scss", + "options": { + "printWidth": 150 + } + } + ] } diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..266d6bc38 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "posit.shiny-python", + "esbenp.prettier-vscode", + "ms-python.black-formatter", + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 07dbac4c2..1664fba47 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,10 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[scss]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, "[python]": { "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true, diff --git a/CHANGELOG.md b/CHANGELOG.md index f99233a26..ba67ba560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +* `@render.data_frame` return values of `DataTable` and `DataGrid` had their parameter of `row_selection: Literal["single", "multiple"]` become deprecated. Please use `mode="row_single"` or `mode="row_multiple"` instead. (#1198) + * The `col_widths` argument of `ui.layout_columns()` now sets the `sm` breakpoint by default, rather than the `md` breakpoint. For example, `col_widths=(12, 6, 6)` is now equivalent to `{"sm": (12, 6, 6)}` rather than `{"md": (12, 6, 6)}`. (#1222) ### New features +* Experimental: `@render.data_frame` return values of `DataTable` and `DataGrid` support `mode="edit"` to enable editing of the data table cells. (#1198) + * `ui.card()` and `ui.value_box()` now take an `id` argument that, when provided, is used to report the full screen state of the card or value box to the server. For example, when using `ui.card(id = "my_card", full_screen = TRUE)` you can determine if the card is currently in full screen mode by reading the boolean value of `input.my_card()["full_screen"]`. (#1215) * Added support for using `shiny.express` in Quarto Dashboards. (#1217) diff --git a/Makefile b/Makefile index 76c205aa3..40146b192 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,9 @@ -.PHONY: help clean% check% format% docs% lint test pyright playwright% install% testrail% coverage release +# https://www.gnu.org/software/make/manual/make.html#Phony-Targets +# Prerequisites of .PHONY are always interpreted as literal target names, never as patterns (even if they contain ‘%’ characters). +# # .PHONY: help clean% check% format% docs% lint test pyright playwright% install% testrail% coverage release js-* +# Using `FORCE` as prerequisite to _force_ the target to always run; https://www.gnu.org/software/make/manual/make.html#index-FORCE +FORCE: ; + .DEFAULT_GOAL := help define BROWSER_PYSCRIPT @@ -23,114 +28,157 @@ export PRINT_HELP_PYSCRIPT BROWSER := python -c "$$BROWSER_PYSCRIPT" -help: +help: FORCE @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts -clean-build: ## remove build artifacts +# Remove build artifacts +clean-build: FORCE rm -fr build/ rm -fr dist/ rm -fr .eggs/ find . -name '*.egg-info' -exec rm -fr {} + find . -name '*.egg' -exec rm -f {} + -clean-pyc: ## remove Python file artifacts +# Remove Python file artifacts +clean-pyc: FORCE find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + find . -name '__pycache__' -exec rm -fr {} + -clean-test: ## remove test and coverage artifacts +# Remove test and coverage artifacts +clean-test: FORCE rm -fr .tox/ rm -f .coverage rm -fr htmlcov/ rm -fr .pytest_cache rm -rf typings/ +typings/appdirs: + echo "Creating appdirs stubs" + pyright --createstub appdirs +typings/folium: + echo "Creating folium stubs" + pyright --createstub folium typings/uvicorn: + echo "Creating uvicorn stubs" pyright --createstub uvicorn +typings/seaborn: + echo "Creating seaborn stubs" + pyright --createstub seaborn -typings/matplotlib/__init__.pyi: ## grab type stubs from GitHub +typings/matplotlib/__init__.pyi: + echo "Creating matplotlib stubs" mkdir -p typings git clone --depth 1 https://github.com/microsoft/python-type-stubs typings/python-type-stubs mv typings/python-type-stubs/stubs/matplotlib typings/ rm -rf typings/python-type-stubs -typings/seaborn: - pyright --createstub seaborn +pyright-typings: typings/appdirs typings/folium typings/uvicorn typings/seaborn typings/matplotlib/__init__.pyi check: check-format check-lint check-types check-tests ## check code, style, types, and test (basic CI) check-fix: format check-lint check-types check-tests ## check and format code, style, types, and test check-format: check-black check-isort -check-lint: - @echo "-------- Checking style with flake8 --------" +check-lint: check-flake8 +check-types: check-pyright +check-tests: check-pytest + +check-flake8: FORCE + @echo "-------- Checking style with flake8 ---------" flake8 --show-source . -check-black: - @echo "-------- Checking code with black --------" +check-black: FORCE + @echo "-------- Checking code with black -----------" black --check . -check-isort: - @echo "-------- Sorting imports with isort --------" +check-isort: FORCE + @echo "-------- Sorting imports with isort ---------" isort --check-only --diff . -check-types: typings/uvicorn typings/matplotlib/__init__.pyi typings/seaborn +check-pyright: pyright-typings @echo "-------- Checking types with pyright --------" pyright -check-tests: - @echo "-------- Running tests with pytest --------" +check-pytest: FORCE + @echo "-------- Running tests with pytest ----------" python3 tests/pytest/asyncio_prevent.py pytest -pyright: check-types ## check types with pyright -lint: check-lint ## check style with flake8 +# Check types with pyright +pyright: check-types +# Check style with flake8 +lint: check-lint test: check-tests ## check tests quickly with the default Python format: format-black format-isort ## format code with black and isort -format-black: +format-black: FORCE @echo "-------- Formatting code with black --------" black . -format-isort: +format-isort: FORCE @echo "-------- Sorting imports with isort --------" isort . -docs: ## docs: build docs with quartodoc - @echo "-------- Building docs with quartodoc --------" +docs: FORCE ## docs: build docs with quartodoc + @echo "-------- Building docs with quartodoc ------" @cd docs && make quartodoc -docs-preview: ## docs: preview docs in browser +docs-preview: FORCE ## docs: preview docs in browser @echo "-------- Previewing docs in browser --------" @cd docs && make serve + +install-npm: FORCE + $(if $(shell which npm), @echo -n, $(error Please install node.js and npm first. See https://nodejs.org/en/download/ for instructions.)) +js/node_modules: install-npm + @echo "-------- Installing node_modules -----------" + @cd js && npm install +js-build: js/node_modules ## Build JS assets + @echo "-------- Building JS assets ----------------" + @cd js && npm run build +js-watch: js/node_modules + @echo "-------- Continuously building JS assets ---" + @cd js && npm run watch +js-watch-fast: js/node_modules ## Continuously build JS assets (development) + @echo "-------- Previewing docs in browser --------" + @cd js && npm run watch-fast +clean-js: FORCE + @echo "-------- Removing js/node_modules ----------" + rm -rf js/node_modules + # Default `SUB_FILE` to empty SUB_FILE:= -install-playwright: +install-playwright: FORCE playwright install --with-deps -install-trcli: - which trcli || pip install trcli +install-trcli: FORCE + $(if $(shell which trcli), @echo -n, $(shell pip install trcli)) -install-rsconnect: ## install the main version of rsconnect till pypi version supports shiny express +# Installs the main version of rsconnect till pypi version supports shiny express +install-rsconnect: FORCE pip install git+https://github.com/rstudio/rsconnect-python.git#egg=rsconnect-python -playwright-shiny: install-playwright ## end-to-end tests with playwright +# end-to-end tests with playwright; (SUB_FILE="" within tests/playwright/shiny/) +playwright-shiny: install-playwright pytest tests/playwright/shiny/$(SUB_FILE) -playwright-deploys: install-playwright install-rsconnect ## end-to-end tests on examples with playwright +# 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) -playwright-examples: install-playwright ## end-to-end tests on examples with playwright +# 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) -playwright-debug: install-playwright ## All end-to-end tests, chrome only, headed +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 -testrail-junit: install-playwright install-trcli ## end-to-end tests with playwright and generate junit report +# end-to-end tests with playwright and generate junit report +testrail-junit: install-playwright install-trcli pytest tests/playwright/shiny/$(SUB_FILE) --junitxml=report.xml -coverage: ## check combined code coverage (must run e2e last) +coverage: FORCE ## check combined code coverage (must run e2e last) pytest --cov-report term-missing --cov=shiny tests/pytest/ tests/playwright/shiny/$(SUB_FILE) coverage html $(BROWSER) htmlcov/index.html @@ -151,9 +199,9 @@ install: dist pip uninstall -y shiny python3 -m pip install dist/shiny*.whl -install-deps: ## install dependencies +install-deps: FORCE ## install dependencies pip install -e ".[dev,test]" --upgrade # ## If caching is ever used, we could run: -# install-deps: ## install latest dependencies +# install-deps: FORCE ## install latest dependencies # pip install --editable ".[dev,test]" --upgrade --upgrade-strategy eager diff --git a/examples/dataframe/app.py b/examples/dataframe/app.py index 99374627e..acf7ee51e 100644 --- a/examples/dataframe/app.py +++ b/examples/dataframe/app.py @@ -18,9 +18,14 @@ def app_ui(req): ui.input_select( "selection_mode", "Selection mode", - {"none": "(None)", "single": "Single", "multiple": "Multiple"}, + { + "none": "(None)", + "single_row": "Single", + "multiple_row": "Multiple", + }, selected="multiple", ), + ui.input_switch("editable", "Edit", False), ui.input_switch("filters", "Filters", True), ui.input_switch("gridstyle", "Grid", True), ui.input_switch("fullwidth", "Take full width", True), @@ -61,6 +66,12 @@ def server(input: Inputs, output: Outputs, session: Session): def update_df(): return df.set(sns.load_dataset(req(input.dataset()))) + @reactive.calc + def selection_mode(): + if input.editable(): + return "edit" + return input.selection_mode() + @render.data_frame def grid(): height = 350 @@ -71,7 +82,7 @@ def grid(): width=width, height=height, filters=input.filters(), - row_selection_mode=input.selection_mode(), + mode=selection_mode(), ) else: return render.DataTable( @@ -79,7 +90,7 @@ def grid(): width=width, height=height, filters=input.filters(), - row_selection_mode=input.selection_mode(), + mode=selection_mode(), ) @reactive.effect @@ -92,10 +103,10 @@ def handle_edit(): @render.text def detail(): - selected_rows = input.grid_selected_rows() or () + selected_rows = grid.input_selected_rows() or () if len(selected_rows) > 0: # "split", "records", "index", "columns", "values", "table" - return df().iloc[list(input.grid_selected_rows())] + return df().iloc[list(grid.input_selected_rows())] app = App(app_ui, server) diff --git a/js/.eslintrc.js b/js/.eslintrc.js index fa6f42d5d..ea03295fa 100644 --- a/js/.eslintrc.js +++ b/js/.eslintrc.js @@ -9,6 +9,7 @@ module.exports = { "plugin:react/recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", ], ignorePatterns: ["dist/*"], overrides: [], diff --git a/js/build.ts b/js/build.ts index 1467fdd54..48ff4c1be 100644 --- a/js/build.ts +++ b/js/build.ts @@ -2,6 +2,14 @@ import { BuildOptions, build } from "esbuild"; import { sassPlugin } from "esbuild-sass-plugin"; import * as fs from "node:fs/promises"; +let minify = true; +process.argv.forEach((val, index) => { + if (val === "--minify=false") { + console.log("Disabling minification"); + minify = false; + } +}); + const outDir = "../shiny/www/shared/py-shiny"; async function bundle_helper( @@ -11,7 +19,7 @@ async function bundle_helper( const result = await build({ format: "esm", bundle: true, - minify: true, + minify: minify, sourcemap: true, metafile: false, outdir: outDir, diff --git a/js/dataframe/cell-edit-map.tsx b/js/dataframe/cell-edit-map.tsx new file mode 100644 index 000000000..02d3bbad0 --- /dev/null +++ b/js/dataframe/cell-edit-map.tsx @@ -0,0 +1,42 @@ +import { enableMapSet } from "immer"; +import { Updater, useImmer } from "use-immer"; +import type { CellState } from "./cell"; + +// const [cellEditMap, setCellEditMap] = useImmer< +// Map +// >(new Map()); +// enableMapSet(); + +export type CellEdit = { + value: string; + state: CellState; + save_error?: string; +}; +export type CellEditMap = Map; +export type SetCellEditMap = Updater; +export const useCellEditMap = () => { + const [cellEditMap, setCellEditMap] = useImmer( + new Map() + ); + enableMapSet(); + return [cellEditMap, setCellEditMap] as const; +}; + +export const makeCellEditMapKey = (rowIndex: number, columnIndex: number) => { + return `[${rowIndex}, ${columnIndex}]`; +}; + +export const cellEditMapHasKey = ( + x: CellEditMap, + rowIndex: number, + columnIndex: number +) => { + return x.has(makeCellEditMapKey(rowIndex, columnIndex)); +}; +export const getCellEditMapValue = ( + x: CellEditMap, + rowIndex: number, + columnIndex: number +) => { + return x.get(makeCellEditMapKey(rowIndex, columnIndex)); +}; diff --git a/js/dataframe/cell.tsx b/js/dataframe/cell.tsx new file mode 100644 index 000000000..dc6efa105 --- /dev/null +++ b/js/dataframe/cell.tsx @@ -0,0 +1,420 @@ +import { flexRender } from "@tanstack/react-table"; +import { VirtualItem } from "@tanstack/react-virtual"; +import { Cell } from "@tanstack/table-core"; +import React, { + FC, + ChangeEvent as ReactChangeEvent, + ReactElement, + FocusEvent as ReactFocusEvent, + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { useImmer } from "use-immer"; +import { + CellEditMap, + SetCellEditMap, + getCellEditMapValue, +} from "./cell-edit-map"; +import { updateCellsData } from "./data-update"; + +// States +// # √ Ready +// # √ Editing +// # √ Saving / Disabled +// # √ Error +// # √ Saved +// # Cancelled (is Ready state?) +// # New +// # Added +// # Removed +export const CellStateEnum = { + EditSaving: "EditSaving", + EditSuccess: "EditSuccess", + EditFailure: "EditFailure", + Editing: "Editing", + Ready: "Ready", +} as const; +const CellStateClassEnum = { + EditSaving: "cell-edit-saving", + EditSuccess: "cell-edit-success", + EditFailure: "cell-edit-failure", + Editing: "cell-edit-editing", + Ready: undefined, +} as const; +export type CellState = keyof typeof CellStateEnum; + +interface TableBodyCellProps { + id: string | null; + cell: Cell; + columns: readonly string[]; + editCellsIsAllowed: boolean; + editRowIndex: number | null; + editColumnIndex: number | null; + virtualRows: VirtualItem[]; + setEditRowIndex: (index: number | null) => void; + setEditColumnIndex: (index: number | null) => void; + setData: (fn: (draft: unknown[][]) => void) => void; + cellEditMap: CellEditMap; + setCellEditMap: SetCellEditMap; + maxRowSize: number; +} + +export const TableBodyCell: FC = ({ + id, + cell, + columns, + editCellsIsAllowed, + editRowIndex, + editColumnIndex, + virtualRows, + setEditRowIndex, + setEditColumnIndex, + cellEditMap, + setData, + setCellEditMap, + maxRowSize, +}) => { + const rowIndex = cell.row.index; + const columnIndex = cell.column.columnDef.meta!.colIndex; + + const initialValue = cell.getValue(); + // We need to keep and update the state of the cell normally + const [value, setValue] = useImmer(initialValue); + // If the initialValue (defined by `cell.getValue()`) is changed externally + // (e.g. copy/**paste**), sync it up with our state + // (This method only runs if `initialValue` has changed) + useEffect(() => setValue(initialValue), [initialValue, setValue]); + + const inputRef = useRef(null); + const [cellState, setCellState] = useState( + getCellEditMapValue(cellEditMap, rowIndex, columnIndex)?.state || + CellStateEnum.Ready + ); + + const [errorTitle, setErrorTitle] = useState(undefined); + + const setValueStateError = useCallback( + ({ + newValue, + newCellState, + newErrorTitle, + }: { + newValue: typeof value; + newCellState: typeof cellState; + newErrorTitle: typeof errorTitle; + }) => { + setValue(newValue); + setCellState(newCellState); + setErrorTitle(newErrorTitle); + }, + [setValue, setCellState, setErrorTitle] + ); + + // Keyboard navigation: + // * When editing a cell: + // * On esc key: + // * √ Restore prior value / state / error + // * Move focus from input to td + // * On enter key: + // * √ Save value + // * √ Move to the cell below (or above w/ shift) and edit the new cell + // * Should shift+enter add a newline in a cell? + // * On tab key: + // * √ Save value + // * √ Move to the cell to the right (or left w/ shift) and edit the new cell + // * Scrolls out of view: + // * Intercept keyboard events and execute the above actions + // * (Currently, there literally is no input DOM element to accept keyboard events) + // TODO-barret-future; More keyboard navigation! + // * https://www.npmjs.com/package/@table-nav/react ? + // * When focused on a td: + // * Allow for arrow key navigation + // * Have enter key enter edit mode for a cell + // * When a td is focused, Have esc key move focus to the table + // * When table is focused, Have esc key blur the focus + // TODO-barret-future; Combat edit mode being independent of selection mode + // * In row / column selection mode, allow for arrowoutput_binding_request_handler key navigation by focusing on a single cell, not a TR + // * If a cell is focused, + // * `enter key` allows you to go into edit mode; If editing is turned off, the selection is toggled + // * `space key` allows you toggle the selection of the cell + // * Arrow key navigation is required + + useEffect(() => { + const cellIsEditable = + editRowIndex === rowIndex && editColumnIndex === columnIndex; + // If the cell is editable, set the cell state to editing + if (cellIsEditable) { + setCellState(CellStateEnum.Editing); + } else { + // Update cell state when a cell edit has been created + const editInfo = getCellEditMapValue(cellEditMap, rowIndex, columnIndex); + if (editInfo) { + setValueStateError({ + newValue: editInfo.value, + newCellState: editInfo.state, + newErrorTitle: editInfo.save_error, + }); + } + } + }, [ + editRowIndex, + editColumnIndex, + rowIndex, + columnIndex, + cellEditMap, + setValueStateError, + ]); + + const resetEditInfo = useCallback(() => { + setEditRowIndex(null); + setEditColumnIndex(null); + }, [setEditColumnIndex, setEditRowIndex]); + + const handleEsc = (e: ReactKeyboardEvent) => { + if (e.key !== "Escape") return; + // Prevent default behavior + e.preventDefault(); + + // Try to restore the previous value, state, and error + // If there is no previous state info, reset the cell to the inital value + const stateInfo = getCellEditMapValue(cellEditMap, rowIndex, columnIndex); + + if (stateInfo) { + // Restore the previous value, state, and error + setValueStateError({ + newValue: stateInfo.value, + newCellState: stateInfo.state, + newErrorTitle: stateInfo.save_error, + }); + } else { + // Reset to the initial value + setValueStateError({ + newValue: initialValue, + newCellState: CellStateEnum.Ready, + newErrorTitle: undefined, + }); + } + // Remove editing info + resetEditInfo(); + }; + const handleTab = (e: ReactKeyboardEvent) => { + if (e.key !== "Tab") return; + // Prevent default behavior + e.preventDefault(); + + const hasShift = e.shiftKey; + + const newColumnIndex = editColumnIndex! + (hasShift ? -1 : 1); + if (newColumnIndex < 0 || newColumnIndex >= columns.length) { + // If the new column index is out of bounds, quit + return; + } + + attemptUpdate(); + setEditColumnIndex(newColumnIndex); + }; + // TODO future: Make Cmd-Enter add a newline in a cell. + const handleEnter = (e: ReactKeyboardEvent) => { + if (e.key !== "Enter") return; + // Prevent default behavior + e.preventDefault(); + + const hasShift = e.shiftKey; + + const newRowIndex = editRowIndex! + (hasShift ? -1 : 1); + if (newRowIndex < 0 || newRowIndex >= maxRowSize) { + // If the new row index is out of bounds, quit + return; + } + + attemptUpdate(); + setEditRowIndex(newRowIndex); + }; + + const onInputKeyDown = (e: ReactKeyboardEvent) => { + [handleEsc, handleEnter, handleTab].forEach((fn) => fn(e)); + }; + + const attemptUpdate = useCallback(() => { + setErrorTitle(undefined); + + // Only update if the string form of the value has changed + if (`${initialValue}` === `${value}`) { + setCellState(CellStateEnum.Ready); + return; + } + + setCellState(CellStateEnum.EditSaving); + // Update the data! + // updateCellsData updates the underlying data via `setData` and `setCellEditMap` + updateCellsData({ + id, + patches: [ + { + rowIndex, + columnIndex, + value, + // prev: initialValue, + }, + ], + onSuccess: (_patches) => { + // console.log("Success!!"); + }, + onError: (err) => { + // console.log("Error!!", err); + }, + columns, + setData, + setCellEditMap, + }); + }, [ + id, + rowIndex, + columnIndex, + value, + initialValue, + columns, + setData, + setCellEditMap, + ]); + + // Select the input when it becomes editable + useEffect(() => { + if (cellState !== CellStateEnum.Editing) return; + if (!inputRef.current) return; + + inputRef.current.focus(); + inputRef.current.select(); + }, [cellState]); + + // When editing a cell, set up a global click listener to reset edit info when + // clicking outside of the cell + useEffect(() => { + if (cellState !== CellStateEnum.Editing) return; + if (!inputRef.current) return; + + // TODO-barret; Restore cursor position and text selection here + + // Set up global click listener to reset edit info + const onBodyClick = (e: MouseEvent) => { + if (e.target === inputRef.current) return; + + attemptUpdate(); + resetEditInfo(); + }; + document.body.addEventListener("click", onBodyClick); + + // Tear down global click listener when we're done + return () => { + document.body.removeEventListener("click", onBodyClick); + }; + }, [cellState, attemptUpdate, resetEditInfo, value]); + + // Reselect the input when it comes into view! + // (It could be scrolled out of view and then back into view) + function onFocus(e: ReactFocusEvent) { + if (cellState === CellStateEnum.Editing) { + e.target.select(); + } + } + + function onChange(e: ReactChangeEvent) { + // Upddate value temporarily (do not save to cell edit map) + setValue(e.target.value); + } + + // https://medium.com/@oherterich/creating-a-textarea-with-dynamic-height-using-react-and-typescript-5ed2d78d9848 + // Updates the height of a