From 2cf4818737a96b983222f3c5e32d89d3303fa313 Mon Sep 17 00:00:00 2001 From: damien Date: Thu, 18 Mar 2021 18:56:56 +0100 Subject: [PATCH] [DataGrid] Edit Cell Navigation (#1205) --- .../grid/_modules_/grid/GridComponent.tsx | 2 + .../grid/components/GridViewport.tsx | 5 +- .../grid/components/{ => cell}/GridCell.tsx | 40 ++- .../GridEditInputCell.tsx} | 52 +-- .../components/{ => cell}/GridEmptyCell.tsx | 2 +- .../components/{ => cell}/GridRowCells.tsx | 16 +- .../_modules_/grid/components/cell/index.ts | 4 + .../columnHeaders/GridColumnHeaders.tsx | 2 +- .../grid/_modules_/grid/components/index.ts | 5 +- .../menu/columnMenu/GridColumnMenu.tsx | 5 +- .../grid/components/panel/GridPanel.tsx | 6 +- .../toolbar/GridDensitySelector.tsx | 5 +- .../components/toolbar/GridToolbarExport.tsx | 5 +- .../grid/constants/eventsConstants.ts | 16 +- .../grid/hooks/features/core/useGridState.ts | 3 +- .../features/keyboard/useGridKeyboard.ts | 215 +++-------- .../keyboard/useGridKeyboardNavigation.ts | 160 +++++++++ .../hooks/features/rows/useGridEditRows.ts | 339 ++++++++++++------ .../hooks/features/rows/useGridParamsApi.ts | 1 + .../grid/_modules_/grid/models/api/gridApi.ts | 2 + .../grid/models/api/gridEditRowApi.ts | 56 +-- .../grid/models/api/gridNavigationApi.ts | 9 + .../grid/models/colDef/gridStringColDef.ts | 2 +- .../_modules_/grid/models/gridEditRowModel.ts | 4 +- .../_modules_/grid/models/gridOptions.tsx | 15 +- .../grid/models/params/gridCellParams.ts | 10 +- .../grid/models/params/gridEditCellParams.ts | 15 +- .../_modules_/grid/utils/keyboardUtils.ts | 36 +- .../x-grid/src/tests/apiRef.XGrid.test.tsx | 336 ----------------- .../x-grid/src/tests/editRows.XGrid.test.tsx | 217 +++++++++++ .../x-grid/src/tests/events.XGrid.test.tsx | 53 +++ .../x-grid/src/tests/export.XGrid.test.tsx | 106 ++++++ .../grid/x-grid/src/tests/rows.XGrid.test.tsx | 208 +++++++++-- packages/storybook/.storybook/preview.tsx | 2 +- .../src/stories/grid-rows.stories.tsx | 134 ++----- .../playground/real-data-demo.stories.tsx | 2 +- 36 files changed, 1242 insertions(+), 848 deletions(-) rename packages/grid/_modules_/grid/components/{ => cell}/GridCell.tsx (74%) rename packages/grid/_modules_/grid/components/{editCell/EditInputCell.tsx => cell/GridEditInputCell.tsx} (55%) rename packages/grid/_modules_/grid/components/{ => cell}/GridEmptyCell.tsx (87%) rename packages/grid/_modules_/grid/components/{ => cell}/GridRowCells.tsx (89%) create mode 100644 packages/grid/_modules_/grid/components/cell/index.ts create mode 100644 packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboardNavigation.ts create mode 100644 packages/grid/_modules_/grid/models/api/gridNavigationApi.ts delete mode 100644 packages/grid/x-grid/src/tests/apiRef.XGrid.test.tsx create mode 100644 packages/grid/x-grid/src/tests/editRows.XGrid.test.tsx create mode 100644 packages/grid/x-grid/src/tests/export.XGrid.test.tsx diff --git a/packages/grid/_modules_/grid/GridComponent.tsx b/packages/grid/_modules_/grid/GridComponent.tsx index 4a677f73594a7..ada6604002990 100644 --- a/packages/grid/_modules_/grid/GridComponent.tsx +++ b/packages/grid/_modules_/grid/GridComponent.tsx @@ -19,6 +19,7 @@ import { GridComponentProps } from './GridComponentProps'; import { useGridColumnMenu } from './hooks/features/columnMenu/useGridColumnMenu'; import { useGridColumns } from './hooks/features/columns/useGridColumns'; import { useGridState } from './hooks/features/core/useGridState'; +import { useGridKeyboardNavigation } from './hooks/features/keyboard/useGridKeyboardNavigation'; import { useGridPagination } from './hooks/features/pagination/useGridPagination'; import { useGridPreferencesPanel } from './hooks/features/preferencesPanel/useGridPreferencesPanel'; import { useGridParamsApi } from './hooks/features/rows/useGridParamsApi'; @@ -81,6 +82,7 @@ export const GridComponent = React.forwardRef( extendRowFullWidth={!options.disableExtendRowFullWidth} rowIndex={renderState.renderContext!.firstRowIdx! + idx} cellFocus={cellFocus} - domIndex={idx} /> diff --git a/packages/grid/_modules_/grid/components/GridCell.tsx b/packages/grid/_modules_/grid/components/cell/GridCell.tsx similarity index 74% rename from packages/grid/_modules_/grid/components/GridCell.tsx rename to packages/grid/_modules_/grid/components/cell/GridCell.tsx index f1f3f58df0a33..63035550cb215 100644 --- a/packages/grid/_modules_/grid/components/GridCell.tsx +++ b/packages/grid/_modules_/grid/components/cell/GridCell.tsx @@ -1,17 +1,19 @@ import { capitalize } from '@material-ui/core/utils'; import * as React from 'react'; -import { GRID_CELL_CSS_CLASS } from '../constants/cssClassesConstants'; +import { GRID_CELL_CSS_CLASS } from '../../constants/cssClassesConstants'; import { GRID_CELL_CLICK, GRID_CELL_DOUBLE_CLICK, GRID_CELL_ENTER, + GRID_CELL_KEYDOWN, GRID_CELL_LEAVE, + GRID_CELL_MOUSE_DOWN, GRID_CELL_OUT, GRID_CELL_OVER, -} from '../constants/eventsConstants'; -import { GridAlignment, GridCellValue, GridRowId } from '../models'; -import { classnames } from '../utils'; -import { GridApiContext } from './GridApiContext'; +} from '../../constants/eventsConstants'; +import { GridAlignment, GridCellMode, GridCellValue, GridRowId } from '../../models/index'; +import { classnames } from '../../utils/index'; +import { GridApiContext } from '../GridApiContext'; export interface GridCellProps { align: GridAlignment; @@ -25,9 +27,9 @@ export interface GridCellProps { isEditable?: boolean; rowIndex?: number; showRightBorder?: boolean; - tabIndex?: number; value?: GridCellValue; width: number; + cellMode?: GridCellMode; } export const GridCell: React.FC = React.memo((props) => { @@ -35,6 +37,7 @@ export const GridCell: React.FC = React.memo((props) => { align, children, colIndex, + cellMode, cssClass, field, formattedValue, @@ -44,7 +47,6 @@ export const GridCell: React.FC = React.memo((props) => { rowIndex, rowId, showRightBorder, - tabIndex, value, width, } = props; @@ -63,12 +65,6 @@ export const GridCell: React.FC = React.memo((props) => { }, ); - React.useEffect(() => { - if (hasFocus && cellRef.current) { - cellRef.current.focus(); - } - }, [hasFocus]); - const publishClick = React.useCallback( (eventName: string) => (event: React.MouseEvent) => { const params = apiRef!.current.getCellParams(rowId, field || ''); @@ -81,7 +77,7 @@ export const GridCell: React.FC = React.memo((props) => { ); const publish = React.useCallback( - (eventName: string) => (event: React.MouseEvent) => + (eventName: string) => (event: React.SyntheticEvent) => apiRef!.current.publishEvent( eventName, apiRef!.current.getCellParams(rowId!, field || ''), @@ -90,14 +86,16 @@ export const GridCell: React.FC = React.memo((props) => { [apiRef, field, rowId], ); - const mouseEventsHandlers = React.useMemo( + const eventsHandlers = React.useMemo( () => ({ onClick: publishClick(GRID_CELL_CLICK), onDoubleClick: publish(GRID_CELL_DOUBLE_CLICK), + onMouseDown: publish(GRID_CELL_MOUSE_DOWN), onMouseOver: publish(GRID_CELL_OVER), onMouseOut: publish(GRID_CELL_OUT), onMouseEnter: publish(GRID_CELL_ENTER), onMouseLeave: publish(GRID_CELL_LEAVE), + onKeyDown: publish(GRID_CELL_KEYDOWN), }), [publish, publishClick], ); @@ -110,6 +108,12 @@ export const GridCell: React.FC = React.memo((props) => { maxHeight: height, }; + React.useLayoutEffect(() => { + if (hasFocus && cellMode === 'view' && cellRef.current) { + cellRef.current!.focus(); + } + }); + return (
= React.memo((props) => { data-field={field} data-rowindex={rowIndex} data-editable={isEditable} + data-mode={cellMode} aria-colindex={colIndex} style={style} - tabIndex={tabIndex} - {...mouseEventsHandlers} + /* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */ + tabIndex={hasFocus ? 0 : -1} + {...eventsHandlers} > {children || valueToRender?.toString()}
diff --git a/packages/grid/_modules_/grid/components/editCell/EditInputCell.tsx b/packages/grid/_modules_/grid/components/cell/GridEditInputCell.tsx similarity index 55% rename from packages/grid/_modules_/grid/components/editCell/EditInputCell.tsx rename to packages/grid/_modules_/grid/components/cell/GridEditInputCell.tsx index 16cb6306d2777..ce348ecc9ea35 100644 --- a/packages/grid/_modules_/grid/components/editCell/EditInputCell.tsx +++ b/packages/grid/_modules_/grid/components/cell/GridEditInputCell.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import InputBase, { InputBaseProps } from '@material-ui/core/InputBase'; +import { GRID_CELL_EDIT_BLUR } from '../../constants/eventsConstants'; import { GridCellParams } from '../../models/params/gridCellParams'; +import { isCellEditCommitKeys } from '../../utils/keyboardUtils'; import { formatDateToLocalInputDate, isDate, mapColDefTypeToInputType } from '../../utils/utils'; -import { GridEditRowUpdate } from '../../models/gridEditRowModel'; -import { GridEditRowApi } from '../../models/api/gridEditRowApi'; -export function EditInputCell(props: GridCellParams & InputBaseProps) { +export function GridEditInputCell(props: GridCellParams & InputBaseProps) { const { id, value, @@ -14,6 +14,7 @@ export function EditInputCell(props: GridCellParams & InputBaseProps) { field, row, colDef, + cellMode, getValue, rowIndex, colIndex, @@ -21,35 +22,37 @@ export function EditInputCell(props: GridCellParams & InputBaseProps) { ...inputBaseProps } = props; - const editRowApi = api as GridEditRowApi; const [valueState, setValueState] = React.useState(value); - const onValueChange = React.useCallback( + const handleBlur = React.useCallback( + (event: React.SyntheticEvent) => { + const params = api.getCellParams(id, field); + api.publishEvent(GRID_CELL_EDIT_BLUR, params, event); + }, + [api, field, id], + ); + + const handleChange = React.useCallback( (event) => { const newValue = event.target.value; - const update: GridEditRowUpdate = {}; - update[field] = { + const editProps = { value: colDef.type === 'date' || colDef.type === 'dateTime' ? new Date(newValue) : newValue, }; setValueState(newValue); - editRowApi.setEditCellProps(row.id, update); + api.setEditCellProps({ id, field, props: editProps }); }, - [editRowApi, colDef.type, field, row.id], + [api, colDef.type, field, id], ); - const onKeyDown = React.useCallback( + const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if (!inputBaseProps.error && event.key === 'Enter') { - const update: GridEditRowUpdate = {}; - update[field] = { value }; - editRowApi.commitCellChange(row.id, update); - } - - if (event.key === 'Escape') { - editRowApi.setCellMode(row.id, field, 'view'); + if (inputBaseProps.error && isCellEditCommitKeys(event.key)) { + // Account for when tab/enter is pressed + event.preventDefault(); + event.stopPropagation(); } }, - [inputBaseProps.error, row.id, field, value, editRowApi], + [inputBaseProps.error], ); const inputType = mapColDefTypeToInputType(colDef.type); @@ -65,14 +68,15 @@ export function EditInputCell(props: GridCellParams & InputBaseProps) { return ( ); } -export const renderEditInputCell = (params) => ; +export const renderEditInputCell = (params) => ; diff --git a/packages/grid/_modules_/grid/components/GridEmptyCell.tsx b/packages/grid/_modules_/grid/components/cell/GridEmptyCell.tsx similarity index 87% rename from packages/grid/_modules_/grid/components/GridEmptyCell.tsx rename to packages/grid/_modules_/grid/components/cell/GridEmptyCell.tsx index 02ba236662184..cba118e019dff 100644 --- a/packages/grid/_modules_/grid/components/GridEmptyCell.tsx +++ b/packages/grid/_modules_/grid/components/cell/GridEmptyCell.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { GRID_CELL_CSS_CLASS } from '../constants/cssClassesConstants'; +import { GRID_CELL_CSS_CLASS } from '../../constants/cssClassesConstants'; export interface GridEmptyCellProps { width?: number; diff --git a/packages/grid/_modules_/grid/components/GridRowCells.tsx b/packages/grid/_modules_/grid/components/cell/GridRowCells.tsx similarity index 89% rename from packages/grid/_modules_/grid/components/GridRowCells.tsx rename to packages/grid/_modules_/grid/components/cell/GridRowCells.tsx index 4bfa1e3d34adc..e49b20d0d34f1 100644 --- a/packages/grid/_modules_/grid/components/GridRowCells.tsx +++ b/packages/grid/_modules_/grid/components/cell/GridRowCells.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { gridEditRowsStateSelector } from '../hooks/features/rows/gridEditRowsSelector'; +import { gridEditRowsStateSelector } from '../../hooks/features/rows/gridEditRowsSelector'; import { GridCellClassParams, GridColumns, @@ -7,12 +7,12 @@ import { GridCellClassRules, GridCellParams, GridCellIndexCoordinates, -} from '../models'; +} from '../../models/index'; import { GridCell, GridCellProps } from './GridCell'; -import { GridApiContext } from './GridApiContext'; -import { classnames, isFunction } from '../utils'; -import { gridDensityRowHeightSelector } from '../hooks/features/density/densitySelector'; -import { useGridSelector } from '../hooks/features/core/useGridSelector'; +import { GridApiContext } from '../GridApiContext'; +import { classnames, isFunction } from '../../utils/index'; +import { gridDensityRowHeightSelector } from '../../hooks/features/density/densitySelector'; +import { useGridSelector } from '../../hooks/features/core/useGridSelector'; function applyCssClassRules(cellClassRules: GridCellClassRules, params: GridCellClassParams) { return Object.entries(cellClassRules).reduce((appliedCss, entry) => { @@ -24,7 +24,6 @@ function applyCssClassRules(cellClassRules: GridCellClassRules, params: GridCell interface RowCellsProps { columns: GridColumns; - domIndex: number; extendRowFullWidth: boolean; firstColIdx: number; hasScroll: { y: boolean; x: boolean }; @@ -39,7 +38,6 @@ interface RowCellsProps { export const GridRowCells: React.FC = React.memo((props) => { const { columns, - domIndex, firstColIdx, hasScroll, lastColIdx, @@ -102,8 +100,8 @@ export const GridRowCells: React.FC = React.memo((props) => { formattedValue: cellParams.formattedValue, align: column.align || 'left', ...cssClassProp, - tabIndex: domIndex === 0 && colIdx === 0 ? 0 : -1, rowIndex, + cellMode: cellParams.cellMode, colIndex: cellParams.colIndex, children: cellComponent, isEditable: cellParams.isEditable, diff --git a/packages/grid/_modules_/grid/components/cell/index.ts b/packages/grid/_modules_/grid/components/cell/index.ts new file mode 100644 index 0000000000000..8d5f70eb20a5c --- /dev/null +++ b/packages/grid/_modules_/grid/components/cell/index.ts @@ -0,0 +1,4 @@ +export * from './GridCell'; +export * from './GridEditInputCell'; +export * from './GridEmptyCell'; +export * from './GridRowCells'; diff --git a/packages/grid/_modules_/grid/components/columnHeaders/GridColumnHeaders.tsx b/packages/grid/_modules_/grid/components/columnHeaders/GridColumnHeaders.tsx index d52ff534cedd1..4386f018459e0 100644 --- a/packages/grid/_modules_/grid/components/columnHeaders/GridColumnHeaders.tsx +++ b/packages/grid/_modules_/grid/components/columnHeaders/GridColumnHeaders.tsx @@ -5,7 +5,7 @@ import { useGridSelector } from '../../hooks/features/core/useGridSelector'; import { renderStateSelector } from '../../hooks/features/virtualization/renderingStateSelector'; import { optionsSelector } from '../../hooks/utils/optionsSelector'; import { GridApiContext } from '../GridApiContext'; -import { GridEmptyCell } from '../GridEmptyCell'; +import { GridEmptyCell } from '../cell/GridEmptyCell'; import { GridScrollArea } from '../GridScrollArea'; import { GridColumnHeadersItemCollection } from './GridColumnHeadersItemCollection'; import { gridDensityHeaderHeightSelector } from '../../hooks/features/density/densitySelector'; diff --git a/packages/grid/_modules_/grid/components/index.ts b/packages/grid/_modules_/grid/components/index.ts index f25672967a6ca..f514318c49e69 100644 --- a/packages/grid/_modules_/grid/components/index.ts +++ b/packages/grid/_modules_/grid/components/index.ts @@ -1,12 +1,13 @@ +export * from './cell'; export * from './containers'; export * from './columnHeaders'; export * from './icons'; export * from './menu'; export * from './panel'; export * from './toolbar'; + export * from './GridApiContext'; export * from './GridAutoSizer'; -export * from './GridCell'; export * from './GridCheckboxRenderer'; export * from './GridFooter'; export * from './GridHeader'; @@ -14,7 +15,6 @@ export * from './GridLoadingOverlay'; export * from './GridNoRowsOverlay'; export * from './GridPagination'; export * from './GridRenderingZone'; -export * from './GridRowCells'; export * from './GridRowCount'; export * from './GridRow'; export * from './GridSelectedRowCount'; @@ -22,4 +22,3 @@ export * from './GridStickyContainer'; export * from './GridViewport'; export * from './Watermark'; export * from './GridScrollArea'; -export * from './GridEmptyCell'; diff --git a/packages/grid/_modules_/grid/components/menu/columnMenu/GridColumnMenu.tsx b/packages/grid/_modules_/grid/components/menu/columnMenu/GridColumnMenu.tsx index 6398949a9cb51..59aa2dca41274 100644 --- a/packages/grid/_modules_/grid/components/menu/columnMenu/GridColumnMenu.tsx +++ b/packages/grid/_modules_/grid/components/menu/columnMenu/GridColumnMenu.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import MenuList from '@material-ui/core/MenuList'; import { GridColDef } from '../../../models/colDef/gridColDef'; +import { isHideMenuKey, isTabKey } from '../../../utils/keyboardUtils'; import { GridColumnsMenuItem } from './GridColumnsMenuItem'; import { GridFilterMenuItem } from './GridFilterMenuItem'; import { HideGridColMenuItem } from './HideGridColMenuItem'; @@ -18,10 +19,10 @@ export function GridColumnMenu(props: GridColumnMenuProps) { const { hideMenu, currentColumn, open, id, labelledby } = props; const handleListKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if (event.key === 'Tab') { + if (isTabKey(event.key)) { event.preventDefault(); } - if (event.key === 'Tab' || event.key === 'Escape') { + if (isHideMenuKey(event.key)) { hideMenu(); } }, diff --git a/packages/grid/_modules_/grid/components/panel/GridPanel.tsx b/packages/grid/_modules_/grid/components/panel/GridPanel.tsx index 55c06435629ec..592aa16a328c2 100644 --- a/packages/grid/_modules_/grid/components/panel/GridPanel.tsx +++ b/packages/grid/_modules_/grid/components/panel/GridPanel.tsx @@ -4,7 +4,7 @@ import ClickAwayListener from '@material-ui/core/ClickAwayListener'; import Paper from '@material-ui/core/Paper'; import Popper from '@material-ui/core/Popper'; import { GridApiContext } from '../GridApiContext'; -import { isMuiV5 } from '../../utils'; +import { isEscapeKey, isMuiV5 } from '../../utils'; export interface GridPanelProps { children?: React.ReactNode; @@ -50,8 +50,8 @@ export function GridPanel(props: GridPanelProps) { }, [apiRef]); const handleKeyDown = React.useCallback( - (event) => { - if (event.key === 'Escape') { + (event: React.KeyboardEvent) => { + if (isEscapeKey(event.key)) { apiRef!.current.hidePreferences(); } }, diff --git a/packages/grid/_modules_/grid/components/toolbar/GridDensitySelector.tsx b/packages/grid/_modules_/grid/components/toolbar/GridDensitySelector.tsx index 6178566f6f156..02e49904250fc 100644 --- a/packages/grid/_modules_/grid/components/toolbar/GridDensitySelector.tsx +++ b/packages/grid/_modules_/grid/components/toolbar/GridDensitySelector.tsx @@ -7,6 +7,7 @@ import MenuItem from '@material-ui/core/MenuItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import { gridDensityValueSelector } from '../../hooks/features/density/densitySelector'; import { GridDensity, GridDensityTypes } from '../../models/gridDensity'; +import { isHideMenuKey, isTabKey } from '../../utils/keyboardUtils'; import { GridApiContext } from '../GridApiContext'; import { useGridSelector } from '../../hooks/features/core/useGridSelector'; import { optionsSelector } from '../../hooks/utils/optionsSelector'; @@ -62,10 +63,10 @@ export function GridDensitySelector() { }; const handleListKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Tab') { + if (isTabKey(event.key)) { event.preventDefault(); } - if (event.key === 'Tab' || event.key === 'Escape') { + if (isHideMenuKey(event.key)) { handleDensitySelectorClose(); } }; diff --git a/packages/grid/_modules_/grid/components/toolbar/GridToolbarExport.tsx b/packages/grid/_modules_/grid/components/toolbar/GridToolbarExport.tsx index d6df6e9453909..5c54b1b03b643 100644 --- a/packages/grid/_modules_/grid/components/toolbar/GridToolbarExport.tsx +++ b/packages/grid/_modules_/grid/components/toolbar/GridToolbarExport.tsx @@ -4,6 +4,7 @@ import { unstable_useId as useId } from '@material-ui/core/utils'; import MenuList from '@material-ui/core/MenuList'; import Button from '@material-ui/core/Button'; import MenuItem from '@material-ui/core/MenuItem'; +import { isHideMenuKey, isTabKey } from '../../utils/keyboardUtils'; import { GridApiContext } from '../GridApiContext'; import { GridMenu } from '../menu/GridMenu'; import { GridExportOption } from '../../models'; @@ -33,10 +34,10 @@ export function GridToolbarExport() { }; const handleListKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Tab') { + if (isTabKey(event.key)) { event.preventDefault(); } - if (event.key === 'Tab' || event.key === 'Escape') { + if (isHideMenuKey(event.key)) { handleExportSelectorClose(); } }; diff --git a/packages/grid/_modules_/grid/constants/eventsConstants.ts b/packages/grid/_modules_/grid/constants/eventsConstants.ts index 1c9e89fe890f8..1ea18c619cd89 100644 --- a/packages/grid/_modules_/grid/constants/eventsConstants.ts +++ b/packages/grid/_modules_/grid/constants/eventsConstants.ts @@ -11,15 +11,23 @@ export const GRID_COMPONENT_ERROR = 'componentError'; export const GRID_UNMOUNT = 'unmount'; export const GRID_ELEMENT_FOCUS_OUT = 'gridFocusOut'; -export const GRID_CELL_CHANGE = 'cellChange'; -export const GRID_CELL_CHANGE_COMMITTED = 'cellChangeCommitted'; export const GRID_CELL_MODE_CHANGE = 'cellModeChange'; export const GRID_CELL_CLICK = 'cellClick'; export const GRID_CELL_DOUBLE_CLICK = 'cellDoubleClick'; +export const GRID_CELL_MOUSE_DOWN = 'cellMouseDown'; export const GRID_CELL_OVER = 'cellOver'; export const GRID_CELL_OUT = 'cellOut'; export const GRID_CELL_ENTER = 'cellEnter'; export const GRID_CELL_LEAVE = 'cellLeave'; +export const GRID_CELL_KEYDOWN = 'cellKeyDown'; +export const GRID_CELL_EDIT_BLUR = 'cellEditBlur'; +export const GRID_CELL_EDIT_PROPS_CHANGE = 'cellEditPropsChange'; +export const GRID_CELL_EDIT_PROPS_CHANGE_COMMITTED = 'cellEditPropsChangeCommitted'; +export const GRID_CELL_VALUE_CHANGE = 'cellValueChange'; + +export const GRID_CELL_ENTER_EDIT = 'cellEnterEdit'; +export const GRID_CELL_EXIT_EDIT = 'cellExitEdit'; +export const GRID_CELL_NAVIGATION_KEYDOWN = 'cellNavigationKeyDown'; export const GRID_ROW_CLICK = 'rowClick'; export const GRID_ROW_DOUBLE_CLICK = 'rowDoubleClick'; @@ -27,6 +35,8 @@ export const GRID_ROW_OVER = 'rowOver'; export const GRID_ROW_OUT = 'rowOut'; export const GRID_ROW_ENTER = 'rowEnter'; export const GRID_ROW_LEAVE = 'rowLeave'; +export const GRID_ROW_EDIT_MODEL_CHANGE = 'editRowModelChange'; +export const GRID_ROW_SELECTED = 'rowSelected'; export const GRID_COLUMN_HEADER_CLICK = 'columnHeaderClick'; export const GRID_COLUMN_HEADER_DOUBLE_CLICK = 'columnHeaderDoubleClick'; @@ -35,8 +45,6 @@ export const GRID_COLUMN_HEADER_OUT = 'columnHeaderOut'; export const GRID_COLUMN_HEADER_ENTER = 'columnHeaderEnter'; export const GRID_COLUMN_HEADER_LEAVE = 'columnHeaderLeave'; -export const GRID_EDIT_ROW_MODEL_CHANGE = 'editRowModelChange'; -export const GRID_ROW_SELECTED = 'rowSelected'; export const GRID_SELECTION_CHANGED = 'selectionChange'; export const GRID_PAGE_CHANGED = 'pageChange'; diff --git a/packages/grid/_modules_/grid/hooks/features/core/useGridState.ts b/packages/grid/_modules_/grid/hooks/features/core/useGridState.ts index eff02f4338445..aca1d7efcb791 100644 --- a/packages/grid/_modules_/grid/hooks/features/core/useGridState.ts +++ b/packages/grid/_modules_/grid/hooks/features/core/useGridState.ts @@ -7,7 +7,7 @@ import { useGridApi } from './useGridApi'; export const useGridState = ( apiRef: GridApiRef, -): [GridState, (stateUpdaterFn: (oldState: GridState) => GridState) => void, () => void] => { +): [GridState, (stateUpdaterFn: (oldState: GridState) => GridState) => boolean, () => void] => { useGridApi(apiRef); const forceUpdate = React.useCallback( () => apiRef.current.forceUpdate(() => apiRef.current.state), @@ -26,6 +26,7 @@ export const useGridState = ( const params: GridStateChangeParams = { api: apiRef.current, state: newState }; apiRef.current.publishEvent(GRID_STATE_CHANGE, params); } + return hasChanged; }, [apiRef], ); diff --git a/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts b/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts index 1d3379ceccc19..40feeccbddb4b 100644 --- a/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts +++ b/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts @@ -1,163 +1,57 @@ import * as React from 'react'; -import { GRID_CELL_CSS_CLASS, GRID_ROW_CSS_CLASS } from '../../../constants/cssClassesConstants'; +import { GRID_ROW_CSS_CLASS } from '../../../constants/cssClassesConstants'; import { + GRID_CELL_KEYDOWN, + GRID_CELL_NAVIGATION_KEYDOWN, GRID_ELEMENT_FOCUS_OUT, GRID_KEYDOWN, GRID_KEYUP, GRID_MULTIPLE_KEY_PRESS_CHANGED, } from '../../../constants/eventsConstants'; import { GridApiRef } from '../../../models/api/gridApiRef'; -import { GridCellIndexCoordinates } from '../../../models/gridCell'; +import { GridCellParams } from '../../../models/params/gridCellParams'; import { findParentElementFromClassName, getIdFromRowElem, getRowEl, isGridCellRoot, } from '../../../utils/domUtils'; -import { - isArrowKeys, - isHomeOrEndKeys, - isMultipleKey, - isNavigationKey, - isPageKeys, - isSpaceKey, -} from '../../../utils/keyboardUtils'; -import { optionsSelector } from '../../utils/optionsSelector'; -import { visibleGridColumnsLengthSelector } from '../columns/gridColumnsSelector'; +import { isMultipleKey, isNavigationKey, isSpaceKey, isTabKey } from '../../../utils/keyboardUtils'; import { useGridSelector } from '../core/useGridSelector'; import { useGridState } from '../core/useGridState'; -import { gridPaginationSelector } from '../pagination/gridPaginationSelector'; -import { gridRowCountSelector } from '../rows/gridRowsSelector'; import { useLogger } from '../../utils/useLogger'; import { useGridApiEventHandler } from '../../root/useGridApiEventHandler'; import { gridSelectionStateSelector } from '../selection/gridSelectionSelector'; import { KeyboardState } from './keyboardState'; -import { gridContainerSizesSelector } from '../../root/gridContainerSizesSelector'; - -const getNextCellIndexes = (code: string, indexes: GridCellIndexCoordinates) => { - if (!isArrowKeys(code)) { - throw new Error('Material-UI: The first argument (code) should be an arrow key code.'); - } - - if (code === 'ArrowLeft') { - return { ...indexes, colIndex: indexes.colIndex - 1 }; - } - if (code === 'ArrowRight') { - return { ...indexes, colIndex: indexes.colIndex + 1 }; - } - if (code === 'ArrowUp') { - return { ...indexes, rowIndex: indexes.rowIndex - 1 }; - } - // Last option code === 'ArrowDown' - return { ...indexes, rowIndex: indexes.rowIndex + 1 }; -}; export const useGridKeyboard = ( gridRootRef: React.RefObject, apiRef: GridApiRef, ): void => { const logger = useLogger('useGridKeyboard'); - const options = useGridSelector(apiRef, optionsSelector); const [, setGridState, forceUpdate] = useGridState(apiRef); - const paginationState = useGridSelector(apiRef, gridPaginationSelector); - const totalRowCount = useGridSelector(apiRef, gridRowCountSelector); - const colCount = useGridSelector(apiRef, visibleGridColumnsLengthSelector); - const containerSizes = useGridSelector(apiRef, gridContainerSizesSelector); const selectionState = useGridSelector(apiRef, gridSelectionStateSelector); - const onMultipleKeyChange = React.useCallback( + const setMultipleKeyState = React.useCallback( (isPressed: boolean) => { - setGridState((state) => { + const hasChanged = setGridState((state) => { + if (state.keyboard.isMultipleKeyPressed === isPressed) { + return state; + } + logger.debug(`Toggling keyboard multiple key pressed to ${isPressed}`); const keyboardState: KeyboardState = { ...state.keyboard, isMultipleKeyPressed: isPressed }; return { ...state, keyboard: keyboardState }; }); - forceUpdate(); - - apiRef.current.publishEvent(GRID_MULTIPLE_KEY_PRESS_CHANGED, isPressed); - }, - [apiRef, forceUpdate, logger, setGridState], - ); - - const navigateCells = React.useCallback( - (key: string, isCtrlPressed: boolean) => { - const cellEl = findParentElementFromClassName( - document.activeElement as HTMLDivElement, - GRID_CELL_CSS_CLASS, - )! as HTMLElement; - cellEl.tabIndex = -1; - - const currentColIndex = Number(cellEl.getAttribute('aria-colindex')); - const currentRowIndex = Number(cellEl.getAttribute('data-rowindex')); - const rowCount = options.pagination - ? paginationState.pageSize * (paginationState.page + 1) - : totalRowCount; - let nextCellIndexes: GridCellIndexCoordinates; - if (isArrowKeys(key)) { - nextCellIndexes = getNextCellIndexes(key, { - colIndex: currentColIndex, - rowIndex: currentRowIndex, - }); - } else if (isHomeOrEndKeys(key)) { - const colIdx = key === 'Home' ? 0 : colCount - 1; - - if (!isCtrlPressed) { - // we go to the current row, first col, or last col! - nextCellIndexes = { colIndex: colIdx, rowIndex: currentRowIndex }; - } else { - // In that case we go to first row, first col, or last row last col! - let rowIndex = 0; - if (colIdx === 0) { - rowIndex = options.pagination ? rowCount - paginationState.pageSize : 0; - } else { - rowIndex = rowCount - 1; - } - nextCellIndexes = { colIndex: colIdx, rowIndex }; - } - } else if (isPageKeys(key) || isSpaceKey(key)) { - const nextRowIndex = - currentRowIndex + - (key.indexOf('Down') > -1 || isSpaceKey(key) - ? containerSizes!.viewportPageSize - : -1 * containerSizes!.viewportPageSize); - nextCellIndexes = { colIndex: currentColIndex, rowIndex: nextRowIndex }; - } else { - throw new Error('Material-UI. Key not mapped to navigation behavior.'); + if (!hasChanged) { + return; } - nextCellIndexes.rowIndex = nextCellIndexes.rowIndex <= 0 ? 0 : nextCellIndexes.rowIndex; - nextCellIndexes.rowIndex = - nextCellIndexes.rowIndex >= rowCount && rowCount > 0 - ? rowCount - 1 - : nextCellIndexes.rowIndex; - - nextCellIndexes.colIndex = nextCellIndexes.colIndex <= 0 ? 0 : nextCellIndexes.colIndex; - nextCellIndexes.colIndex = - nextCellIndexes.colIndex >= colCount ? colCount - 1 : nextCellIndexes.colIndex; - - apiRef.current.scrollToIndexes(nextCellIndexes); - - setGridState((state) => { - logger.debug(`Setting keyboard state, cell focus to ${JSON.stringify(nextCellIndexes)}`); - return { ...state, keyboard: { ...state.keyboard, cell: nextCellIndexes } }; - }); forceUpdate(); - - return nextCellIndexes; + apiRef.current.publishEvent(GRID_MULTIPLE_KEY_PRESS_CHANGED, isPressed); }, - [ - options.pagination, - paginationState.pageSize, - paginationState.page, - totalRowCount, - colCount, - apiRef, - setGridState, - forceUpdate, - containerSizes, - logger, - ], + [apiRef, forceUpdate, logger, setGridState], ); const selectActiveRow = React.useCallback(() => { @@ -171,7 +65,7 @@ export const useGridKeyboard = ( }, [apiRef]); const expandSelection = React.useCallback( - (key: string) => { + (params: GridCellParams, event: React.KeyboardEvent) => { const rowEl = findParentElementFromClassName( document.activeElement as HTMLDivElement, GRID_ROW_CSS_CLASS, @@ -194,7 +88,9 @@ export const useGridKeyboard = ( selectionFromRowIndex = selectedRowsIndex[diffWithCurrentIndex.indexOf(minIndex)]; } - const nextCellIndexes = navigateCells(key, false); + apiRef.current.publishEvent(GRID_CELL_NAVIGATION_KEYDOWN, params, event); + + const nextCellIndexes = apiRef.current.getState().keyboard.cell!; // We select the rows in between const rowIds = Array(Math.abs(nextCellIndexes.rowIndex - selectionFromRowIndex) + 1) .fill( @@ -208,7 +104,7 @@ export const useGridKeyboard = ( apiRef.current.selectRows(rowIds, true, true); }, - [logger, apiRef, navigateCells], + [logger, apiRef], ); const handleCopy = React.useCallback(() => { @@ -224,13 +120,36 @@ export const useGridKeyboard = ( document.execCommand('copy'); }, [selectionState]); - const onKeyDownHandler = React.useCallback( + const handleKeyDown = React.useCallback( (event: KeyboardEvent) => { if (isMultipleKey(event.key)) { logger.debug('Multiple Select key pressed'); - onMultipleKeyChange(true); + setMultipleKeyState(true); } + }, + [logger, setMultipleKeyState], + ); + + const handleKeyUp = React.useCallback( + (event: KeyboardEvent) => { + if (isMultipleKey(event.key)) { + logger.debug('Multiple Select key released'); + setMultipleKeyState(false); + } + }, + [logger, setMultipleKeyState], + ); + const handleFocusOut = React.useCallback( + (args) => { + logger.debug('Grid lost focus, releasing key press', args); + setMultipleKeyState(false); + }, + [logger, setMultipleKeyState], + ); + + const handleCellKeyDown = React.useCallback( + (params: GridCellParams, event: React.KeyboardEvent) => { if (!isGridCellRoot(document.activeElement)) { return; } @@ -241,15 +160,14 @@ export const useGridKeyboard = ( return; } - if (isNavigationKey(event.key) && !event.shiftKey) { - event.preventDefault(); - navigateCells(event.key, event.ctrlKey || event.metaKey); + if ((isNavigationKey(event.key) && !event.shiftKey) || isTabKey(event.key)) { + apiRef.current.publishEvent(GRID_CELL_NAVIGATION_KEYDOWN, params, event); return; } if (isNavigationKey(event.key) && event.shiftKey) { event.preventDefault(); - expandSelection(event.key); + expandSelection(params, event); return; } @@ -263,38 +181,11 @@ export const useGridKeyboard = ( apiRef.current.selectRows(apiRef.current.getAllRowIds(), true); } }, - [ - apiRef, - logger, - onMultipleKeyChange, - expandSelection, - handleCopy, - navigateCells, - selectActiveRow, - ], - ); - - const onKeyUpHandler = React.useCallback( - (event: KeyboardEvent) => { - if (isMultipleKey(event.key)) { - logger.debug('Multiple Select key released'); - onMultipleKeyChange(false); - } - }, - [logger, onMultipleKeyChange], - ); - - const onFocusOutHandler = React.useCallback( - (args) => { - logger.debug('Grid lost focus, releasing key press', args); - if (apiRef.current.getState().keyboard.isMultipleKeyPressed) { - onMultipleKeyChange(false); - } - }, - [apiRef, logger, onMultipleKeyChange], + [apiRef, expandSelection, handleCopy, selectActiveRow], ); - useGridApiEventHandler(apiRef, GRID_KEYDOWN, onKeyDownHandler); - useGridApiEventHandler(apiRef, GRID_KEYUP, onKeyUpHandler); - useGridApiEventHandler(apiRef, GRID_ELEMENT_FOCUS_OUT, onFocusOutHandler); + useGridApiEventHandler(apiRef, GRID_KEYDOWN, handleKeyDown); + useGridApiEventHandler(apiRef, GRID_CELL_KEYDOWN, handleCellKeyDown); + useGridApiEventHandler(apiRef, GRID_KEYUP, handleKeyUp); + useGridApiEventHandler(apiRef, GRID_ELEMENT_FOCUS_OUT, handleFocusOut); }; diff --git a/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboardNavigation.ts b/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboardNavigation.ts new file mode 100644 index 0000000000000..eed2b76b6c2ec --- /dev/null +++ b/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboardNavigation.ts @@ -0,0 +1,160 @@ +import * as React from 'react'; +import { GRID_CELL_NAVIGATION_KEYDOWN } from '../../../constants/eventsConstants'; +import { GridApiRef } from '../../../models/api/gridApiRef'; +import { GridNavigationApi } from '../../../models/api/gridNavigationApi'; +import { GridCellIndexCoordinates } from '../../../models/gridCell'; +import { GridCellParams } from '../../../models/params/gridCellParams'; +import { + isArrowKeys, + isEnterKey, + isHomeOrEndKeys, + isPageKeys, + isSpaceKey, + isTabKey, +} from '../../../utils/keyboardUtils'; +import { gridContainerSizesSelector } from '../../root/gridContainerSizesSelector'; +import { useGridApiMethod } from '../../root/useGridApiMethod'; +import { optionsSelector } from '../../utils/optionsSelector'; +import { visibleGridColumnsLengthSelector } from '../columns/gridColumnsSelector'; +import { useGridSelector } from '../core/useGridSelector'; +import { useGridState } from '../core/useGridState'; +import { gridPaginationSelector } from '../pagination/gridPaginationSelector'; +import { gridRowCountSelector } from '../rows/gridRowsSelector'; +import { useLogger } from '../../utils/useLogger'; +import { useGridApiEventHandler } from '../../root/useGridApiEventHandler'; + +const getNextCellIndexes = (code: string, indexes: GridCellIndexCoordinates) => { + if (!isArrowKeys(code)) { + throw new Error('Material-UI: The first argument (code) should be an arrow key code.'); + } + + if (code === 'ArrowLeft') { + return { ...indexes, colIndex: indexes.colIndex - 1 }; + } + if (code === 'ArrowRight') { + return { ...indexes, colIndex: indexes.colIndex + 1 }; + } + if (code === 'ArrowUp') { + return { ...indexes, rowIndex: indexes.rowIndex - 1 }; + } + // Last option code === 'ArrowDown' + return { ...indexes, rowIndex: indexes.rowIndex + 1 }; +}; + +export const useGridKeyboardNavigation = ( + gridRootRef: React.RefObject, + apiRef: GridApiRef, +): void => { + const logger = useLogger('useGridKeyboardNavigation'); + const options = useGridSelector(apiRef, optionsSelector); + const [, setGridState, forceUpdate] = useGridState(apiRef); + const paginationState = useGridSelector(apiRef, gridPaginationSelector); + const totalRowCount = useGridSelector(apiRef, gridRowCountSelector); + const colCount = useGridSelector(apiRef, visibleGridColumnsLengthSelector); + const containerSizes = useGridSelector(apiRef, gridContainerSizesSelector); + + const mapKey = (event: React.KeyboardEvent) => { + if (isEnterKey(event.key)) { + return 'ArrowDown'; + } + if (isTabKey(event.key)) { + return event.shiftKey ? 'ArrowLeft' : 'ArrowRight'; + } + return event.key; + }; + + const navigateCells = React.useCallback( + (params: GridCellParams, event: React.KeyboardEvent) => { + event.preventDefault(); + + const key = mapKey(event); + const isCtrlPressed = event.ctrlKey || event.metaKey || event.shiftKey; + + const cellEl = params.element!; + cellEl.tabIndex = -1; + + const currentColIndex = Number(cellEl.getAttribute('aria-colindex')); + const currentRowIndex = Number(cellEl.getAttribute('data-rowindex')); + const rowCount = options.pagination + ? paginationState.pageSize * (paginationState.page + 1) + : totalRowCount; + + let nextCellIndexes: GridCellIndexCoordinates; + if (isArrowKeys(key)) { + nextCellIndexes = getNextCellIndexes(key, { + colIndex: currentColIndex, + rowIndex: currentRowIndex, + }); + } else if (isHomeOrEndKeys(key)) { + const colIdx = key === 'Home' ? 0 : colCount - 1; + + if (!isCtrlPressed) { + // we go to the current row, first col, or last col! + nextCellIndexes = { colIndex: colIdx, rowIndex: currentRowIndex }; + } else { + // In that case we go to first row, first col, or last row last col! + let rowIndex = 0; + if (colIdx === 0) { + rowIndex = options.pagination ? rowCount - paginationState.pageSize : 0; + } else { + rowIndex = rowCount - 1; + } + nextCellIndexes = { colIndex: colIdx, rowIndex }; + } + } else if (isPageKeys(key) || isSpaceKey(key)) { + const nextRowIndex = + currentRowIndex + + (key.indexOf('Down') > -1 || isSpaceKey(key) + ? containerSizes!.viewportPageSize + : -1 * containerSizes!.viewportPageSize); + nextCellIndexes = { colIndex: currentColIndex, rowIndex: nextRowIndex }; + } else { + throw new Error('Material-UI. Key not mapped to navigation behavior.'); + } + + nextCellIndexes.rowIndex = nextCellIndexes.rowIndex <= 0 ? 0 : nextCellIndexes.rowIndex; + nextCellIndexes.rowIndex = + nextCellIndexes.rowIndex >= rowCount && rowCount > 0 + ? rowCount - 1 + : nextCellIndexes.rowIndex; + + nextCellIndexes.colIndex = nextCellIndexes.colIndex <= 0 ? 0 : nextCellIndexes.colIndex; + nextCellIndexes.colIndex = + nextCellIndexes.colIndex >= colCount ? colCount - 1 : nextCellIndexes.colIndex; + apiRef.current.setCellFocus(nextCellIndexes); + }, + [ + options.pagination, + paginationState.pageSize, + paginationState.page, + totalRowCount, + colCount, + apiRef, + containerSizes, + ], + ); + + const setCellFocus = React.useCallback( + (nextCellIndexes: GridCellIndexCoordinates) => { + apiRef.current.scrollToIndexes(nextCellIndexes); + + setGridState((state) => { + logger.debug( + `Focusing on cell with rowIndex=${nextCellIndexes.rowIndex} and colIndex=${nextCellIndexes.colIndex}`, + ); + return { ...state, keyboard: { ...state.keyboard, cell: nextCellIndexes } }; + }); + forceUpdate(); + }, + [apiRef, forceUpdate, logger, setGridState], + ); + + useGridApiMethod( + apiRef, + { + setCellFocus, + }, + 'GridNavigationApi', + ); + useGridApiEventHandler(apiRef, GRID_CELL_NAVIGATION_KEYDOWN, navigateCells); +}; diff --git a/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts b/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts index f97034ede3a57..4f31e22fcb69c 100644 --- a/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts +++ b/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts @@ -1,52 +1,87 @@ import * as React from 'react'; import { GRID_CELL_MODE_CHANGE, - GRID_CELL_CHANGE, - GRID_CELL_CHANGE_COMMITTED, - GRID_EDIT_ROW_MODEL_CHANGE, + GRID_CELL_EDIT_PROPS_CHANGE, + GRID_CELL_EDIT_PROPS_CHANGE_COMMITTED, + GRID_ROW_EDIT_MODEL_CHANGE, + GRID_CELL_DOUBLE_CLICK, + GRID_CELL_ENTER_EDIT, + GRID_CELL_EXIT_EDIT, + GRID_CELL_NAVIGATION_KEYDOWN, + GRID_CELL_MOUSE_DOWN, + GRID_CELL_EDIT_BLUR, + GRID_CELL_KEYDOWN, + GRID_CELL_VALUE_CHANGE, } from '../../../constants/eventsConstants'; import { GridApiRef } from '../../../models/api/gridApiRef'; import { GridEditRowApi } from '../../../models/api/gridEditRowApi'; import { GridCellMode } from '../../../models/gridCell'; -import { GridEditRowsModel, GridEditRowUpdate } from '../../../models/gridEditRowModel'; +import { GridEditRowsModel } from '../../../models/gridEditRowModel'; import { GridFeatureModeConstant } from '../../../models/gridFeatureMode'; import { GridRowId } from '../../../models/gridRows'; import { GridCellParams } from '../../../models/params/gridCellParams'; import { - GridCellModeChangeParams, - GridEditCellParams, + GridEditCellPropsParams, + GridEditCellValueParams, GridEditRowModelParams, } from '../../../models/params/gridEditCellParams'; +import { + isAlphaKeys, + isCellEditCommitKeys, + isCellEnterEditModeKeys, + isCellExitEditModeKeys, + isDeleteKeys, + isEscapeKey, + isKeyboardEvent, +} from '../../../utils/keyboardUtils'; import { useGridApiEventHandler } from '../../root/useGridApiEventHandler'; import { useGridApiMethod } from '../../root/useGridApiMethod'; import { optionsSelector } from '../../utils/optionsSelector'; +import { useLogger } from '../../utils/useLogger'; import { useGridSelector } from '../core/useGridSelector'; import { useGridState } from '../core/useGridState'; export function useGridEditRows(apiRef: GridApiRef) { + const logger = useLogger('useGridEditRows'); const [, setGridState, forceUpdate] = useGridState(apiRef); const options = useGridSelector(apiRef, optionsSelector); + const lastEditedCell = React.useRef(null); - const setCellEditMode = React.useCallback( - (id, field) => { + const setCellMode = React.useCallback( + (id, field, mode: GridCellMode) => { + let hasChanged = false; setGridState((state) => { - if (state.editRows[id] && state.editRows[id][field]) { + const stateExist = state.editRows[id] && state.editRows[id][field]; + const newEditRowsState: GridEditRowsModel = { ...state.editRows }; + if ((mode === 'edit' && stateExist) || (mode === 'view' && !stateExist)) { return state; } - const currentCellEditState: GridEditRowsModel = { ...state.editRows }; - currentCellEditState[id] = { ...currentCellEditState[id] } || {}; - currentCellEditState[id][field] = { value: apiRef.current.getCellValue(id, field) }; - - const newEditRowsState: GridEditRowsModel = { ...state.editRows, ...currentCellEditState }; + if (mode === 'edit') { + newEditRowsState[id] = { ...newEditRowsState[id] } || {}; + newEditRowsState[id][field] = { value: apiRef.current.getCellValue(id, field) }; + lastEditedCell.current = apiRef.current.getCellParams(id, field); + } else { + delete newEditRowsState[id][field]; + lastEditedCell.current = null; + if (!Object.keys(newEditRowsState[id]).length) { + delete newEditRowsState[id]; + } + } + hasChanged = true; return { ...state, editRows: newEditRowsState }; }); + + if (!hasChanged) { + return; + } + logger.debug(`Switching cell id: ${id} field: ${field} to mode: ${mode}`); forceUpdate(); apiRef.current.publishEvent(GRID_CELL_MODE_CHANGE, { id, field, - mode: 'edit', + mode, api: apiRef.current, }); @@ -54,158 +89,240 @@ export function useGridEditRows(apiRef: GridApiRef) { api: apiRef.current, model: apiRef.current.getState().editRows, }; - apiRef.current.publishEvent(GRID_EDIT_ROW_MODEL_CHANGE, editRowParams); + apiRef.current.publishEvent(GRID_ROW_EDIT_MODEL_CHANGE, editRowParams); }, - [apiRef, forceUpdate, setGridState], + [apiRef, forceUpdate, logger, setGridState], ); - const setCellViewMode = React.useCallback( + const getCellMode = React.useCallback( (id, field) => { - setGridState((state) => { - const newEditRowsState: GridEditRowsModel = { ...state.editRows }; + const editState = apiRef.current.getState().editRows; + const isEditing = editState[id] && editState[id][field]; + return isEditing ? 'edit' : 'view'; + }, + [apiRef], + ); - if (!newEditRowsState[id] || !newEditRowsState[id][field]) { - return state; - } + const isCellEditable = React.useCallback( + (params: GridCellParams) => + params.colDef.editable && + params.colDef!.renderEditCell && + (!options.isCellEditable || options.isCellEditable(params)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [options.isCellEditable], + ); - if (newEditRowsState[id][field]) { - delete newEditRowsState[id][field]; - if (!Object.keys(newEditRowsState[id]).length) { - delete newEditRowsState[id]; - } - } - return { ...state, editRows: newEditRowsState }; + const setEditCellProps = React.useCallback( + (params: GridEditCellPropsParams) => { + if (options.editMode === GridFeatureModeConstant.server) { + apiRef.current.publishEvent(GRID_CELL_EDIT_PROPS_CHANGE, params); + return; + } + const { id, field, props } = params; + logger.debug(`Setting cell props on id: ${id} field: ${field}`); + setGridState((state) => { + const editRowsModel: GridEditRowsModel = { ...state.editRows }; + editRowsModel[id] = { ...state.editRows[id] }; + editRowsModel[id][field] = props; + return { ...state, editRows: editRowsModel }; }); forceUpdate(); - const params: GridCellModeChangeParams = { - id, - field, - mode: 'view', - api: apiRef.current, - }; - apiRef.current.publishEvent(GRID_CELL_MODE_CHANGE, params); const editRowParams: GridEditRowModelParams = { api: apiRef.current, model: apiRef.current.getState().editRows, }; - apiRef.current.publishEvent(GRID_EDIT_ROW_MODEL_CHANGE, editRowParams); + apiRef.current.publishEvent(GRID_ROW_EDIT_MODEL_CHANGE, editRowParams); }, - [apiRef, forceUpdate, setGridState], + [apiRef, forceUpdate, logger, options.editMode, setGridState], ); - const setCellMode = React.useCallback( - (id, field, mode: GridCellMode) => { - if (mode === 'edit') { - setCellEditMode(id, field); - } else { - setCellViewMode(id, field); + const getEditCellProps = React.useCallback( + (id: GridRowId, field: string) => { + const model = apiRef.current.getEditRowsModel(); + if (!model[id] || !model[id][field]) { + return { id, field, value: apiRef.current.getCellValue(id, field) }; } + return model[id][field]; }, - [setCellEditMode, setCellViewMode], + [apiRef], ); - const isCellEditable = React.useCallback( - (params: GridCellParams) => { - return params.colDef.editable && (!options.isCellEditable || options.isCellEditable(params)); + const setEditRowsModel = React.useCallback( + (editRows: GridEditRowsModel): void => { + logger.debug(`Setting row model`); + + setGridState((state) => ({ ...state, editRows })); + forceUpdate(); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [options.isCellEditable], + [forceUpdate, logger, setGridState], + ); + + const getEditRowsModel = React.useCallback( + (): GridEditRowsModel => apiRef.current.getState().editRows, + [apiRef], + ); + + const getEditCellPropsParams = React.useCallback( + (id: GridRowId, field: string): GridEditCellPropsParams => { + const fieldProps = apiRef.current.getEditCellProps(id, field); + return { id, field, props: fieldProps }; + }, + [apiRef], + ); + + const getEditCellValueParams = React.useCallback( + (id: GridRowId, field: string): GridEditCellValueParams => { + const fieldProps = apiRef.current.getEditCellProps(id, field); + return { id, field, value: fieldProps?.value }; + }, + [apiRef], + ); + + const setCellValue = React.useCallback( + (params: GridEditCellValueParams) => { + logger.debug( + `Setting cell id: ${params.id} field: ${ + params.field + } to value: ${params.value?.toString()}`, + ); + const rowUpdate = { id: params.id }; + rowUpdate[params.field] = params.value; + apiRef.current.updateRows([rowUpdate]); + apiRef.current.publishEvent(GRID_CELL_VALUE_CHANGE, params); + }, + [apiRef, logger], ); const commitCellChange = React.useCallback( - (id: GridRowId, update: GridEditRowUpdate) => { + (params: GridEditCellPropsParams) => { if (options.editMode === GridFeatureModeConstant.server) { - const params: GridEditCellParams = { api: apiRef.current, id, update }; - apiRef.current.publishEvent(GRID_CELL_CHANGE_COMMITTED, params); return; } - const field = Object.keys(update).find((key) => key !== 'id')!; - const rowUpdate = { id }; - rowUpdate[field] = update[field].value; - apiRef.current.updateRows([rowUpdate]); - apiRef.current.setCellMode(id, field, 'view'); + logger.debug(`Committing cell change on id: ${params.id} field: ${params.field}`); + + const value = params.props && params.props.value; + apiRef.current.setCellValue({ id: params.id, field: params.field, value }); }, - [apiRef, options.editMode], + [apiRef, logger, options.editMode], ); - const setEditCellProps = React.useCallback( - (id: GridRowId, update: GridEditRowUpdate) => { - if (options.editMode === GridFeatureModeConstant.server) { - const params: GridEditCellParams = { api: apiRef.current, id, update }; - apiRef.current.publishEvent(GRID_CELL_CHANGE, params); + const handleExitEdit = React.useCallback( + (params: GridCellParams, event: React.SyntheticEvent) => { + setCellMode(params.id, params.field, 'view'); + + if (!isKeyboardEvent(event)) { return; } - setGridState((state) => { - const editRowsModel: GridEditRowsModel = { ...state.editRows }; - editRowsModel[id] = { - ...state.editRows[id], - ...update, - }; - return { ...state, editRows: editRowsModel }; - }); - forceUpdate(); - const params: GridEditRowModelParams = { - api: apiRef.current, - model: apiRef.current.getState().editRows, - }; - apiRef.current.publishEvent(GRID_EDIT_ROW_MODEL_CHANGE, params); + + if (isCellEditCommitKeys(event.key)) { + apiRef.current.publishEvent(GRID_CELL_NAVIGATION_KEYDOWN, params, event); + return; + } + if (isEscapeKey(event.key) || isDeleteKeys(event.key)) { + apiRef.current.setCellFocus(params); + } }, - [apiRef, forceUpdate, options.editMode, setGridState], + [apiRef, setCellMode], ); - const setEditRowsModel = React.useCallback( - (editRows: GridEditRowsModel) => { - setGridState((state) => { - const newState = { ...state, editRows }; - return newState; - }); - forceUpdate(); + const handleEnterEdit = React.useCallback( + (params: GridCellParams, event: React.MouseEvent | React.KeyboardEvent) => { + if (!params.isEditable) { + return; + } + if (isKeyboardEvent(event) && isAlphaKeys(event.key)) { + const propsParams = apiRef.current.getEditCellPropsParams(params.id, params.field); + propsParams.props.value = ''; + apiRef.current.setEditCellProps(propsParams); + } + setCellMode(params.id, params.field, 'edit'); }, - [forceUpdate, setGridState], + [apiRef, setCellMode], ); - // TODO cleanup params What should we put? - const onEditRowModelChange = React.useCallback( - (handler: (param: GridEditRowModelParams) => void): (() => void) => { - return apiRef.current.subscribeEvent(GRID_EDIT_ROW_MODEL_CHANGE, handler); + + const preventTextSelection = React.useCallback( + (params: GridCellParams, event: React.MouseEvent) => { + const isMoreThanOneClick = event.detail > 1; + if (params.isEditable && params.cellMode === 'view' && isMoreThanOneClick) { + // If we click more than one time, then we prevent the default behavior of selecting the text cell. + event.preventDefault(); + } }, - [apiRef], + [], ); - const onCellModeChange = React.useCallback( - (handler: (param: GridCellModeChangeParams) => void): (() => void) => { - return apiRef.current.subscribeEvent(GRID_CELL_MODE_CHANGE, handler); + + const handleCellEditBlur = React.useCallback( + (params: GridCellParams, event) => { + if (params.cellMode === 'view') { + return; + } + const cellCommitParams = apiRef.current.getEditCellPropsParams(params.id, params.field); + apiRef.current.publishEvent(GRID_CELL_EDIT_PROPS_CHANGE_COMMITTED, cellCommitParams, event); + apiRef.current.publishEvent(GRID_CELL_EXIT_EDIT, params, event); }, [apiRef], ); - const onEditCellChange = React.useCallback( - (handler: (param: GridEditCellParams) => void): (() => void) => { - return apiRef.current.subscribeEvent(GRID_CELL_CHANGE, handler); + + const handleCellKeyDown = React.useCallback( + (params: GridCellParams, event) => { + const isEditMode = params.cellMode === 'edit'; + + if (!isEditMode && isCellEnterEditModeKeys(event.key)) { + apiRef.current.publishEvent(GRID_CELL_ENTER_EDIT, params, event); + } + if (!isEditMode && isDeleteKeys(event.key)) { + const commitParams: GridEditCellPropsParams = apiRef.current.getEditCellPropsParams( + params.id, + params.field, + ); + commitParams.props.value = ''; + apiRef.current.publishEvent(GRID_CELL_EDIT_PROPS_CHANGE_COMMITTED, commitParams, event); + apiRef.current.publishEvent(GRID_CELL_EXIT_EDIT, params, event); + } + if (isEditMode && isCellEditCommitKeys(event.key)) { + const cellCommitParams = apiRef.current.getEditCellPropsParams(params.id, params.field); + apiRef.current.publishEvent(GRID_CELL_EDIT_PROPS_CHANGE_COMMITTED, cellCommitParams, event); + } + if (isEditMode && !event.isPropagationStopped() && isCellExitEditModeKeys(event.key)) { + apiRef.current.publishEvent(GRID_CELL_EXIT_EDIT, params, event); + } }, [apiRef], ); - const onEditCellChangeCommitted = React.useCallback( - (handler: (param: GridEditCellParams) => void): (() => void) => { - return apiRef.current.subscribeEvent(GRID_CELL_CHANGE_COMMITTED, handler); - }, - [apiRef], + + useGridApiEventHandler(apiRef, GRID_CELL_KEYDOWN, handleCellKeyDown); + useGridApiEventHandler(apiRef, GRID_CELL_EDIT_BLUR, handleCellEditBlur); + useGridApiEventHandler(apiRef, GRID_CELL_MOUSE_DOWN, preventTextSelection); + useGridApiEventHandler(apiRef, GRID_CELL_DOUBLE_CLICK, handleEnterEdit); + useGridApiEventHandler(apiRef, GRID_CELL_ENTER_EDIT, handleEnterEdit); + useGridApiEventHandler(apiRef, GRID_CELL_EXIT_EDIT, handleExitEdit); + + useGridApiEventHandler(apiRef, GRID_CELL_EDIT_PROPS_CHANGE, options.onEditCellChange); + useGridApiEventHandler(apiRef, GRID_CELL_EDIT_PROPS_CHANGE_COMMITTED, commitCellChange); + useGridApiEventHandler( + apiRef, + GRID_CELL_EDIT_PROPS_CHANGE_COMMITTED, + options.onEditCellChangeCommitted, ); - useGridApiEventHandler(apiRef, GRID_CELL_CHANGE, options.onEditCellChange); - useGridApiEventHandler(apiRef, GRID_CELL_CHANGE_COMMITTED, options.onEditCellChangeCommitted); + useGridApiEventHandler(apiRef, GRID_CELL_VALUE_CHANGE, options.onCellValueChange); useGridApiEventHandler(apiRef, GRID_CELL_MODE_CHANGE, options.onCellModeChange); - useGridApiEventHandler(apiRef, GRID_EDIT_ROW_MODEL_CHANGE, options.onEditRowModelChange); + useGridApiEventHandler(apiRef, GRID_ROW_EDIT_MODEL_CHANGE, options.onEditRowModelChange); useGridApiMethod( apiRef, { setCellMode, - onEditRowModelChange, - onCellModeChange, - onEditCellChangeCommitted, - onEditCellChange, + getCellMode, isCellEditable, + setCellValue, commitCellChange, setEditCellProps, + getEditCellProps, + getEditCellPropsParams, + getEditCellValueParams, setEditRowsModel, + getEditRowsModel, }, 'EditRowApi', ); diff --git a/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts b/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts index 4c533f6d3ff02..f07f07f63f99e 100644 --- a/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts +++ b/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts @@ -65,6 +65,7 @@ export function useGridParamsApi(apiRef: GridApiRef) { value: row[field], getValue: (columnField: string) => apiRef.current.getCellValue(id, columnField), colDef: apiRef.current.getColumnFromField(field), + cellMode: apiRef.current.getCellMode(id, field), rowIndex: apiRef.current.getRowIndexFromId(id), colIndex: apiRef.current.getColumnIndex(field, true), api: apiRef.current, diff --git a/packages/grid/_modules_/grid/models/api/gridApi.ts b/packages/grid/_modules_/grid/models/api/gridApi.ts index cfea6b26c03ca..d76dd472a0f24 100644 --- a/packages/grid/_modules_/grid/models/api/gridApi.ts +++ b/packages/grid/_modules_/grid/models/api/gridApi.ts @@ -1,5 +1,6 @@ import { ColumnMenuApi } from './columnMenuApi'; import { ColumnResizeApi } from './columnResizeApi'; +import { GridNavigationApi } from './gridNavigationApi'; import { GridParamsApi } from './gridParamsApi'; import { ComponentsApi } from './gridComponentsApi'; import { FilterApi } from './filterApi'; @@ -37,6 +38,7 @@ export type GridApi = GridCoreApi & GridVirtualizationApi & GridPaginationApi & GridCsvExportApi & + GridNavigationApi & FilterApi & ColumnMenuApi & ColumnResizeApi & diff --git a/packages/grid/_modules_/grid/models/api/gridEditRowApi.ts b/packages/grid/_modules_/grid/models/api/gridEditRowApi.ts index 9575f249df4ee..0bfae6ee8933f 100644 --- a/packages/grid/_modules_/grid/models/api/gridEditRowApi.ts +++ b/packages/grid/_modules_/grid/models/api/gridEditRowApi.ts @@ -1,12 +1,8 @@ import { GridCellMode } from '../gridCell'; -import { GridEditRowsModel, GridEditRowUpdate } from '../gridEditRowModel'; +import { GridEditCellProps, GridEditRowsModel } from '../gridEditRowModel'; import { GridRowId } from '../gridRows'; import { GridCellParams } from '../params/gridCellParams'; -import { - GridCellModeChangeParams, - GridEditCellParams, - GridEditRowModelParams, -} from '../params/gridEditCellParams'; +import { GridEditCellValueParams, GridEditCellPropsParams } from '../params/gridEditCellParams'; export interface GridEditRowApi { /** @@ -14,6 +10,11 @@ export interface GridEditRowApi { * @param GridEditRowsModel */ setEditRowsModel: (model: GridEditRowsModel) => void; + /** + * Get the edit rows model of the grid. + * @returns GridEditRowsModel + */ + getEditRowsModel: () => GridEditRowsModel; /** * Set the cellMode of a cell. * @param GridRowId @@ -21,6 +22,13 @@ export interface GridEditRowApi { * @param 'edit' | 'view' */ setCellMode: (id: GridRowId, field: string, mode: GridCellMode) => void; + /** + * Get the cellMode of a cell. + * @param GridRowId + * @param string + * @returns 'edit' | 'view' + */ + getCellMode: (id: GridRowId, field: string) => GridCellMode; /** * Returns true if the cell is editable. * @param params @@ -28,32 +36,36 @@ export interface GridEditRowApi { isCellEditable: (params: GridCellParams) => boolean; /** * Set the edit cell input props. + * @param rowId * @param update */ - setEditCellProps: (id: GridRowId, update: GridEditRowUpdate) => void; + setEditCellProps: (params: GridEditCellPropsParams) => void; /** - * Commit the cell value changes to update the cell value. - * @param update + * Get the edit cell input props. + * @param rowId + * @param field */ - commitCellChange: (id: GridRowId, update: GridEditRowUpdate) => void; + getEditCellProps: (rowId: GridRowId, field: string) => GridEditCellProps; /** - * Callback fired when the EditRowModel changed. - * @param handler + * Get the edit cell input props params passed in handler. + * @param rowId + * @param field */ - onEditRowModelChange: (handler: (param: GridEditRowModelParams) => void) => void; + getEditCellPropsParams: (rowId: GridRowId, field: string) => GridEditCellPropsParams; /** - * Callback fired when the cell mode changed. - * @param handler + * Get the edit cell value params. + * @param rowId + * @param field */ - onCellModeChange: (handler: (param: GridCellModeChangeParams) => void) => void; + getEditCellValueParams: (rowId: GridRowId, field: string) => GridEditCellValueParams; /** - * Callback fired when the cell changes are committed. - * @param handler + * Commit the cell value changes to update the cell value. + * @param update */ - onEditCellChangeCommitted: (handler: (param: GridEditCellParams) => void) => void; + commitCellChange: (params: GridEditCellPropsParams) => void; /** - * Callback fired when the edit cell value changed. - * @param handler + * Set the cell value. + * @param params */ - onEditCellChange: (handler: (param: GridEditCellParams) => void) => void; + setCellValue: (params: GridEditCellValueParams) => void; } diff --git a/packages/grid/_modules_/grid/models/api/gridNavigationApi.ts b/packages/grid/_modules_/grid/models/api/gridNavigationApi.ts new file mode 100644 index 0000000000000..275f00dbdc6b2 --- /dev/null +++ b/packages/grid/_modules_/grid/models/api/gridNavigationApi.ts @@ -0,0 +1,9 @@ +import { GridCellIndexCoordinates } from '../gridCell'; + +export interface GridNavigationApi { + /** + * Set the active element to the cell with the indexes. + * @param indexes + */ + setCellFocus: (indexes: GridCellIndexCoordinates) => void; +} diff --git a/packages/grid/_modules_/grid/models/colDef/gridStringColDef.ts b/packages/grid/_modules_/grid/models/colDef/gridStringColDef.ts index 29fa09b277171..6fae7cde530e5 100644 --- a/packages/grid/_modules_/grid/models/colDef/gridStringColDef.ts +++ b/packages/grid/_modules_/grid/models/colDef/gridStringColDef.ts @@ -1,4 +1,4 @@ -import { renderEditInputCell } from '../../components/editCell/EditInputCell'; +import { renderEditInputCell } from '../../components/cell/GridEditInputCell'; import { gridStringNumberComparer } from '../../utils/sortingUtils'; import { GridColTypeDef } from './gridColDef'; import { getGridStringOperators } from './gridStringOperators'; diff --git a/packages/grid/_modules_/grid/models/gridEditRowModel.ts b/packages/grid/_modules_/grid/models/gridEditRowModel.ts index 2757b7ead47e5..9dd6a4816e13a 100644 --- a/packages/grid/_modules_/grid/models/gridEditRowModel.ts +++ b/packages/grid/_modules_/grid/models/gridEditRowModel.ts @@ -5,6 +5,6 @@ export interface GridEditCellProps { [prop: string]: any; } -export type GridEditRowUpdate = { [field: string]: GridEditCellProps }; +export type GridEditRowProps = { [field: string]: GridEditCellProps }; -export type GridEditRowsModel = { [rowId: string]: GridEditRowUpdate }; +export type GridEditRowsModel = { [rowId: string]: GridEditRowProps }; diff --git a/packages/grid/_modules_/grid/models/gridOptions.tsx b/packages/grid/_modules_/grid/models/gridOptions.tsx index 8fdfe29a2dfef..808f32aaf82a0 100644 --- a/packages/grid/_modules_/grid/models/gridOptions.tsx +++ b/packages/grid/_modules_/grid/models/gridOptions.tsx @@ -20,7 +20,8 @@ import { GridSelectionModel } from './gridSelectionModel'; import { GridSortDirection, GridSortModel } from './gridSortModel'; import { GridCellModeChangeParams, - GridEditCellParams, + GridEditCellPropsParams, + GridEditCellValueParams, GridEditRowModelParams, } from './params/gridEditCellParams'; import { GridRowScrollEndParams } from './params/gridRowScrollEndParams'; @@ -182,12 +183,15 @@ export interface GridOptions { * Callback fired when the edit cell value changed. * @param handler */ - onEditCellChange?: (params: GridEditCellParams) => void; + onEditCellChange?: (params: GridEditCellPropsParams) => void; /** * Callback fired when the cell changes are committed. * @param handler */ - onEditCellChangeCommitted?: (params: GridEditCellParams) => void; + onEditCellChangeCommitted?: ( + params: GridEditCellPropsParams, + event?: React.SyntheticEvent, + ) => void; /** * Callback fired when the EditRowModel changed. * @param handler @@ -238,6 +242,11 @@ export interface GridOptions { * @param handler */ onCellModeChange?: (params: GridCellModeChangeParams) => void; + /** + * Callback fired when the cell value changed. + * @param handler + */ + onCellValueChange?: (params: GridEditCellValueParams) => void; /** * Callback fired when a click event comes from a column header element. * @param param With all properties from [[GridColParams]]. diff --git a/packages/grid/_modules_/grid/models/params/gridCellParams.ts b/packages/grid/_modules_/grid/models/params/gridCellParams.ts index 9176c4423d547..8c966b89f1bcb 100644 --- a/packages/grid/_modules_/grid/models/params/gridCellParams.ts +++ b/packages/grid/_modules_/grid/models/params/gridCellParams.ts @@ -1,4 +1,4 @@ -import { GridCellValue } from '../gridCell'; +import { GridCellMode, GridCellValue } from '../gridCell'; import { GridRowId, GridRowModel } from '../gridRows'; /** @@ -41,11 +41,11 @@ export interface GridCellParams { /** * The row index of the row that the current cell belongs to. */ - rowIndex?: number; + rowIndex: number; /** * The column index that the current cell belongs to. */ - colIndex?: number; + colIndex: number; /** * GridApi that let you manipulate the grid. */ @@ -54,6 +54,10 @@ export interface GridCellParams { * If true, the cell is editable. */ isEditable?: boolean; + /** + * The mode of the cell. + */ + cellMode: GridCellMode; } /** diff --git a/packages/grid/_modules_/grid/models/params/gridEditCellParams.ts b/packages/grid/_modules_/grid/models/params/gridEditCellParams.ts index 437aabc9604a6..c1cc7a34a73e5 100644 --- a/packages/grid/_modules_/grid/models/params/gridEditCellParams.ts +++ b/packages/grid/_modules_/grid/models/params/gridEditCellParams.ts @@ -1,11 +1,16 @@ -import { GridCellMode } from '../gridCell'; -import { GridEditRowsModel, GridEditRowUpdate } from '../gridEditRowModel'; +import { GridCellMode, GridCellValue } from '../gridCell'; +import { GridEditRowsModel, GridEditCellProps } from '../gridEditRowModel'; import { GridRowId } from '../gridRows'; -export interface GridEditCellParams { - api: any; +export interface GridEditCellPropsParams { id: GridRowId; - update: GridEditRowUpdate; + field: string; + props: GridEditCellProps; +} +export interface GridEditCellValueParams { + id: GridRowId; + field: string; + value: GridCellValue; } export interface GridCellModeChangeParams { diff --git a/packages/grid/_modules_/grid/utils/keyboardUtils.ts b/packages/grid/_modules_/grid/utils/keyboardUtils.ts index 3124e5a6cfa20..76e5a34533f2e 100644 --- a/packages/grid/_modules_/grid/utils/keyboardUtils.ts +++ b/packages/grid/_modules_/grid/utils/keyboardUtils.ts @@ -1,11 +1,39 @@ -export const GRID_MULTIPLE_SELECTION_KEYS = ['Meta', 'Control', 'Shift']; -export const isMultipleKey = (key: string): boolean => - GRID_MULTIPLE_SELECTION_KEYS.indexOf(key) > -1; +import * as React from 'react'; + +export const isEscapeKey = (key: string): boolean => key === 'Escape'; +export const isEnterKey = (key: string): boolean => key === 'Enter'; export const isTabKey = (key: string): boolean => key === 'Tab'; + export const isSpaceKey = (key: string): boolean => key === ' '; + export const isArrowKeys = (key: string): boolean => key.indexOf('Arrow') === 0; + export const isHomeOrEndKeys = (key: string): boolean => key === 'Home' || key === 'End'; + export const isPageKeys = (key: string): boolean => key.indexOf('Page') === 0; +export const isDeleteKeys = (key: string) => key === 'Delete' || key === 'Backspace'; +const alphaRegex = /^[a-z]{1}$/i; +export const isAlphaKeys = (key: string) => alphaRegex.test(key); + +export const GRID_MULTIPLE_SELECTION_KEYS = ['Meta', 'Control', 'Shift']; +export const GRID_CELL_EXIT_EDIT_MODE_KEYS = ['Enter', 'Escape', 'Tab']; +export const GRID_CELL_EDIT_COMMIT_KEYS = ['Enter', 'Tab']; + +export const isMultipleKey = (key: string): boolean => + GRID_MULTIPLE_SELECTION_KEYS.indexOf(key) > -1; + +export const isCellEnterEditModeKeys = (key: string): boolean => + isEnterKey(key) || isDeleteKeys(key) || isAlphaKeys(key); + +export const isCellExitEditModeKeys = (key: string): boolean => + GRID_CELL_EXIT_EDIT_MODE_KEYS.indexOf(key) > -1; + +export const isCellEditCommitKeys = (key: string): boolean => + GRID_CELL_EDIT_COMMIT_KEYS.indexOf(key) > -1; export const isNavigationKey = (key: string) => - isHomeOrEndKeys(key) || isArrowKeys(key) || isPageKeys(key) || isSpaceKey(key); + isHomeOrEndKeys(key) || isArrowKeys(key) || isPageKeys(key) || isSpaceKey(key) || isTabKey(key); + +export const isKeyboardEvent = (event: any): event is React.KeyboardEvent => !!event.key; + +export const isHideMenuKey = (key) => isTabKey(key) || isEscapeKey(key); diff --git a/packages/grid/x-grid/src/tests/apiRef.XGrid.test.tsx b/packages/grid/x-grid/src/tests/apiRef.XGrid.test.tsx deleted file mode 100644 index 9ff1eca1d53cc..0000000000000 --- a/packages/grid/x-grid/src/tests/apiRef.XGrid.test.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { - GridApiRef, - GridComponentProps, - GridRowData, - GRID_ROWS_SCROLL, - useGridApiRef, - XGrid, -} from '@material-ui/x-grid'; -import { expect } from 'chai'; -import * as React from 'react'; -import { spy, useFakeTimers } from 'sinon'; -import { getCell, getColumnValues } from 'test/utils/helperFn'; -import { createClientRenderStrictMode } from 'test/utils'; - -describe(' - apiRef', () => { - let clock; - - afterEach(() => { - clock.restore(); - }); - - before(function beforeHook() { - if (/jsdom/.test(window.navigator.userAgent)) { - // Need layouting - this.skip(); - } - }); - let baselineProps; - - // TODO v5: replace with createClientRender - const render = createClientRenderStrictMode(); - beforeEach(() => { - clock = useFakeTimers(); - - baselineProps = { - rows: [ - { - id: 0, - brand: 'Nike', - }, - { - id: 1, - brand: 'Adidas', - }, - { - id: 2, - brand: 'Puma', - }, - ], - columns: [{ field: 'brand', headerName: 'Brand' }], - }; - }); - - let apiRef: GridApiRef; - - const TestCase = (props: Partial) => { - apiRef = useGridApiRef(); - return ( -
- -
- ); - }; - - it('should allow to reset rows with setRows and render after 100ms', () => { - render(); - const newRows = [ - { - id: 3, - brand: 'Asics', - }, - ]; - apiRef.current.setRows(newRows); - - clock.tick(50); - expect(getColumnValues()).to.deep.equal(['Nike', 'Adidas', 'Puma']); - clock.tick(50); - expect(getColumnValues()).to.deep.equal(['Asics']); - }); - - it('should allow to update row data', () => { - render(); - apiRef.current.updateRows([{ id: 1, brand: 'Fila' }]); - apiRef.current.updateRows([{ id: 0, brand: 'Pata' }]); - apiRef.current.updateRows([{ id: 2, brand: 'Pum' }]); - clock.tick(100); - expect(getColumnValues()).to.deep.equal(['Pata', 'Fila', 'Pum']); - }); - - it('update row data can also add rows', () => { - render(); - apiRef.current.updateRows([{ id: 1, brand: 'Fila' }]); - apiRef.current.updateRows([{ id: 0, brand: 'Pata' }]); - apiRef.current.updateRows([{ id: 2, brand: 'Pum' }]); - apiRef.current.updateRows([{ id: 3, brand: 'Jordan' }]); - clock.tick(100); - expect(getColumnValues()).to.deep.equal(['Pata', 'Fila', 'Pum', 'Jordan']); - }); - - it('update row data can also add rows in bulk', () => { - render(); - apiRef.current.updateRows([ - { id: 1, brand: 'Fila' }, - { id: 0, brand: 'Pata' }, - { id: 2, brand: 'Pum' }, - { id: 3, brand: 'Jordan' }, - ]); - clock.tick(100); - expect(getColumnValues()).to.deep.equal(['Pata', 'Fila', 'Pum', 'Jordan']); - }); - - it('update row data can also delete rows', () => { - render(); - apiRef.current.updateRows([{ id: 1, _action: 'delete' }]); - apiRef.current.updateRows([{ id: 0, brand: 'Apple' }]); - apiRef.current.updateRows([{ id: 2, _action: 'delete' }]); - apiRef.current.updateRows([{ id: 5, brand: 'Atari' }]); - clock.tick(100); - expect(getColumnValues()).to.deep.equal(['Apple', 'Atari']); - }); - - it('update row data can also delete rows in bulk', () => { - render(); - apiRef.current.updateRows([ - { id: 1, _action: 'delete' }, - { id: 0, brand: 'Apple' }, - { id: 2, _action: 'delete' }, - { id: 5, brand: 'Atari' }, - ]); - clock.tick(100); - expect(getColumnValues()).to.deep.equal(['Apple', 'Atari']); - }); - - it('update row data should process getRowId', () => { - const TestCaseGetRowId = () => { - apiRef = useGridApiRef(); - const getRowId = React.useCallback((row: GridRowData) => row.idField, []); - return ( -
- ({ idField: row.id, brand: row.brand }))} - getRowId={getRowId} - /> -
- ); - }; - - render(); - expect(getColumnValues()).to.deep.equal(['Nike', 'Adidas', 'Puma']); - apiRef.current.updateRows([ - { idField: 1, _action: 'delete' }, - { idField: 0, brand: 'Apple' }, - { idField: 2, _action: 'delete' }, - { idField: 5, brand: 'Atari' }, - ]); - clock.tick(100); - expect(getColumnValues()).to.deep.equal(['Apple', 'Atari']); - }); - - it('getDataAsCsv should return the correct string representation of the grid data', () => { - const TestCaseCSVExport = () => { - apiRef = useGridApiRef(); - return ( -
- -
- ); - }; - - render(); - expect(apiRef.current.getDataAsCsv()).to.equal('Brand\r\nNike\r\nAdidas\r\nPuma'); - apiRef.current.updateRows([ - { - id: 1, - brand: 'Adidas,Reebok', - }, - ]); - expect(apiRef.current.getDataAsCsv()).to.equal('Brand\r\nNike\r\n"Adidas,Reebok"\r\nPuma'); - }); - - it('getDataAsCsv should return the correct string representation of the grid data if cell contains comma', () => { - const TestCaseCSVExport = () => { - apiRef = useGridApiRef(); - return ( -
- -
- ); - }; - - render(); - expect(apiRef.current.getDataAsCsv()).to.equal('Brand\r\nNike\r\n"Adidas,Puma"'); - }); - - it('getDataAsCsv should return the correct string representation of the grid data if cell contains comma and double quotes', () => { - const TestCaseCSVExport = () => { - apiRef = useGridApiRef(); - return ( -
- -
- ); - }; - - render(); - expect(apiRef.current.getDataAsCsv()).to.equal('Brand\r\n"Nike,""Adidas"",Puma"'); - }); - - it('should allow to switch between cell mode', () => { - baselineProps.columns = baselineProps.columns.map((col) => ({ ...col, editable: true })); - - render(); - apiRef!.current.setCellMode(1, 'brand', 'edit'); - const cell = getCell(1, 0); - - expect(cell.classList.contains('MuiDataGrid-cellEditable')).to.equal(true); - expect(cell.classList.contains('MuiDataGrid-cellEditing')).to.equal(true); - expect(cell.querySelector('input')!.value).to.equal('Adidas'); - - apiRef!.current.setCellMode(1, 'brand', 'view'); - expect(cell.classList.contains('MuiDataGrid-cellEditable')).to.equal(true); - expect(cell.classList.contains('MuiDataGrid-cellEditing')).to.equal(false); - expect(cell.querySelector('input')).to.equal(null); - }); - - it('isCellEditable should add the class MuiDataGrid-cellEditable to editable cells but not prevent a cell from switching mode', () => { - baselineProps.columns = baselineProps.columns.map((col) => ({ ...col, editable: true })); - - render( params.value === 'Adidas'} />); - const cellNike = getCell(0, 0); - expect(cellNike!.classList.contains('MuiDataGrid-cellEditable')).to.equal(false); - const cellAdidas = getCell(1, 0); - expect(cellAdidas!.classList.contains('MuiDataGrid-cellEditable')).to.equal(true); - - apiRef!.current.setCellMode(0, 'brand', 'edit'); - expect(cellNike.classList.contains('MuiDataGrid-cellEditing')).to.equal(true); - }); - - it('publishing GRID_ROWS_SCROLL should call onRowsScrollEnd callback', () => { - const handleOnRowsScrollEnd = spy(); - - render(); - apiRef.current.publishEvent(GRID_ROWS_SCROLL); - expect(handleOnRowsScrollEnd.callCount).to.equal(1); - }); - - it('call onRowsScrollEnd when viewport scroll reaches the bottom', () => { - const handleOnRowsScrollEnd = spy(); - const data = { - rows: [ - { - id: 0, - brand: 'Nike', - }, - { - id: 1, - brand: 'Adidas', - }, - { - id: 2, - brand: 'Puma', - }, - { - id: 3, - brand: 'Under Armor', - }, - { - id: 4, - brand: 'Jordan', - }, - { - id: 5, - brand: 'Reebok', - }, - ], - columns: [{ field: 'brand', width: 100 }], - }; - - const { container } = render( -
- -
, - ); - const gridWindow = container.querySelector('.MuiDataGrid-window'); - // arbitrary number to make sure that the bottom of the grid window is reached. - gridWindow.scrollTop = 12345; - gridWindow.dispatchEvent(new Event('scroll')); - expect(handleOnRowsScrollEnd.callCount).to.equal(1); - }); -}); diff --git a/packages/grid/x-grid/src/tests/editRows.XGrid.test.tsx b/packages/grid/x-grid/src/tests/editRows.XGrid.test.tsx new file mode 100644 index 0000000000000..a73737e7f36ad --- /dev/null +++ b/packages/grid/x-grid/src/tests/editRows.XGrid.test.tsx @@ -0,0 +1,217 @@ +import { + GRID_CELL_KEYDOWN, + GridApiRef, + GridComponentProps, + useGridApiRef, + XGrid, +} from '@material-ui/x-grid'; +import { expect } from 'chai'; +import * as React from 'react'; +import { getActiveCell, getCell } from 'test/utils/helperFn'; +import { + createClientRenderStrictMode, + // @ts-expect-error need to migrate helpers to TypeScript + fireEvent, +} from 'test/utils'; + +describe(' - Edit Rows', () => { + let baselineProps; + + before(function beforeHook() { + if (/jsdom/.test(window.navigator.userAgent)) { + // Need layouting + this.skip(); + } + }); + + // TODO v5: replace with createClientRender + const render = createClientRenderStrictMode(); + beforeEach(() => { + baselineProps = { + rows: [ + { + id: 0, + brand: 'Nike', + year: 1941, + }, + { + id: 1, + brand: 'Adidas', + year: 1961, + }, + { + id: 2, + brand: 'Puma', + year: 1921, + }, + ], + columns: [ + { field: 'brand', editable: true }, + { field: 'year', editable: true }, + ], + }; + }); + + let apiRef: GridApiRef; + + const TestCase = (props: Partial) => { + apiRef = useGridApiRef(); + return ( +
+ +
+ ); + }; + + it('isCellEditable should add the class MuiDataGrid-cellEditable to editable cells but not prevent a cell from switching mode', () => { + render( params.value === 'Adidas'} />); + const cellNike = getCell(0, 0); + expect(cellNike).to.not.have.class('MuiDataGrid-cellEditable'); + const cellAdidas = getCell(1, 0); + expect(cellAdidas).to.have.class('MuiDataGrid-cellEditable'); + + apiRef!.current.setCellMode(0, 'brand', 'edit'); + expect(cellNike).to.have.class('MuiDataGrid-cellEditing'); + }); + + it('should allow to switch between cell mode', () => { + render(); + apiRef!.current.setCellMode(1, 'brand', 'edit'); + const cell = getCell(1, 0); + + expect(cell).to.have.class('MuiDataGrid-cellEditable'); + expect(cell).to.have.class('MuiDataGrid-cellEditing'); + expect(cell.querySelector('input')!.value).to.equal('Adidas'); + + apiRef!.current.setCellMode(1, 'brand', 'view'); + expect(cell).to.have.class('MuiDataGrid-cellEditable'); + expect(cell).to.not.have.class('MuiDataGrid-cellEditing'); + expect(cell.querySelector('input')).to.equal(null); + }); + + it('should allow to switch between cell mode using double click', () => { + render(); + const cell = getCell(1, 0); + cell.focus(); + fireEvent.doubleClick(cell); + + expect(cell).to.have.class('MuiDataGrid-cellEditable'); + expect(cell).to.have.class('MuiDataGrid-cellEditing'); + expect(cell.querySelector('input')!.value).to.equal('Adidas'); + }); + + it('should allow to switch between cell mode using enter key', () => { + render(); + const cell = getCell(1, 0); + cell.focus(); + fireEvent.keyDown(cell, { key: 'Enter' }); + + expect(cell).to.have.class('MuiDataGrid-cellEditable'); + expect(cell).to.have.class('MuiDataGrid-cellEditing'); + expect(cell.querySelector('input')!.value).to.equal('Adidas'); + }); + + it('should allow to delete a cell directly if editable using delete key', () => { + render(); + const cell = getCell(1, 0); + cell.focus(); + + expect(cell.textContent).to.equal('Adidas'); + fireEvent.keyDown(cell, { key: 'Delete' }); + expect(cell).to.have.class('MuiDataGrid-cellEditable'); + expect(cell).to.not.have.class('MuiDataGrid-cellEditing'); + expect(cell.textContent).to.equal(''); + }); + + // Due to an issue with the keyDown event in test library, this test uses the apiRef to publish an event + // https://github.com/testing-library/dom-testing-library/issues/405 + it('should allow to edit a cell value by typing an alpha char', () => { + render(); + const cell = getCell(1, 0); + cell.focus(); + expect(cell.textContent).to.equal('Adidas'); + const params = apiRef.current.getCellParams(1, 'brand'); + apiRef.current.publishEvent(GRID_CELL_KEYDOWN, params, { key: 'a', code: 1, target: cell }); + // fireEvent.keyDown(cell, { key: 'a', code: 1, target: cell }); + + expect(cell).to.have.class('MuiDataGrid-cellEditable'); + expect(cell).to.have.class('MuiDataGrid-cellEditing'); + // we can't check input as we did not fire the real keyDown event + // expect(cell.querySelector('input')!.value).to.equal('a'); + }); + + it('should allow to rollback from edit changes using Escape', () => { + render(); + const cell = getCell(1, 0); + cell.focus(); + fireEvent.doubleClick(cell); + const input = cell.querySelector('input')!; + expect(input.value).to.equal('Adidas'); + + fireEvent.change(input, { target: { value: 'n' } }); + expect(cell.querySelector('input')!.value).to.equal('n'); + + fireEvent.keyDown(input, { key: 'Escape' }); + expect(cell).to.have.class('MuiDataGrid-cellEditable'); + expect(cell).to.not.have.class('MuiDataGrid-cellEditing'); + expect(cell.textContent).to.equal('Adidas'); + }); + + it('should allow to save an edit changes using Enter', () => { + render(); + const cell = getCell(1, 0); + cell.focus(); + fireEvent.doubleClick(cell); + const input = cell.querySelector('input')!; + expect(input.value).to.equal('Adidas'); + fireEvent.change(input, { target: { value: 'n' } }); + expect(cell.querySelector('input')!.value).to.equal('n'); + + fireEvent.keyDown(input, { key: 'Enter' }); + expect(cell).to.have.class('MuiDataGrid-cellEditable'); + expect(cell).to.not.have.class('MuiDataGrid-cellEditing'); + expect(cell.textContent).to.equal('n'); + expect(getActiveCell()).to.equal('2-0'); + }); + + it('should allow to save an edit changes using Tab', () => { + render(); + const cell = getCell(1, 0); + cell.focus(); + fireEvent.doubleClick(cell); + const input = cell.querySelector('input')!; + expect(input.value).to.equal('Adidas'); + + fireEvent.change(input, { target: { value: 'n' } }); + expect(cell.querySelector('input')!.value).to.equal('n'); + + fireEvent.keyDown(input, { key: 'Tab' }); + expect(cell).to.have.class('MuiDataGrid-cellEditable'); + expect(cell).to.not.have.class('MuiDataGrid-cellEditing'); + expect(cell.textContent).to.equal('n'); + expect(getActiveCell()).to.equal('1-1'); + }); + + it('should allow to save an edit changes using shift+Tab', () => { + render(); + const cell = getCell(1, 1); + cell.focus(); + fireEvent.doubleClick(cell); + const input = cell.querySelector('input')!; + expect(input.value).to.equal('1961'); + + fireEvent.change(input, { target: { value: '1970' } }); + expect(cell.querySelector('input')!.value).to.equal('1970'); + + fireEvent.keyDown(input, { key: 'Tab', shiftKey: true }); + expect(cell).to.have.class('MuiDataGrid-cellEditable'); + expect(cell).to.not.have.class('MuiDataGrid-cellEditing'); + expect(cell.textContent).to.equal('1970'); + expect(getActiveCell()).to.equal('1-0'); + }); +}); diff --git a/packages/grid/x-grid/src/tests/events.XGrid.test.tsx b/packages/grid/x-grid/src/tests/events.XGrid.test.tsx index d051946d3bd3d..646b368fc5ae2 100644 --- a/packages/grid/x-grid/src/tests/events.XGrid.test.tsx +++ b/packages/grid/x-grid/src/tests/events.XGrid.test.tsx @@ -14,8 +14,10 @@ import { GridCellParams, GridRowsProp, GridColumns, + GRID_ROWS_SCROLL, } from '@material-ui/x-grid'; import { getCell, getColumnHeaderCell, getRow } from 'test/utils/helperFn'; +import { spy } from 'sinon'; describe(' - Events Params ', () => { // TODO v5: replace with createClientRender @@ -217,4 +219,55 @@ describe(' - Events Params ', () => { expect(eventStack).to.deep.equal([]); }); }); + it('publishing GRID_ROWS_SCROLL should call onRowsScrollEnd callback', () => { + const handleOnRowsScrollEnd = spy(); + + render(); + apiRef.current.publishEvent(GRID_ROWS_SCROLL); + expect(handleOnRowsScrollEnd.callCount).to.equal(1); + }); + + it('call onRowsScrollEnd when viewport scroll reaches the bottom', () => { + const handleOnRowsScrollEnd = spy(); + const data = { + rows: [ + { + id: 0, + brand: 'Nike', + }, + { + id: 1, + brand: 'Adidas', + }, + { + id: 2, + brand: 'Puma', + }, + { + id: 3, + brand: 'Under Armor', + }, + { + id: 4, + brand: 'Jordan', + }, + { + id: 5, + brand: 'Reebok', + }, + ], + columns: [{ field: 'brand', width: 100 }], + }; + + const { container } = render( +
+ +
, + ); + const gridWindow = container.querySelector('.MuiDataGrid-window'); + // arbitrary number to make sure that the bottom of the grid window is reached. + gridWindow.scrollTop = 12345; + gridWindow.dispatchEvent(new Event('scroll')); + expect(handleOnRowsScrollEnd.callCount).to.equal(1); + }); }); diff --git a/packages/grid/x-grid/src/tests/export.XGrid.test.tsx b/packages/grid/x-grid/src/tests/export.XGrid.test.tsx new file mode 100644 index 0000000000000..e47368a253bec --- /dev/null +++ b/packages/grid/x-grid/src/tests/export.XGrid.test.tsx @@ -0,0 +1,106 @@ +import { GridApiRef, useGridApiRef, XGrid } from '@material-ui/x-grid'; +import { expect } from 'chai'; +import * as React from 'react'; +import { createClientRenderStrictMode } from 'test/utils'; + +describe(' - Export', () => { + before(function beforeHook() { + if (/jsdom/.test(window.navigator.userAgent)) { + // Need layouting + this.skip(); + } + }); + + // TODO v5: replace with createClientRender + const render = createClientRenderStrictMode(); + + let apiRef: GridApiRef; + + it('getDataAsCsv should return the correct string representation of the grid data', () => { + const TestCaseCSVExport = () => { + apiRef = useGridApiRef(); + return ( +
+ +
+ ); + }; + + render(); + expect(apiRef.current.getDataAsCsv()).to.equal('Brand\r\nNike\r\nAdidas\r\nPuma'); + apiRef.current.updateRows([ + { + id: 1, + brand: 'Adidas,Reebok', + }, + ]); + expect(apiRef.current.getDataAsCsv()).to.equal('Brand\r\nNike\r\n"Adidas,Reebok"\r\nPuma'); + }); + + it('getDataAsCsv should return the correct string representation of the grid data if cell contains comma', () => { + const TestCaseCSVExport = () => { + apiRef = useGridApiRef(); + return ( +
+ +
+ ); + }; + + render(); + expect(apiRef.current.getDataAsCsv()).to.equal('Brand\r\nNike\r\n"Adidas,Puma"'); + }); + + it('getDataAsCsv should return the correct string representation of the grid data if cell contains comma and double quotes', () => { + const TestCaseCSVExport = () => { + apiRef = useGridApiRef(); + return ( +
+ +
+ ); + }; + + render(); + expect(apiRef.current.getDataAsCsv()).to.equal('Brand\r\n"Nike,""Adidas"",Puma"'); + }); +}); diff --git a/packages/grid/x-grid/src/tests/rows.XGrid.test.tsx b/packages/grid/x-grid/src/tests/rows.XGrid.test.tsx index 803d6f36ffd3d..4493c1c27ba9e 100644 --- a/packages/grid/x-grid/src/tests/rows.XGrid.test.tsx +++ b/packages/grid/x-grid/src/tests/rows.XGrid.test.tsx @@ -3,42 +3,22 @@ import { createClientRenderStrictMode } from 'test/utils'; import { useFakeTimers } from 'sinon'; import { expect } from 'chai'; import { getCell, getColumnValues } from 'test/utils/helperFn'; -import { GridApiRef, GridColDef, GridRowData, useGridApiRef, XGrid } from '@material-ui/x-grid'; +import { + GridApiRef, + GridColDef, + GridComponentProps, + GridRowData, + useGridApiRef, + XGrid, +} from '@material-ui/x-grid'; describe(' - Rows ', () => { let clock; + let baselineProps: { columns: GridColDef[]; rows: GridRowData[] }; - beforeEach(() => { - clock = useFakeTimers(); - }); - - afterEach(() => { - clock.restore(); - }); // TODO v5: replace with createClientRender const render = createClientRenderStrictMode(); - const baselineProps: { columns: GridColDef[]; rows: GridRowData[] } = { - rows: [ - { - clientId: 'c1', - first: 'Mike', - age: 11, - }, - { - clientId: 'c2', - first: 'Jack', - age: 11, - }, - { - clientId: 'c3', - first: 'Mike', - age: 20, - }, - ], - columns: [{ field: 'id' }, { field: 'first' }, { field: 'age' }], - }; - before(function beforeHook() { if (/jsdom/.test(window.navigator.userAgent)) { // Need layouting @@ -47,6 +27,35 @@ describe(' - Rows ', () => { }); describe('getRowId', () => { + beforeEach(() => { + clock = useFakeTimers(); + + baselineProps = { + rows: [ + { + clientId: 'c1', + first: 'Mike', + age: 11, + }, + { + clientId: 'c2', + first: 'Jack', + age: 11, + }, + { + clientId: 'c3', + first: 'Mike', + age: 20, + }, + ], + columns: [{ field: 'id' }, { field: 'first' }, { field: 'age' }], + }; + }); + + afterEach(() => { + clock.restore(); + }); + describe('updateRows', () => { it('should apply getRowId before updating rows', () => { const getRowId = (row) => `${row.clientId}`; @@ -99,4 +108,145 @@ describe(' - Rows ', () => { expect(cell.querySelector('input')).to.equal(null); }); }); + + describe('updateRows', () => { + beforeEach(() => { + clock = useFakeTimers(); + + baselineProps = { + rows: [ + { + id: 0, + brand: 'Nike', + }, + { + id: 1, + brand: 'Adidas', + }, + { + id: 2, + brand: 'Puma', + }, + ], + columns: [{ field: 'brand', headerName: 'Brand' }], + }; + }); + + afterEach(() => { + clock.restore(); + }); + + let apiRef: GridApiRef; + + const TestCase = (props: Partial) => { + apiRef = useGridApiRef(); + return ( +
+ +
+ ); + }; + + it('should allow to reset rows with setRows and render after 100ms', () => { + render(); + const newRows = [ + { + id: 3, + brand: 'Asics', + }, + ]; + apiRef.current.setRows(newRows); + + clock.tick(50); + expect(getColumnValues()).to.deep.equal(['Nike', 'Adidas', 'Puma']); + clock.tick(50); + expect(getColumnValues()).to.deep.equal(['Asics']); + }); + + it('should allow to update row data', () => { + render(); + apiRef.current.updateRows([{ id: 1, brand: 'Fila' }]); + apiRef.current.updateRows([{ id: 0, brand: 'Pata' }]); + apiRef.current.updateRows([{ id: 2, brand: 'Pum' }]); + clock.tick(100); + expect(getColumnValues()).to.deep.equal(['Pata', 'Fila', 'Pum']); + }); + + it('update row data can also add rows', () => { + render(); + apiRef.current.updateRows([{ id: 1, brand: 'Fila' }]); + apiRef.current.updateRows([{ id: 0, brand: 'Pata' }]); + apiRef.current.updateRows([{ id: 2, brand: 'Pum' }]); + apiRef.current.updateRows([{ id: 3, brand: 'Jordan' }]); + clock.tick(100); + expect(getColumnValues()).to.deep.equal(['Pata', 'Fila', 'Pum', 'Jordan']); + }); + + it('update row data can also add rows in bulk', () => { + render(); + apiRef.current.updateRows([ + { id: 1, brand: 'Fila' }, + { id: 0, brand: 'Pata' }, + { id: 2, brand: 'Pum' }, + { id: 3, brand: 'Jordan' }, + ]); + clock.tick(100); + expect(getColumnValues()).to.deep.equal(['Pata', 'Fila', 'Pum', 'Jordan']); + }); + + it('update row data can also delete rows', () => { + render(); + apiRef.current.updateRows([{ id: 1, _action: 'delete' }]); + apiRef.current.updateRows([{ id: 0, brand: 'Apple' }]); + apiRef.current.updateRows([{ id: 2, _action: 'delete' }]); + apiRef.current.updateRows([{ id: 5, brand: 'Atari' }]); + clock.tick(100); + expect(getColumnValues()).to.deep.equal(['Apple', 'Atari']); + }); + + it('update row data can also delete rows in bulk', () => { + render(); + apiRef.current.updateRows([ + { id: 1, _action: 'delete' }, + { id: 0, brand: 'Apple' }, + { id: 2, _action: 'delete' }, + { id: 5, brand: 'Atari' }, + ]); + clock.tick(100); + expect(getColumnValues()).to.deep.equal(['Apple', 'Atari']); + }); + + it('update row data should process getRowId', () => { + const TestCaseGetRowId = () => { + apiRef = useGridApiRef(); + const getRowId = React.useCallback((row: GridRowData) => row.idField, []); + return ( +
+ ({ idField: row.id, brand: row.brand }))} + getRowId={getRowId} + /> +
+ ); + }; + + render(); + expect(getColumnValues()).to.deep.equal(['Nike', 'Adidas', 'Puma']); + apiRef.current.updateRows([ + { idField: 1, _action: 'delete' }, + { idField: 0, brand: 'Apple' }, + { idField: 2, _action: 'delete' }, + { idField: 5, brand: 'Atari' }, + ]); + clock.tick(100); + expect(getColumnValues()).to.deep.equal(['Apple', 'Atari']); + }); + }); }); diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx index e6b45512938c4..a93244795d8c6 100644 --- a/packages/storybook/.storybook/preview.tsx +++ b/packages/storybook/.storybook/preview.tsx @@ -18,7 +18,7 @@ export const parameters = { // due to its circular structure actions: { argTypesRegex: - '^on((?!CellClick|CellDoubleClick|CellEnter|CellLeave|CellOut|CellOver|ColumnHeaderClick|ColumnHeaderDoubleClick|ColumnHeaderOver|ColumnHeaderOut|ColumnHeaderEnter|ColumnHeaderLeave|StateChange|RowClick|RowDoubleClick|RowEnter|RowLeave|RowOut|RowOver).)*$', + '^on((?!CellClick|CellDoubleClick|CellEditBlur|CellKeyDown|CellEnter|CellLeave|CellMouseDown|CellOut|CellOver|ColumnHeaderClick|ColumnHeaderDoubleClick|ColumnHeaderOver|ColumnHeaderOut|ColumnHeaderEnter|ColumnHeaderLeave|StateChange|RowClick|RowDoubleClick|RowEnter|RowLeave|RowOut|RowOver).)*$', }, options: { /** diff --git a/packages/storybook/src/stories/grid-rows.stories.tsx b/packages/storybook/src/stories/grid-rows.stories.tsx index e1ffc84bf3306..14344c3564aa9 100644 --- a/packages/storybook/src/stories/grid-rows.stories.tsx +++ b/packages/storybook/src/stories/grid-rows.stories.tsx @@ -14,7 +14,8 @@ import { GridRowData, useGridApiRef, XGrid, - GridEditCellParams, + GRID_CELL_EXIT_EDIT, + GridEditCellPropsParams, } from '@material-ui/x-grid'; import { useDemoData } from '@material-ui/x-grid-data-generator'; import { action } from '@storybook/addon-actions'; @@ -400,7 +401,7 @@ const baselineEditProps = { { field: 'fullname', editable: true, - valueGetter: ({ row }) => `${row.firstname} ${row.lastname}`, + valueGetter: ({ row }) => `${row.firstname || ''} ${row.lastname || ''}`, }, { field: 'username', editable: true }, { field: 'email', editable: true, width: 150 }, @@ -446,82 +447,64 @@ export function EditRowsControl() { if (!selectedCell) { return; } - const [id, field, value] = selectedCell; + const [id, field] = selectedCell; - setEditRowsModel((state) => { - const editRowState: GridEditRowsModel = { ...state }; - editRowState[id] = editRowState[id] ? { ...editRowState[id] } : {}; - editRowState[id][field] = { value }; - - return { ...state, ...editRowState }; - }); - }, [selectedCell]); + apiRef.current.setCellMode(id, field, 'edit'); + }, [apiRef, selectedCell]); const onCellClick = React.useCallback((params: GridCellParams) => { setSelectedCell([params.row.id!.toString(), params.field, params.value]); setIsEditable(!!params.isEditable); }, []); - const onCellDoubleClick = React.useCallback( - (params: GridCellParams) => { - if (params.isEditable) { - apiRef.current.setCellMode(params.row.id!.toString(), params.field, 'edit'); - } - }, - [apiRef], - ); - const isCellEditable = React.useCallback((params: GridCellParams) => params.row.id !== 0, []); const onEditCellChange = React.useCallback( - ({ id, update }: GridEditCellParams) => { - if (update.email) { - const isValid = validateEmail(update.email.value); + ({ id, field, props }: GridEditCellPropsParams) => { + if (field === 'email') { + const isValid = validateEmail(props.value); const newState = {}; newState[id] = { ...editRowsModel[id], - email: { ...update.email, error: !isValid }, + email: { ...props, error: !isValid }, }; - newState[id].email.value += 'EXTERRRR'; setEditRowsModel((state) => ({ ...state, ...newState })); return; } const newState = {}; newState[id] = { ...editRowsModel[id], - ...update, }; + newState[id][field] = props; setEditRowsModel((state) => ({ ...state, ...newState })); }, [editRowsModel], ); const onEditCellChangeCommitted = React.useCallback( - ({ id, update }: GridEditCellParams) => { - const field = Object.keys(update)[0]!; - const rowUpdate = { id }; - rowUpdate[field] = update[field].value; + (params: GridEditCellPropsParams, event?: React.SyntheticEvent) => { + const { id, field, props } = params; + event!.persist(); + // we stop propagation as we want to switch back to view mode after we updated the value on the server + event!.stopPropagation(); - if (update.email) { - const newState = {}; - const componentProps = { - endAdornment: , - }; - newState[id] = {}; - newState[id][field] = { ...update.email, ...componentProps }; - setEditRowsModel((state) => ({ ...state, ...newState })); - setTimeout(() => { - apiRef.current.updateRows([rowUpdate]); - apiRef.current.setCellMode(id, field, 'view'); - }, 2000); - } else if (update.fullname && update.fullname.value) { - const [firstname, lastname] = update.fullname.value.toString().split(' '); - apiRef.current.updateRows([{ id, firstname, lastname }]); - apiRef.current.setCellMode(id, field, 'view'); - } else { - apiRef.current.updateRows([rowUpdate]); - apiRef.current.setCellMode(id, field, 'view'); + let cellUpdate: any = { id }; + cellUpdate[field] = props.value; + + const newState = {}; + newState[id] = {}; + newState[id][field] = { ...props, endAdornment: }; + setEditRowsModel((state) => ({ ...state, ...newState })); + + if (field === 'fullname') { + const [firstname, lastname] = props.value!.toString().split(' '); + cellUpdate = { id, firstname, lastname }; } + + setTimeout(() => { + apiRef.current.updateRows([cellUpdate]); + apiRef.current.publishEvent(GRID_CELL_EXIT_EDIT, params, event); + }, randomInt(300, 2000)); }, [apiRef], ); @@ -540,7 +523,6 @@ export function EditRowsControl() { {...baselineEditProps} apiRef={apiRef} onCellClick={onCellClick} - onCellDoubleClick={onCellDoubleClick} isCellEditable={isCellEditable} onEditCellChange={onEditCellChange} onEditCellChangeCommitted={onEditCellChangeCommitted} @@ -554,23 +536,12 @@ export function EditRowsControl() { export function EditRowsBasic() { const apiRef = useGridApiRef(); - const onCellDoubleClick = React.useCallback( - (params: GridCellParams) => { - if (params.isEditable) { - apiRef.current.setCellMode(params.row.id!.toString(), params.field, 'edit'); - } - }, - [apiRef], - ); - return ( - Double click to edit.
@@ -583,50 +554,22 @@ singleData.columns.length = 1; singleData.columns[0].width = 200; export function SingleCellBasic() { - const apiRef = useGridApiRef(); - const onCellDoubleClick = React.useCallback( - (params: GridCellParams) => { - if (params.isEditable) { - apiRef.current.setCellMode(params.row.id!.toString(), params.field, 'edit'); - } - }, - [apiRef], - ); - return ( - - Double click to edit. -
- -
-
+
+ +
); } export function CommodityEdit() { - const apiRef = useGridApiRef(); - const onCellDoubleClick = React.useCallback( - (params: GridCellParams) => { - apiRef.current.setCellMode(params.row.id!.toString(), params.field, 'edit'); - }, - [apiRef], - ); - const { data } = useDemoData({ dataSet: 'Commodity', rowLength: 100000, }); return ( - -
- -
-
+
+ +
); } @@ -634,7 +577,6 @@ export function EditCellSnap() { const apiRef = useGridApiRef(); React.useEffect(() => { - apiRef.current.setCellMode(0, 'brand', 'edit'); apiRef.current.setCellMode(1, 'brand', 'edit'); }); diff --git a/packages/storybook/src/stories/playground/real-data-demo.stories.tsx b/packages/storybook/src/stories/playground/real-data-demo.stories.tsx index 330f0d72ba988..5211d98c1a2ee 100644 --- a/packages/storybook/src/stories/playground/real-data-demo.stories.tsx +++ b/packages/storybook/src/stories/playground/real-data-demo.stories.tsx @@ -40,7 +40,7 @@ export default { }, }, rowLength: { - defaultValue: 10000, + defaultValue: 2000, control: { type: 'select', options: [100, 500, 1000, 2000, 5000, 8000, 10000, 50000, 100000, 500000],