From 3085f28fc140bfc5ffc3f27cf85badaabed8f500 Mon Sep 17 00:00:00 2001 From: Danail Hadjiatanasov Date: Thu, 15 Oct 2020 16:49:49 +0200 Subject: [PATCH] [DataGrid] Add column reorder support (#165) * [useColumnReorder] Add the ability to reorder columns using drag and drop * [useColumnReorder] Fix review comments * fix broking functionality * Fix issues related to pulling the latest changes * {useColumnReorder] Refactor the feature hook and add drag scroll support for the ColumnsHeader * [useColumnReorder] Fix lint and format issues * [useColumnsReorder] Fix build and formatting * [useColumnReorder] Fix lint errors * Fix styles downgrade when dragging a column cell * [useColumnReorder] Added option to disable the feature, added docs, added tests * [useColumnReorder] Fix formatting and enable all the tests * Remove unnessesary code from the mouse.test.ts file * Fix formatting * fix formatting * Emit event when drag enters a column * Working on PR comments * Fix inifinte loop when dragging smaller cal over large col * Rename drag handlers prefix * Prep codebase for commit and push * Run prettier * Fix lint errors * Rename file to match coding style * Rename columnsHeaderRef to ref * Fix PR comments * Update docs/pages/api-docs/x-grid.md Co-authored-by: Matt * Update docs/pages/api-docs/x-grid.md Co-authored-by: Matt * Update docs/pages/api-docs/x-grid.md Co-authored-by: Matt * Add relevant documentation about coumn reorder on the /components/data-grid/columns/ page * Fix docs formatting * Update docs/src/pages/components/data-grid/columns/columns.md Co-authored-by: Olivier Tassinari * Update docs/src/pages/components/data-grid/columns/columns.md Co-authored-by: Olivier Tassinari * Update docs/src/pages/components/data-grid/columns/columns.md Co-authored-by: Olivier Tassinari * Update docs/src/pages/components/data-grid/columns/columns.md Co-authored-by: Olivier Tassinari * Update code example * Fix PR comments * Fix the doc example code * Update docs/src/pages/components/data-grid/columns/ColumnOrderingGrid.tsx Co-authored-by: Olivier Tassinari * Fix column reordering issue while dragging over the same cell * Make scrolling while dragging excelerate * Format and lint the changes * Fix scroll left issue * Fix build * fix scroll smoothness * Fix Col header item alignment visuall regression * Final polishing of the useColumnReorder hook * Fix typings * the logic never runs on Node.js * clear timeout when unmounting, avoid edge-case leak * use design token * no shorthand * Use CSS inherit * simpler class name logic * we have strong constraint that these value should be defined. It should fail if its not the case * no shorthands * the logic depends on the element to be the current target, not the target, remove potential confusion * add visual clue about where the column is, outline shouldn't be visible * Remove unnecessary checks related to disableColumnReorder flag * Fix PR comments related * remove raf Co-authored-by: Matt Co-authored-by: Olivier Tassinari Co-authored-by: Danail Hadzhiatanasov --- .../data-grid/columns/ColumnOrderingGrid.js | 17 ++ .../data-grid/columns/ColumnOrderingGrid.tsx | 17 ++ .../components/data-grid/columns/columns.md | 24 ++- .../grid/_modules_/grid/GridComponent.tsx | 6 + .../_modules_/grid/components/AutoSizer.tsx | 4 +- .../_modules_/grid/components/ScrollArea.tsx | 77 +++++++++ .../grid/components/column-header-item.tsx | 102 ++++++----- .../grid/components/column-headers.tsx | 70 ++++++-- .../grid/_modules_/grid/components/index.ts | 1 + .../styled-wrappers/GridRootStyles.ts | 30 ++++ .../grid/constants/cssClassesConstants.ts | 2 + .../grid/constants/eventsConstants.ts | 7 + .../_modules_/grid/hooks/features/index.ts | 1 + .../grid/hooks/features/useColumnReorder.tsx | 158 ++++++++++++++++++ .../_modules_/grid/hooks/root/useColumns.ts | 35 ++-- .../grid/hooks/utils/useOptionsProp.ts | 2 + .../_modules_/grid/models/api/columnApi.ts | 5 +- .../_modules_/grid/models/gridOptions.tsx | 5 + packages/grid/data-grid/src/DataGrid.tsx | 23 ++- packages/storybook/integration/helper-fn.ts | 2 +- packages/storybook/integration/mouse.test.ts | 77 +++++++++ .../integration/staticStories.test.ts | 1 + .../src/stories/grid-reorder.stories.tsx | 28 ++++ 23 files changed, 609 insertions(+), 85 deletions(-) create mode 100644 docs/src/pages/components/data-grid/columns/ColumnOrderingGrid.js create mode 100644 docs/src/pages/components/data-grid/columns/ColumnOrderingGrid.tsx create mode 100644 packages/grid/_modules_/grid/components/ScrollArea.tsx create mode 100644 packages/grid/_modules_/grid/hooks/features/useColumnReorder.tsx create mode 100644 packages/storybook/integration/mouse.test.ts create mode 100644 packages/storybook/src/stories/grid-reorder.stories.tsx diff --git a/docs/src/pages/components/data-grid/columns/ColumnOrderingGrid.js b/docs/src/pages/components/data-grid/columns/ColumnOrderingGrid.js new file mode 100644 index 0000000000000..634ff4c3fe631 --- /dev/null +++ b/docs/src/pages/components/data-grid/columns/ColumnOrderingGrid.js @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { XGrid } from '@material-ui/x-grid'; +import { useDemoData } from '@material-ui/x-grid-data-generator'; + +export default function ColumnOrderingGrid() { + const { data } = useDemoData({ + dataSet: 'Commodity', + rowLength: 20, + maxColumns: 20, + }); + + return ( +
+ +
+ ); +} diff --git a/docs/src/pages/components/data-grid/columns/ColumnOrderingGrid.tsx b/docs/src/pages/components/data-grid/columns/ColumnOrderingGrid.tsx new file mode 100644 index 0000000000000..634ff4c3fe631 --- /dev/null +++ b/docs/src/pages/components/data-grid/columns/ColumnOrderingGrid.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { XGrid } from '@material-ui/x-grid'; +import { useDemoData } from '@material-ui/x-grid-data-generator'; + +export default function ColumnOrderingGrid() { + const { data } = useDemoData({ + dataSet: 'Commodity', + rowLength: 20, + maxColumns: 20, + }); + + return ( +
+ +
+ ); +} diff --git a/docs/src/pages/components/data-grid/columns/columns.md b/docs/src/pages/components/data-grid/columns/columns.md index 1a08c0a693ac8..d0ed067d9b3c7 100644 --- a/docs/src/pages/components/data-grid/columns/columns.md +++ b/docs/src/pages/components/data-grid/columns/columns.md @@ -103,21 +103,29 @@ const usdPrice: ColTypeDef = { {{"demo": "pages/components/data-grid/columns/CustomColumnTypesGrid.js", "bg": "inline"}} -## 🚧 Column groups +## Column reorder [⚡️](https://material-ui.com/store/items/material-ui-x/) -> ⚠️ This feature isn't implemented yet. It's coming. -> -> 👍 Upvote [issue #195](https://github.com/mui-org/material-ui-x/issues/195) if you want to see it land faster. +By default, `XGrid` allows all column reordering by dragging the header cells and moving them left or right. -Grouping columns allows you to have multiple levels of columns in your header and the ability, if needed, to 'open and close' column groups to show and hide additional columns. +To disable column reordering, set the prop `disableColumnReorder={true}`. + +In addition, column reordering emits the following events that can be imported: -## 🚧 Column reorder [⚡️](https://material-ui.com/store/items/material-ui-x/) +- `COL_REORDER_START`: emitted when dragging of a header cell starts. +- `COL_REORDER_DRAG_ENTER`: emitted when the cursor enters another header cell while dragging. +- `COL_REORDER_DRAG_OVER`: emitted when dragging a header cell over another header cell. +- `COL_REORDER_DRAG_OVER_HEADER`: emitted when dragging a header cell over the `ColumnsHeader` component. +- `COL_REORDER_STOP`: emitted when dragging of a header cell stops. + +{{"demo": "pages/components/data-grid/columns/ColumnOrderingGrid.js", "disableAd": true, "bg": "inline"}} + +## 🚧 Column groups > ⚠️ This feature isn't implemented yet. It's coming. > -> 👍 Upvote [issue #194](https://github.com/mui-org/material-ui-x/issues/194) if you want to see it land faster. +> 👍 Upvote [issue #195](https://github.com/mui-org/material-ui-x/issues/195) if you want to see it land faster. -Column reordering enables reordering the columns by dragging the header cells. +Grouping columns allows you to have multiple levels of columns in your header and the ability, if needed, to 'open and close' column groups to show and hide additional columns. ## 🚧 Column pinning [⚡️](https://material-ui.com/store/items/material-ui-x/) diff --git a/packages/grid/_modules_/grid/GridComponent.tsx b/packages/grid/_modules_/grid/GridComponent.tsx index 7cd79615b4de6..ed6b473268c2c 100644 --- a/packages/grid/_modules_/grid/GridComponent.tsx +++ b/packages/grid/_modules_/grid/GridComponent.tsx @@ -3,6 +3,7 @@ import { useForkRef } from '@material-ui/core/utils'; import { GridComponentProps } from './GridComponentProps'; import { useApiRef, + useColumnReorder, useColumnResize, useComponents, usePagination, @@ -92,6 +93,7 @@ export const GridComponent = React.forwardRef diff --git a/packages/grid/_modules_/grid/components/AutoSizer.tsx b/packages/grid/_modules_/grid/components/AutoSizer.tsx index 50763b78aa150..137bc8dc8ed07 100644 --- a/packages/grid/_modules_/grid/components/AutoSizer.tsx +++ b/packages/grid/_modules_/grid/components/AutoSizer.tsx @@ -70,8 +70,8 @@ export const AutoSizer = React.forwardRef(functi width: defaultWidth, }); - const rootRef = React.useRef(null); - const parentElement = React.useRef(null) as React.MutableRefObject; + const rootRef = React.useRef(null); + const parentElement = React.useRef(null); const handleResize = useEventCallback(() => { // Guard against AutoSizer component being removed from the DOM immediately after being added. diff --git a/packages/grid/_modules_/grid/components/ScrollArea.tsx b/packages/grid/_modules_/grid/components/ScrollArea.tsx new file mode 100644 index 0000000000000..6c953c3a9fd17 --- /dev/null +++ b/packages/grid/_modules_/grid/components/ScrollArea.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { COL_REORDER_START, COL_REORDER_STOP, SCROLLING } from '../constants/eventsConstants'; +import { ScrollParams, useApiEventHandler } from '../hooks'; +import { ApiRef } from '../models'; +import { classnames } from '../utils'; +import { ApiContext } from './api-context'; + +const CLIFF = 1; +const SLOP = 1.5; + +interface ScrollAreaProps { + scrollDirection: 'left' | 'right'; +} + +export const ScrollArea = React.memo(function ScrollArea(props: ScrollAreaProps) { + const { scrollDirection } = props; + const rootRef = React.useRef(null); + const api = React.useContext(ApiContext); + const timeout = React.useRef(); + const [dragging, setDragging] = React.useState(false); + const scrollPosition = React.useRef({ + left: 0, + top: 0, + }); + + const handleScrolling = React.useCallback((newScrollPosition) => { + scrollPosition.current = newScrollPosition; + }, []); + + const handleDragOver = React.useCallback( + (event) => { + let offset; + + if (scrollDirection === 'left') { + offset = event.clientX - rootRef.current!.getBoundingClientRect().right; + } else if (scrollDirection === 'right') { + offset = Math.max(1, event.clientX - rootRef.current!.getBoundingClientRect().left); + } else { + throw new Error('wrong dir'); + } + + offset = (offset - CLIFF) * SLOP + CLIFF; + + clearTimeout(timeout.current); + // Avoid freeze and inertia. + timeout.current = setTimeout(() => { + api!.current.scroll({ + left: scrollPosition.current.left + offset, + top: scrollPosition.current.top, + }); + }); + }, + [scrollDirection, api], + ); + + React.useEffect(() => { + return () => { + clearTimeout(timeout.current); + }; + }, []); + + const toggleDragging = React.useCallback(() => { + setDragging((prevdragging) => !prevdragging); + }, []); + + useApiEventHandler(api as ApiRef, SCROLLING, handleScrolling); + useApiEventHandler(api as ApiRef, COL_REORDER_START, toggleDragging); + useApiEventHandler(api as ApiRef, COL_REORDER_STOP, toggleDragging); + + return dragging ? ( +
+ ) : null; +}); diff --git a/packages/grid/_modules_/grid/components/column-header-item.tsx b/packages/grid/_modules_/grid/components/column-header-item.tsx index 2d952dacfa036..ee78ea41e08ce 100644 --- a/packages/grid/_modules_/grid/components/column-header-item.tsx +++ b/packages/grid/_modules_/grid/components/column-header-item.tsx @@ -7,33 +7,31 @@ import { ColumnHeaderSortIcon } from './column-header-sort-icon'; import { ColumnHeaderTitle } from './column-header-title'; import { ColumnHeaderSeparator } from './column-header-separator'; import { OptionsContext } from './options-context'; +import { CursorCoordinates } from '../hooks/features/useColumnReorder'; interface ColumnHeaderItemProps { column: ColDef; colIndex: number; onResizeColumn?: (c: any) => void; + onColumnDragStart?: (col: ColDef, currentTarget: HTMLElement) => void; + onColumnDragEnter?: (event: Event) => void; + onColumnDragOver?: (col: ColDef, coordinates: CursorCoordinates) => void; } -const headerAlignPropToCss = { - center: 'MuiDataGrid-colCellCenter', - right: 'MuiDataGrid-colCellRight', -}; + export const ColumnHeaderItem = React.memo( - ({ column, colIndex, onResizeColumn }: ColumnHeaderItemProps) => { + ({ + column, + colIndex, + onResizeColumn, + onColumnDragStart, + onColumnDragEnter, + onColumnDragOver, + }: ColumnHeaderItemProps) => { const api = React.useContext(ApiContext); - const { headerHeight, showColumnRightBorder, disableColumnResize } = React.useContext( + const { showColumnRightBorder, disableColumnResize, disableColumnReorder } = React.useContext( OptionsContext, ); - const cssClass = classnames( - HEADER_CELL_CSS_CLASS, - showColumnRightBorder ? 'MuiDataGrid-withBorder' : '', - column.headerClassName, - column.headerAlign && - column.headerAlign !== 'left' && - headerAlignPropToCss[column.headerAlign], - { 'MuiDataGrid-colCellSortable': column.sortable }, - ); - let headerComponent: React.ReactElement | null = null; if (column.renderHeader) { headerComponent = column.renderHeader({ @@ -44,8 +42,21 @@ export const ColumnHeaderItem = React.memo( }); } - const handleResize = onResizeColumn ? () => onResizeColumn(column) : undefined; - + const handleResize = onResizeColumn && (() => onResizeColumn(column)); + const dragConfig = { + draggable: + !disableColumnReorder && !!onColumnDragStart && !!onColumnDragEnter && !!onColumnDragOver, + onDragStart: onColumnDragStart && ((event) => onColumnDragStart(column, event.currentTarget)), + onDragEnter: onColumnDragEnter && ((event) => onColumnDragEnter(event)), + onDragOver: + onColumnDragOver && + ((event) => { + onColumnDragOver(column, { + x: event.clientX, + y: event.clientY, + }); + }), + }; const width = column.width!; let ariaSort: any; @@ -55,42 +66,49 @@ export const ColumnHeaderItem = React.memo( return (
- {column.type === 'number' && ( - - )} - {headerComponent || ( - - )} - {column.type !== 'number' && ( - - )} +
+ {column.type === 'number' && ( + + )} + {headerComponent || ( + + )} + {column.type !== 'number' && ( + + )} +
void; + onColumnDragStart?: (col: ColDef, htmlEL: HTMLElement) => void; + onColumnDragEnter?: (event: Event) => void; + onColumnDragOver?: (col: ColDef, pos: CursorCoordinates) => void; } export const ColumnHeaderItemCollection: React.FC = React.memo( - ({ onResizeColumn, columns }) => { + ({ onResizeColumn, onColumnDragStart, onColumnDragEnter, onColumnDragOver, columns }) => { const items = columns.map((col, idx) => ( )); @@ -28,14 +37,31 @@ export interface ColumnsHeaderProps { columns: Columns; hasScrollX: boolean; onResizeColumn?: (col: ColDef) => void; + onColumnHeaderDragOver?: (event: Event) => void; + onColumnDragOver?: (col: ColDef, pos: CursorCoordinates) => void; + onColumnDragStart?: (col: ColDef, htmlEl: HTMLElement) => void; + onColumnDragEnter?: (event: Event) => void; renderCtx: Partial | null; } export const ColumnsHeader = React.memo( React.forwardRef( - ({ columns, hasScrollX, onResizeColumn, renderCtx }, columnsHeaderRef) => { + ( + { + columns, + hasScrollX, + onResizeColumn, + onColumnHeaderDragOver, + onColumnDragOver, + onColumnDragStart, + onColumnDragEnter, + renderCtx, + }, + ref, + ) => { const wrapperCssClasses = `MuiDataGrid-colCellWrapper ${hasScrollX ? 'scroll' : ''}`; const api = React.useContext(ApiContext); + const { disableColumnReorder } = React.useContext(OptionsContext); if (!api) { throw new Error('Material-UI: ApiRef was not found in context.'); @@ -62,19 +88,35 @@ export const ColumnsHeader = React.memo( } }, [renderCtx, columns]); + const handleDragOver = + onColumnHeaderDragOver && !disableColumnReorder + ? (event) => onColumnHeaderDragOver(event) + : undefined; + return ( -
- - - -
+ + +
+ + + +
+ +
); }, ), diff --git a/packages/grid/_modules_/grid/components/index.ts b/packages/grid/_modules_/grid/components/index.ts index 02b606caa6b34..ec42b677394fc 100644 --- a/packages/grid/_modules_/grid/components/index.ts +++ b/packages/grid/_modules_/grid/components/index.ts @@ -23,3 +23,4 @@ export * from './sticky-container'; export * from './styled-wrappers'; export * from './viewport'; export * from './watermark'; +export * from './ScrollArea'; diff --git a/packages/grid/_modules_/grid/components/styled-wrappers/GridRootStyles.ts b/packages/grid/_modules_/grid/components/styled-wrappers/GridRootStyles.ts index 6ea3ad62ac55e..576a30c63b8f5 100644 --- a/packages/grid/_modules_/grid/components/styled-wrappers/GridRootStyles.ts +++ b/packages/grid/_modules_/grid/components/styled-wrappers/GridRootStyles.ts @@ -55,6 +55,19 @@ export const useStyles = makeStyles( borderBottom: `1px solid ${borderColor}`, zIndex: 100, }, + '& .MuiDataGrid-scrollArea': { + position: 'absolute', + top: 0, + zIndex: 101, + width: 20, + bottom: 0, + }, + '& .MuiDataGrid-scrollArea-left': { + left: 0, + }, + '& .MuiDataGrid-scrollArea-right': { + right: 0, + }, '& .MuiDataGrid-colCellWrapper': { display: 'flex', width: '100%', @@ -97,6 +110,9 @@ export const useStyles = makeStyles( whiteSpace: 'nowrap', fontWeight: theme.typography.fontWeightMedium, }, + '& .MuiDataGrid-colCellMoving': { + backgroundColor: theme.palette.action.hover, + }, '& .MuiDataGrid-columnSeparator': { position: 'absolute', right: -12, @@ -201,6 +217,20 @@ export const useStyles = makeStyles( display: 'flex', }, }, + '& .MuiDataGrid-colCell-dropZone .MuiDataGrid-colCell-draggable': { + cursor: 'move', + }, + '& .MuiDataGrid-colCell-draggable': { + display: 'flex', + width: '100%', + justifyContent: 'inherit', + }, + '& .MuiDataGrid-colCell-dragging': { + background: theme.palette.background.paper, + padding: '0 12px', + borderRadius: theme.shape.borderRadius, + opacity: theme.palette.action.disabledOpacity, + }, }, }; diff --git a/packages/grid/_modules_/grid/constants/cssClassesConstants.ts b/packages/grid/_modules_/grid/constants/cssClassesConstants.ts index c5012161cdcda..dd62b6933a9a6 100644 --- a/packages/grid/_modules_/grid/constants/cssClassesConstants.ts +++ b/packages/grid/_modules_/grid/constants/cssClassesConstants.ts @@ -3,3 +3,5 @@ export const CELL_CSS_CLASS = 'MuiDataGrid-cell'; export const ROW_CSS_CLASS = 'MuiDataGrid-row'; export const HEADER_CELL_CSS_CLASS = 'MuiDataGrid-colCell'; export const DATA_CONTAINER_CSS_CLASS = 'data-container'; +export const HEADER_CELL_DROP_ZONE_CSS_CLASS = 'MuiDataGrid-colCell-dropZone'; +export const HEADER_CELL_DRAGGING_CSS_CLASS = 'MuiDataGrid-colCell-dragging'; diff --git a/packages/grid/_modules_/grid/constants/eventsConstants.ts b/packages/grid/_modules_/grid/constants/eventsConstants.ts index 8991ff637f029..78ce0bb061c2a 100644 --- a/packages/grid/_modules_/grid/constants/eventsConstants.ts +++ b/packages/grid/_modules_/grid/constants/eventsConstants.ts @@ -6,6 +6,7 @@ export const FOCUS_OUT = 'focusout'; export const KEYDOWN = 'keydown'; export const KEYUP = 'keyup'; export const SCROLL = 'scroll'; +export const DRAGEND = 'dragend'; // XGRID events export const COMPONENT_ERROR = 'componentError'; @@ -29,6 +30,12 @@ export const SCROLLING_STOP = 'scrolling:stop'; export const COL_RESIZE_START = 'colResizing:start'; export const COL_RESIZE_STOP = 'colResizing:stop'; +export const COL_REORDER_START = 'colReordering:dragStart'; +export const COL_REORDER_DRAG_OVER_HEADER = 'colReordering:dragOverHeader'; +export const COL_REORDER_DRAG_OVER = 'colReordering:dragOver'; +export const COL_REORDER_DRAG_ENTER = 'colReordering:dragEnter'; +export const COL_REORDER_STOP = 'colReordering:dragStop'; + export const ROWS_UPDATED = 'rowsUpdated'; export const COLUMNS_UPDATED = 'columnsUpdated'; diff --git a/packages/grid/_modules_/grid/hooks/features/index.ts b/packages/grid/_modules_/grid/hooks/features/index.ts index 876215c969485..226718c21e4d9 100644 --- a/packages/grid/_modules_/grid/hooks/features/index.ts +++ b/packages/grid/_modules_/grid/hooks/features/index.ts @@ -1,5 +1,6 @@ export * from './useApiRef'; export * from './useComponents'; +export * from './useColumnReorder'; export * from './useColumnResize'; export * from './usePagination'; export * from './useSelection'; diff --git a/packages/grid/_modules_/grid/hooks/features/useColumnReorder.tsx b/packages/grid/_modules_/grid/hooks/features/useColumnReorder.tsx new file mode 100644 index 0000000000000..e80995bae50d9 --- /dev/null +++ b/packages/grid/_modules_/grid/hooks/features/useColumnReorder.tsx @@ -0,0 +1,158 @@ +import * as React from 'react'; +import { ColDef } from '../../models/colDef'; +import { useLogger } from '../utils'; +import { ApiRef } from '../../models'; +import { + DRAGEND, + COL_REORDER_START, + COL_REORDER_DRAG_OVER, + COL_REORDER_DRAG_OVER_HEADER, + COL_REORDER_DRAG_ENTER, + COL_REORDER_STOP, +} from '../../constants/eventsConstants'; +import { + HEADER_CELL_DROP_ZONE_CSS_CLASS, + HEADER_CELL_DRAGGING_CSS_CLASS, +} from '../../constants/cssClassesConstants'; + +export interface CursorCoordinates { + x: number; + y: number; +} + +const CURSOR_MOVE_DIRECTION_LEFT = 'left'; +const CURSOR_MOVE_DIRECTION_RIGHT = 'right'; + +const reorderColDefArray = ( + columns: ColDef[], + newColIndex: number, + oldColIndex: number, +): ColDef[] => { + const columnsClone = columns.slice(); + + columnsClone.splice(newColIndex, 0, columnsClone.splice(oldColIndex, 1)[0]); + + return columnsClone; +}; + +const getCursorMoveDirectionX = (currentCoordinates, nextCoordinates) => { + return currentCoordinates.x <= nextCoordinates.x + ? CURSOR_MOVE_DIRECTION_RIGHT + : CURSOR_MOVE_DIRECTION_LEFT; +}; + +const hasCursorPositionChanged = ( + currentCoordinates: CursorCoordinates, + nextCoordinates: CursorCoordinates, +): boolean => + currentCoordinates.x !== nextCoordinates.x || currentCoordinates.y !== nextCoordinates.y; + +export const useColumnReorder = (columnsRef: React.RefObject, apiRef: ApiRef) => { + const logger = useLogger('useColumnReorder'); + + const dragCol = React.useRef(null); + const dragColNode = React.useRef(null); + const cursorPosition = React.useRef({ + x: 0, + y: 0, + }); + const removeDnDStylesTimeout = React.useRef(); + + const handleDragEnd = React.useCallback((): void => { + logger.debug(`End dragging col ${dragCol.current!.field}`); + apiRef.current.publishEvent(COL_REORDER_STOP); + + clearTimeout(removeDnDStylesTimeout.current); + + columnsRef.current!.classList.remove(HEADER_CELL_DROP_ZONE_CSS_CLASS); + dragColNode.current!.parentElement!.classList.remove('MuiDataGrid-colCellMoving'); + dragColNode.current!.removeEventListener(DRAGEND, handleDragEnd); + dragCol.current = null; + dragColNode.current = null; + }, [columnsRef, apiRef, logger]); + + const handleDragStart = React.useCallback( + (col: ColDef, currentTarget: HTMLElement): void => { + logger.debug(`Start dragging col ${col.field}`); + apiRef.current.publishEvent(COL_REORDER_START); + + dragCol.current = col; + dragColNode.current = currentTarget; + dragColNode.current.addEventListener(DRAGEND, handleDragEnd, { once: true }); + dragColNode.current.classList.add(HEADER_CELL_DRAGGING_CSS_CLASS); + dragColNode.current.parentElement!.classList.add('MuiDataGrid-colCellMoving'); + removeDnDStylesTimeout.current = setTimeout(() => { + dragColNode.current!.classList.remove(HEADER_CELL_DRAGGING_CSS_CLASS); + }); + }, + [apiRef, handleDragEnd, logger], + ); + + React.useEffect(() => { + return () => { + clearTimeout(removeDnDStylesTimeout.current); + }; + }, []); + + const handleColumnHeaderDragOver = React.useCallback( + (event) => { + event.preventDefault(); + logger.debug(`Dragging over ${event.target}`); + apiRef.current.publishEvent(COL_REORDER_DRAG_OVER_HEADER); + + columnsRef.current!.classList.add(HEADER_CELL_DROP_ZONE_CSS_CLASS); + }, + [columnsRef, apiRef, logger], + ); + + const handleDragEnter = React.useCallback( + (event) => { + event.preventDefault(); + logger.debug(`Enter dragging col ${event.target}`); + apiRef.current.publishEvent(COL_REORDER_DRAG_ENTER); + }, + [apiRef, logger], + ); + + const handleDragOver = React.useCallback( + (col: ColDef, coordinates: CursorCoordinates): void => { + logger.debug(`Dragging over col ${col.field}`); + apiRef.current.publishEvent(COL_REORDER_DRAG_OVER); + + if ( + col.field !== dragCol.current!.field && + hasCursorPositionChanged(cursorPosition.current, coordinates) + ) { + const targetColIndex = apiRef.current.getColumnIndex(col.field, false); + const dragColIndex = apiRef.current.getColumnIndex(dragCol.current!.field, false); + const columnsSnapshot = apiRef.current.getAllColumns(); + + if ( + (getCursorMoveDirectionX(cursorPosition.current, coordinates) === + CURSOR_MOVE_DIRECTION_RIGHT && + dragColIndex < targetColIndex) || + (getCursorMoveDirectionX(cursorPosition.current, coordinates) === + CURSOR_MOVE_DIRECTION_LEFT && + targetColIndex < dragColIndex) + ) { + const columnsReordered = reorderColDefArray( + columnsSnapshot, + targetColIndex, + dragColIndex, + ); + apiRef.current.updateColumns(columnsReordered, true); + } + + cursorPosition.current = coordinates; + } + }, + [apiRef, logger], + ); + + return { + handleDragStart, + handleColumnHeaderDragOver, + handleDragOver, + handleDragEnter, + }; +}; diff --git a/packages/grid/_modules_/grid/hooks/root/useColumns.ts b/packages/grid/_modules_/grid/hooks/root/useColumns.ts index cd8e087f9f02c..4ee07d4c5e8e8 100644 --- a/packages/grid/_modules_/grid/hooks/root/useColumns.ts +++ b/packages/grid/_modules_/grid/hooks/root/useColumns.ts @@ -117,17 +117,23 @@ const getUpdatedColumnState = ( logger: Logger, state: InternalColumns, columnUpdates: ColDef[], + resetColumnState = false, ): InternalColumns => { const newState = { ...state }; - columnUpdates.forEach((newColumn) => { - const index = newState.all.findIndex((c) => c.field === newColumn.field); - const columnUpdated = { ...newState.all[index], ...newColumn }; - newState.all[index] = columnUpdated; - newState.all = [...newState.all]; - newState.lookup[newColumn.field] = columnUpdated; - newState.lookup = { ...newState.lookup }; - }); + if (resetColumnState) { + newState.all = columnUpdates; + } else { + columnUpdates.forEach((newColumn) => { + const index = newState.all.findIndex((col) => col.field === newColumn.field); + const columnUpdated = { ...newState.all[index], ...newColumn }; + newState.all[index] = columnUpdated; + newState.all = [...newState.all]; + + newState.lookup[newColumn.field] = columnUpdated; + newState.lookup = { ...newState.lookup }; + }); + } const visible = filterVisible(logger, newState.all); const meta = toMeta(logger, visible); @@ -183,8 +189,13 @@ export function useColumns( ); const getAllColumns: () => Columns = () => stateRef.current.all; const getColumnsMeta: () => ColumnsMeta = () => stateRef.current.meta; - const getColumnIndex: (field: string) => number = (field) => - stateRef.current.visible.findIndex((c) => c.field === field); + const getColumnIndex: (field: string, useVisibleColumns?: boolean) => number = ( + field, + useVisibleColumns = true, + ) => + useVisibleColumns + ? stateRef.current.visible.findIndex((col) => col.field === field) + : stateRef.current.all.findIndex((col) => col.field === field); const getColumnPosition: (field: string) => number = (field) => { const index = getColumnIndex(field); return stateRef.current.meta.positions[index]; @@ -193,8 +204,8 @@ export function useColumns( const getVisibleColumns: () => Columns = () => stateRef.current.visible; const updateColumns = React.useCallback( - (cols: ColDef[]) => { - const newState = getUpdatedColumnState(logger, stateRef.current, cols); + (cols: ColDef[], resetColumnState = false) => { + const newState = getUpdatedColumnState(logger, stateRef.current, cols, resetColumnState); updateState(newState, false); }, [updateState, logger, stateRef], diff --git a/packages/grid/_modules_/grid/hooks/utils/useOptionsProp.ts b/packages/grid/_modules_/grid/hooks/utils/useOptionsProp.ts index 6885e7a685d5c..1ef76ac32fc83 100644 --- a/packages/grid/_modules_/grid/hooks/utils/useOptionsProp.ts +++ b/packages/grid/_modules_/grid/hooks/utils/useOptionsProp.ts @@ -19,6 +19,7 @@ export function useOptionsProp(props: GridComponentProps): [GridOptions, Functio disableMultipleColumnsSorting: props.disableMultipleColumnsSorting, disableMultipleSelection: props.disableMultipleSelection, disableColumnResize: props.disableColumnResize, + disableColumnReorder: props.disableColumnReorder, disableExtendRowFullWidth: props.disableExtendRowFullWidth, headerHeight: props.headerHeight, hideFooter: props.hideFooter, @@ -63,6 +64,7 @@ export function useOptionsProp(props: GridComponentProps): [GridOptions, Functio props.disableMultipleColumnsSorting, props.disableMultipleSelection, props.disableColumnResize, + props.disableColumnReorder, props.disableExtendRowFullWidth, props.headerHeight, props.hideFooter, diff --git a/packages/grid/_modules_/grid/models/api/columnApi.ts b/packages/grid/_modules_/grid/models/api/columnApi.ts index 62a0fb4b0ff73..6d6bdaeb9d0ba 100644 --- a/packages/grid/_modules_/grid/models/api/columnApi.ts +++ b/packages/grid/_modules_/grid/models/api/columnApi.ts @@ -29,7 +29,7 @@ export interface ColumnApi { * Get the index position of the column in the array of [[ColDef]]. * @param field */ - getColumnIndex: (field: string) => number; + getColumnIndex: (field: string, useVisibleColumns?: boolean) => number; /** * Get the column left position in pixel relative to the left grid inner border. * @param field @@ -43,6 +43,7 @@ export interface ColumnApi { /** * Allows to batch update multiple columns at the same time. * @param cols [[ColDef[]]] + * @param resetState */ - updateColumns: (cols: ColDef[]) => void; + updateColumns: (cols: ColDef[], resetColumnState?: boolean) => void; } diff --git a/packages/grid/_modules_/grid/models/gridOptions.tsx b/packages/grid/_modules_/grid/models/gridOptions.tsx index c207ecf1b7d03..048be6380eea7 100644 --- a/packages/grid/_modules_/grid/models/gridOptions.tsx +++ b/packages/grid/_modules_/grid/models/gridOptions.tsx @@ -75,6 +75,11 @@ export interface GridOptions { * @default false */ disableColumnResize?: boolean; + /** + * If `true`, reordering columns is disabled. + * @default false + */ + disableColumnReorder?: boolean; /** * If `true`, the right border of the cells are displayed. * @default false diff --git a/packages/grid/data-grid/src/DataGrid.tsx b/packages/grid/data-grid/src/DataGrid.tsx index 2884104acb2c6..ed3c851ebca1e 100644 --- a/packages/grid/data-grid/src/DataGrid.tsx +++ b/packages/grid/data-grid/src/DataGrid.tsx @@ -7,6 +7,7 @@ const chainPropTypes = require('@material-ui/utils').chainPropTypes; const FORCED_PROPS: Partial = { disableColumnResize: true, + disableColumnReorder: true, disableMultipleColumnsSorting: true, disableMultipleSelection: true, pagination: true, @@ -16,6 +17,7 @@ const FORCED_PROPS: Partial = { export type DataGridProps = Omit< GridComponentProps, | 'disableColumnResize' + | 'disableColumnReorder' | 'disableMultipleColumnsSorting' | 'disableMultipleSelection' | 'licenseStatus' @@ -24,6 +26,7 @@ export type DataGridProps = Omit< | 'pagination' > & { disableColumnResize?: true; + disableColumnReorder?: true; disableMultipleColumnsSorting?: true; disableMultipleSelection?: true; pagination?: true; @@ -58,7 +61,7 @@ DataGrid2.propTypes = { throw new Error( [ `Material-UI: \`apiRef\` is not a valid prop.`, - 'ApiRef is not available in the MIT version', + 'ApiRef is not available in the MIT version.', '', 'You need to upgrade to the XGrid component to unlock this feature.', ].join('\n'), @@ -70,7 +73,19 @@ DataGrid2.propTypes = { throw new Error( [ `Material-UI: \`column.resizable = true\` is not a valid prop.`, - 'Column resizing is not available in the MIT version', + 'Column resizing is not available in the MIT version.', + '', + 'You need to upgrade to the XGrid component to unlock this feature.', + ].join('\n'), + ); + } + }), + disableColumnReorder: chainPropTypes(PropTypes.bool, (props) => { + if (props.disableColumnReorder === false) { + throw new Error( + [ + `Material-UI: \`\` is not a valid prop.`, + 'Column reordering is not available in the MIT version.', '', 'You need to upgrade to the XGrid component to unlock this feature.', ].join('\n'), @@ -82,7 +97,7 @@ DataGrid2.propTypes = { throw new Error( [ `Material-UI: \`\` is not a valid prop.`, - 'Column resizing is not available in the MIT version', + 'Column resizing is not available in the MIT version.', '', 'You need to upgrade to the XGrid component to unlock this feature.', ].join('\n'), @@ -94,7 +109,7 @@ DataGrid2.propTypes = { throw new Error( [ `Material-UI: \`\` is not a valid prop.`, - 'Only single column sorting is available in the MIT version', + 'Only single column sorting is available in the MIT version.', '', 'You need to upgrade to the XGrid component to unlock this feature.', ].join('\n'), diff --git a/packages/storybook/integration/helper-fn.ts b/packages/storybook/integration/helper-fn.ts index 2b120b258d1b0..5b57e4bdee00c 100644 --- a/packages/storybook/integration/helper-fn.ts +++ b/packages/storybook/integration/helper-fn.ts @@ -41,7 +41,7 @@ export async function getStoryPage( } const page = await browser.newPage(); await page.goto(url); - await page.waitForSelector('.grid-root'); + await page.waitForSelector('.MuiXGrid-root'); return page; } diff --git a/packages/storybook/integration/mouse.test.ts b/packages/storybook/integration/mouse.test.ts new file mode 100644 index 0000000000000..979e45f0d4232 --- /dev/null +++ b/packages/storybook/integration/mouse.test.ts @@ -0,0 +1,77 @@ +import { getStoryPage, startBrowser } from './helper-fn'; + +describe('Mouse Interactions', () => { + let page; + let browser; + + beforeAll(async (done) => { + browser = await startBrowser(); + done(); + }); + beforeEach(async (done) => { + page = await getStoryPage(browser, '/story/x-grid-tests-reorder--reorder-small-dataset', true); + done(); + }); + + afterEach(async (done) => { + await page.close(); + done(); + }); + + afterAll(async (done) => { + await browser.close(); + done(); + }); + + test('Column reorder by drag and drop', async (done) => { + async function dragAndDrop() { + await page.evaluate(() => { + const source = document.querySelectorAll('.MuiDataGrid-colCell-draggable')[0]; + const target = document.querySelectorAll('.MuiDataGrid-colCell-draggable')[1]; + + // Trigger 'dragstart' event on the source element + const dragstartEvent: any = document.createEvent('CustomEvent'); + dragstartEvent.initCustomEvent('dragstart', true, true, null); + dragstartEvent.clientX = source.getBoundingClientRect().top; + dragstartEvent.clientY = source.getBoundingClientRect().left; + source.dispatchEvent(dragstartEvent); + + // Trigger 'dragover' event on the target element + const dragenterEvent: any = document.createEvent('CustomEvent'); + dragenterEvent.initCustomEvent('dragenter', true, true, null); + dragenterEvent.clientX = target.getBoundingClientRect().top; + dragenterEvent.clientY = target.getBoundingClientRect().left; + target.dispatchEvent(dragenterEvent); + + // Trigger 'dragend' event on the target element + const dragendEvent: any = document.createEvent('CustomEvent'); + dragendEvent.initCustomEvent('dragend', true, true, null); + dragendEvent.clientX = target.getBoundingClientRect().top; + dragendEvent.clientY = target.getBoundingClientRect().left; + target.dispatchEvent(dragendEvent); + }); + } + + const firstTodo = await page.evaluate(() => + document.querySelectorAll('.MuiDataGrid-colCell')[0].getAttribute('data-field'), + ); + const secondTodo = await page.evaluate(() => + document.querySelectorAll('.MuiDataGrid-colCell')[1].getAttribute('data-field'), + ); + + dragAndDrop(); + await page.waitFor(100); + + const newFirstTodo = await page.evaluate(() => + document.querySelectorAll('.MuiDataGrid-colCell')[0].getAttribute('data-field'), + ); + const newSecondTodo = await page.evaluate(() => + document.querySelectorAll('.MuiDataGrid-colCell')[1].getAttribute('data-field'), + ); + + expect(newFirstTodo).toEqual(secondTodo); + expect(newSecondTodo).toEqual(firstTodo); + + done(); + }); +}); diff --git a/packages/storybook/integration/staticStories.test.ts b/packages/storybook/integration/staticStories.test.ts index a80bd1eafb518..2f116ed6912da 100644 --- a/packages/storybook/integration/staticStories.test.ts +++ b/packages/storybook/integration/staticStories.test.ts @@ -61,6 +61,7 @@ const stories = [ '/story/x-grid-tests-pagination--pagination-api-tests', // TODO click btns', '/story/x-grid-tests-pagination--auto-pagination', // TODO click btns', + '/story/x-grid-tests-reorder--reorder-small-dataset', '/story/x-grid-tests-resize--resize-small-dataset', '/story/x-grid-tests-resize--resize-large-dataset', diff --git a/packages/storybook/src/stories/grid-reorder.stories.tsx b/packages/storybook/src/stories/grid-reorder.stories.tsx new file mode 100644 index 0000000000000..257a62425a749 --- /dev/null +++ b/packages/storybook/src/stories/grid-reorder.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { ElementSize, XGrid } from '@material-ui/x-grid'; +import { withKnobs } from '@storybook/addon-knobs'; +import { withA11y } from '@storybook/addon-a11y'; +import '../style/grid-stories.css'; +import { useData } from '../hooks/useData'; + +export default { + title: 'X-Grid Tests/Reorder', + component: XGrid, + decorators: [withKnobs, withA11y], + parameters: { + options: { selectedPanel: 'storybook/storysource/panel' }, + docs: { + page: null, + }, + }, +}; +export const ReorderSmallDataset = () => { + const size: ElementSize = { width: 800, height: 600 }; + const data = useData(5, 4); + + return ( +
+ +
+ ); +};