diff --git a/CHANGELOG.md b/CHANGELOG.md index c27de43fbe..6db40a7788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,8 @@ - `column.minWidth` - `column.maxWidth` - `column.headerCellClass` - - `column.editor2` + - `column.editor` + - New API - `column.editorOptions` - More info in [#2102](https://github.com/adazzle/react-data-grid/pull/2102) - `column.groupFormatter` diff --git a/src/Cell.test.tsx b/src/Cell.test.tsx index eaa49238d2..b2a34102b4 100644 --- a/src/Cell.test.tsx +++ b/src/Cell.test.tsx @@ -3,7 +3,7 @@ import { mount } from 'enzyme'; import Cell from './Cell'; import helpers, { Row } from './test/GridPropHelpers'; -import { SimpleCellFormatter } from './formatters'; +import { ValueFormatter } from './formatters'; import { CalculatedColumn, CellRendererProps, FormatterProps } from './types'; import EventBus from './EventBus'; @@ -15,7 +15,7 @@ const defaultColumn: CalculatedColumn = { left: 0, resizable: false, sortable: false, - formatter: SimpleCellFormatter + formatter: ValueFormatter }; const testProps: CellRendererProps = { @@ -34,12 +34,6 @@ const renderComponent = (extraProps?: PropsWithChildren { - it('should render a SimpleCellFormatter with value', () => { - const wrapper = renderComponent(); - const formatter = wrapper.find(SimpleCellFormatter); - expect(formatter.props().row[defaultColumn.key]).toStrictEqual('Wicklow'); - }); - it('should render a custom formatter when specified on column', () => { const CustomFormatter = (props: FormatterProps) =>
{props.row[props.column.key]}
; diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index ff823d46ef..de74698f67 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -93,10 +93,9 @@ export interface DataGridProps extends SharedDivProps { /** * Callback called whenever row data is updated * When editing is enabled, this callback will be called for the following scenarios - * 1. Using the supplied editor of the column. The default editor is the SimpleTextEditor. - * 2. Copy/pasting the value from one cell to another CTRL+C, CTRL+V - * 3. Update multiple cells by dragging the fill handle of a cell up or down to a destination cell. - * 4. Update all cells under a given cell by double clicking the cell's fill handle. + * 1. Copy/pasting the value from one cell to another CTRL+C, CTRL+V + * 2. Update multiple cells by dragging the fill handle of a cell up or down to a destination cell. + * 3. Update all cells under a given cell by double clicking the cell's fill handle. */ onRowsUpdate?: (event: E) => void; onRowsChange?: (rows: R[]) => void; @@ -481,9 +480,9 @@ function DataGrid({ closeEditor(); } - function commitEditor2Changes() { + function commitEditorChanges() { if ( - columns[selectedPosition.idx]?.editor2 === undefined + columns[selectedPosition.idx]?.editor === undefined || selectedPosition.mode === 'SELECT' || selectedPosition.row === selectedPosition.originalRow) { return; @@ -535,7 +534,7 @@ function DataGrid({ if (selectedPosition.mode === 'EDIT') { if (key === 'Enter') { // Custom editors can listen for the event and stop propagation to prevent commit - commitEditor2Changes(); + commitEditorChanges(); closeEditor(); } return; @@ -626,7 +625,7 @@ function DataGrid({ function handleOnClose(commitChanges?: boolean) { if (commitChanges) { - commitEditor2Changes(); + commitEditorChanges(); } closeEditor(); } @@ -645,7 +644,7 @@ function DataGrid({ function selectCell(position: Position, enableEditor = false): void { if (!isCellWithinBounds(position)) return; - commitEditor2Changes(); + commitEditorChanges(); if (enableEditor && isCellEditable(position)) { const row = rows[position.rowIdx] as R; @@ -802,7 +801,7 @@ function DataGrid({ onCommit: handleCommit, onCommitCancel: closeEditor }, - editor2Props: { + editorProps: { rowHeight, row: selectedPosition.row, onRowChange: handleRowChange, @@ -963,6 +962,4 @@ function DataGrid({ ); } -export default forwardRef( - DataGrid as React.ForwardRefRenderFunction -) as (props: DataGridProps & React.RefAttributes) => JSX.Element; +export default forwardRef(DataGrid) as (props: DataGridProps & React.RefAttributes) => JSX.Element; diff --git a/src/EditCell.tsx b/src/EditCell.tsx index b6455daf20..8597419657 100644 --- a/src/EditCell.tsx +++ b/src/EditCell.tsx @@ -1,33 +1,32 @@ -import React, { forwardRef, useState, useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; import clsx from 'clsx'; -import { EditorContainer, EditorContainer2, EditorPortal } from './editors'; -import { CellRendererProps, SharedEditorContainerProps, SharedEditor2Props } from './types'; -import { useCombinedRefs } from './hooks'; +import EditorContainer from './editors/EditorContainer'; +import { CellRendererProps, SharedEditorContainerProps, SharedEditorProps } from './types'; type SharedCellRendererProps = Pick, -| 'rowIdx' -| 'row' -| 'column' + | 'rowIdx' + | 'row' + | 'column' >; interface EditCellRendererProps extends SharedCellRendererProps, Omit, 'style' | 'children'> { editorPortalTarget: Element; editorContainerProps: SharedEditorContainerProps; - editor2Props: SharedEditor2Props; + editorProps: SharedEditorProps; } -function EditCell({ +export default function EditCell({ className, column, row, rowIdx, editorPortalTarget, editorContainerProps, - editor2Props, + editorProps, onKeyDown, ...props -}: EditCellRendererProps, ref: React.Ref) { +}: EditCellRendererProps) { const [dimensions, setDimensions] = useState<{ left: number; top: number } | null>(null); const cellRef = useCallback(node => { @@ -57,39 +56,16 @@ function EditCell({ const gridLeft = left + docLeft; const gridTop = top + docTop; - if (column.editor2 !== undefined) { - return ( - - ); - } - - const editor = ( - - {...editorContainerProps} + return ( + ); - - if (column.editorOptions?.createPortal !== false) { - return ( - - {editor} - - ); - } - - return editor; } return ( @@ -97,7 +73,7 @@ function EditCell({ role="gridcell" aria-colindex={column.idx + 1} // aria-colindex is 1-based aria-selected - ref={useCombinedRefs(cellRef, ref)} + ref={cellRef} className={className} style={{ width: column.width, @@ -110,5 +86,3 @@ function EditCell({ ); } - -export default forwardRef(EditCell) as (props: EditCellRendererProps & React.RefAttributes) => JSX.Element; diff --git a/src/Row.tsx b/src/Row.tsx index b301055ba0..169c9b2d6d 100644 --- a/src/Row.tsx +++ b/src/Row.tsx @@ -63,7 +63,7 @@ function Row({ onKeyDown={selectedCellProps.onKeyDown} editorPortalTarget={selectedCellProps.editorPortalTarget} editorContainerProps={selectedCellProps.editorContainerProps} - editor2Props={selectedCellProps.editor2Props} + editorProps={selectedCellProps.editorProps} /> ); } diff --git a/src/editors/Editor2Container.tsx b/src/editors/Editor2Container.tsx deleted file mode 100644 index 62a524ed59..0000000000 --- a/src/editors/Editor2Container.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { createPortal } from 'react-dom'; - -import { Editor2Props } from '../types'; -import { useClickOutside } from '../hooks'; - -export default function Editor2Container({ - row, - column, - onRowChange, - editorPortalTarget, - ...props -}: Editor2Props) { - const onClickCapture = useClickOutside(() => onRowChange(row, true)); - if (column.editor2 === undefined) return null; - - const editor = ( -
- -
- ); - - if (column.editorOptions?.createPortal) { - return createPortal(editor, editorPortalTarget); - } - - return editor; -} diff --git a/src/editors/EditorContainer.test.tsx b/src/editors/EditorContainer.test.tsx deleted file mode 100644 index 295efd39af..0000000000 --- a/src/editors/EditorContainer.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -/* eslint-disable sonarjs/no-identical-functions */ -import React from 'react'; -import ReactDOM from 'react-dom'; -import { mount, MountRendererProps } from 'enzyme'; -import { waitFor } from '@testing-library/react'; - -import EditorContainer, { EditorContainerProps } from './EditorContainer'; -import SimpleTextEditor from './SimpleTextEditor'; -import { ValueFormatter } from '../formatters'; -import { CalculatedColumn, EditorProps } from '../types'; - -interface Row { - id: string; - col1: string; - col2: string; - col3: string; -} - -function DefaultEditor() { - return ( -
- -
- -
-
- ); -} - -const fakeColumn: CalculatedColumn = { - idx: 0, - name: 'col1', - key: 'col1', - width: 100, - left: 0, - resizable: false, - sortable: false, - formatter: ValueFormatter -}; - -const setup = (extraProps?: Partial>, opts?: MountRendererProps) => { - const props: EditorContainerProps = { - rowIdx: 0, - row: { - id: '1', - col1: 'Adwolf', - col2: 'SupernaviX', - col3: 'Testing' - }, - column: fakeColumn, - rowHeight: 50, - left: 0, - top: 0, - onCommit: jest.fn(), - onCommitCancel: jest.fn(), - firstEditorKeyPress: null, - scrollLeft: 0, - scrollTop: 0, - ...extraProps - }; - const wrapper = mount(, opts); - - return { wrapper, props }; -}; - -describe('EditorContainer', () => { - describe('Basic render tests', () => { - it('should select the text of the default input when the editor is rendered', () => { - const { wrapper } = setup(); - const input = wrapper.find('input').getDOMNode(); - expect(input.selectionStart === 0 && input.selectionEnd === input.value.length).toBe(true); - }); - - it('should render the editor with the correct properties', () => { - const { wrapper } = setup(); - const editor = wrapper.find(SimpleTextEditor); - - expect(editor).toHaveLength(1); - expect(editor.props().value).toStrictEqual('Adwolf'); - expect(editor.props().column).toStrictEqual(fakeColumn); - }); - - it('should render the editor container div with correct properties', () => { - const { wrapper } = setup(); - const editorDiv = wrapper.find('div').at(0); - - expect(editorDiv.props().className).toBeDefined(); - expect(editorDiv.props().onKeyDown).toBeDefined(); - expect(editorDiv.props().children).toBeDefined(); - }); - }); - - describe('Custom Editors', () => { - class TestEditor extends React.Component> { - getValue() { - return undefined; - } - - getInputNode() { - return undefined; - } - - render() { - return ; - } - } - - function innerSetup() { - return setup({ - column: { ...fakeColumn, key: 'col2', editor: TestEditor } - }); - } - - it('should render element custom editors', () => { - const { wrapper } = innerSetup(); - const editor = wrapper.find(TestEditor); - - expect(editor).toHaveLength(1); - expect(editor.prop('value')).toBe('SupernaviX'); - expect(editor.prop('onCommit')).toBeDefined(); - expect(editor.prop('onCommitCancel')).toBeDefined(); - }); - - it('should not commit if any element inside the editor is clicked', () => { - const { wrapper, props } = innerSetup(); - wrapper.find('#input1').simulate('click'); - wrapper.find('#input2').simulate('click'); - - expect(props.onCommit).not.toHaveBeenCalled(); - }); - - it('should not commit if any element inside the editor is clicked that stops the event propagation', () => { - const { wrapper, props } = innerSetup(); - wrapper.find('#button1').simulate('click'); - wrapper.find('#button2').simulate('click'); - - expect(props.onCommit).not.toHaveBeenCalled(); - }); - - it('should call onCommitCancel when editor cancels editing', () => { - const { wrapper, props } = innerSetup(); - const editor = wrapper.find(TestEditor); - - editor.props().onCommitCancel(); - - expect(props.onCommitCancel).toHaveBeenCalledTimes(1); - expect(props.onCommit).not.toHaveBeenCalled(); - }); - - it('should not commit changes on componentWillUnmount if editor cancels editing', () => { - const { wrapper, props } = innerSetup(); - const editor = wrapper.find(TestEditor); - - editor.props().onCommitCancel(); - wrapper.unmount(); - - expect(props.onCommit).not.toHaveBeenCalled(); - }); - }); - - describe('Custom Portal editors', () => { - class PortalTestEditor extends React.Component> { - getValue() { - return undefined; - } - - getInputNode() { - return undefined; - } - - render() { - return ReactDOM.createPortal(, document.body); - } - } - - function innerSetup() { - const container = document.createElement('div'); - document.body.appendChild(container); - const setupResult = setup({ column: { ...fakeColumn, editor: PortalTestEditor } }, { attachTo: container }); - return { container, ...setupResult }; - } - - it('should not commit if any element inside the editor is clicked', () => { - const { wrapper, props } = innerSetup(); - const editor = wrapper.find(PortalTestEditor); - editor.find('#input1').simulate('click'); - editor.find('#input2').simulate('click'); - - expect(props.onCommit).not.toHaveBeenCalled(); - }); - - it('should not commit if any element inside the editor is clicked that stops the event propagation', () => { - const { wrapper, props } = innerSetup(); - wrapper.find('#button1').simulate('click'); - wrapper.find('#button2').simulate('click'); - - expect(props.onCommit).not.toHaveBeenCalled(); - }); - - it('should commit if any element outside the editor is clicked', async () => { - const { props } = innerSetup(); - document.body.click(); - await waitFor(() => expect(props.onCommit).toHaveBeenCalled()); - }); - }); - - describe('Events', () => { - it('hitting enter should call commit only once', () => { - const { wrapper, props } = setup(); - const editor = wrapper.find(SimpleTextEditor); - editor.simulate('keydown', { key: 'Enter' }); - - expect(props.onCommit).toHaveBeenCalledTimes(1); - }); - - it('hitting tab should call commit only once', () => { - const { wrapper, props } = setup(); - const editor = wrapper.find(SimpleTextEditor); - editor.simulate('keydown', { key: 'Tab' }); - - expect(props.onCommit).toHaveBeenCalledTimes(1); - }); - - it('hitting escape should call commitCancel only once', () => { - const { wrapper, props } = setup(); - const editor = wrapper.find(SimpleTextEditor); - editor.simulate('keydown', { key: 'Escape' }); - - expect(props.onCommitCancel).toHaveBeenCalledTimes(1); - }); - - it('hitting escape should not call commit changes on componentWillUnmount', () => { - const { wrapper, props } = setup(); - const editor = wrapper.find(SimpleTextEditor); - editor.simulate('keydown', { key: 'Escape' }); - wrapper.unmount(); - - expect(props.onCommit).not.toHaveBeenCalled(); - }); - - it('should commit if any element outside the editor is clicked', async () => { - const { props } = setup(); - document.body.click(); - await waitFor(() => expect(props.onCommit).toHaveBeenCalled()); - }); - }); -}); diff --git a/src/editors/EditorContainer.tsx b/src/editors/EditorContainer.tsx index 70b2a9ec16..84fa9cd342 100644 --- a/src/editors/EditorContainer.tsx +++ b/src/editors/EditorContainer.tsx @@ -1,185 +1,32 @@ -import React, { KeyboardEvent, useRef, useState, useLayoutEffect, useCallback, useEffect } from 'react'; -import clsx from 'clsx'; +import React from 'react'; +import { createPortal } from 'react-dom'; -import { CalculatedColumn, Editor, SharedEditorContainerProps } from '../types'; +import { EditorProps } from '../types'; import { useClickOutside } from '../hooks'; -import SimpleTextEditor from './SimpleTextEditor'; -import { preventDefault } from '../utils'; - -export interface EditorContainerProps extends SharedEditorContainerProps { - rowIdx: number; - row: R; - column: CalculatedColumn; - top: number; - left: number; -} export default function EditorContainer({ - rowIdx, - column, row, - rowHeight, - left, - top, - onCommit, - onCommitCancel, - scrollLeft, - scrollTop, - firstEditorKeyPress: key -}: EditorContainerProps) { - const editorRef = useRef(null); - const changeCommitted = useRef(false); - const changeCanceled = useRef(false); - const [isValid, setValid] = useState(true); - const prevScrollLeft = useRef(scrollLeft); - const prevScrollTop = useRef(scrollTop); - const isUnmounting = useRef(false); - const onClickCapture = useClickOutside(commit); - - const getInputNode = useCallback(() => editorRef.current?.getInputNode(), []); - - const commitCancel = useCallback(() => { - changeCanceled.current = true; - onCommitCancel(); - }, [onCommitCancel]); - - useLayoutEffect(() => { - const inputNode = getInputNode(); - - if (inputNode instanceof HTMLElement) { - inputNode.focus(); - } - if (inputNode instanceof HTMLInputElement) { - inputNode.select(); - } - }, [getInputNode]); - - // close editor when scrolling - useEffect(() => { - if (scrollLeft !== prevScrollLeft.current || scrollTop !== prevScrollTop.current) { - commitCancel(); - } - }, [commitCancel, scrollLeft, scrollTop]); - - useEffect(() => () => { - isUnmounting.current = true; - }, []); - - // commit changes when editor is closed - useEffect(() => () => { - if (isUnmounting.current && !changeCommitted.current && !changeCanceled.current) { - commit(); - } - }); - - function getInitialValue() { - const value = row[column.key as keyof R]; - if (key === 'Delete' || key === 'Backspace') { - return ''; - } - if (key === 'Enter' || key === 'F2') { - return value; - } - - return key ?? value; - } - - function isCaretAtBeginningOfInput(): boolean { - const inputNode = getInputNode(); - return inputNode instanceof HTMLInputElement - && inputNode.selectionEnd === 0; - } - - function isCaretAtEndOfInput(): boolean { - const inputNode = getInputNode(); - return inputNode instanceof HTMLInputElement - && inputNode.selectionStart === inputNode.value.length; - } - - function editorHasResults(): boolean { - return editorRef.current?.hasResults?.() ?? false; - } - - function editorIsSelectOpen(): boolean { - return editorRef.current?.isSelectOpen?.() ?? false; - } - - function isNewValueValid(value: unknown): boolean { - const isValid = editorRef.current?.validate?.(value); - if (typeof isValid === 'boolean') { - setValid(isValid); - return isValid; - } - return true; - } - - function preventDefaultNavigation(key: string): boolean { - return (key === 'ArrowLeft' && !isCaretAtBeginningOfInput()) - || (key === 'ArrowRight' && !isCaretAtEndOfInput()) - || (key === 'Escape' && editorIsSelectOpen()) - || (['ArrowUp', 'ArrowDown'].includes(key) && editorHasResults()); - } - - function commit(): void { - if (!editorRef.current) return; - const updated = editorRef.current.getValue(); - if (isNewValueValid(updated)) { - changeCommitted.current = true; - const cellKey = column.key; - onCommit({ cellKey, rowIdx, updated }); - } - } - - function onKeyDown(e: KeyboardEvent) { - if (preventDefaultNavigation(e.key)) { - e.stopPropagation(); - } else if (['Enter', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { - commit(); - } else if (e.key === 'Escape') { - commitCancel(); - } - } - - function createEditor() { - // return custom column editor or SimpleEditor if none specified - if (column.editor) { - return ( - - ); - } - - return ( - } + column, + onRowChange, + ...props +}: EditorProps) { + const onClickCapture = useClickOutside(() => onRowChange(row, true)); + if (column.editor === undefined) return null; + + const editor = ( +
+ - ); - } - - const className = clsx('rdg-editor-container', { - 'rdg-editor-invalid': !isValid - }); - - return ( -
- {createEditor()}
); + + if (column.editorOptions?.createPortal) { + return createPortal(editor, props.editorPortalTarget); + } + + return editor; } diff --git a/src/editors/EditorPortal.tsx b/src/editors/EditorPortal.tsx deleted file mode 100644 index 93b2d875d5..0000000000 --- a/src/editors/EditorPortal.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React, { useState, useLayoutEffect } from 'react'; -import ReactDOM from 'react-dom'; - -interface Props { - children: React.ReactNode; - target: Element; -} - -export default function EditorPortal({ target, children }: Props) { - // Keep track of when the modal element is added to the DOM - const [isMounted, setIsMounted] = useState(false); - - useLayoutEffect(() => { - setIsMounted(true); - }, []); - - // Don't render the portal until the component has mounted, - // So the portal can safely access the DOM. - if (!isMounted) { - return null; - } - - return ReactDOM.createPortal(children, target); -} diff --git a/src/editors/SimpleTextEditor.test.tsx b/src/editors/SimpleTextEditor.test.tsx deleted file mode 100644 index d8546a2528..0000000000 --- a/src/editors/SimpleTextEditor.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import SimpleTextEditor from './SimpleTextEditor'; -import { ValueFormatter } from '../formatters'; -import { CalculatedColumn } from '../types'; - -interface Row { text: string } - -describe('SimpleTextEditor', () => { - describe('Basic tests', () => { - const fakeColumn: CalculatedColumn = { - idx: 0, - key: 'text', - name: 'name', - width: 0, - left: 0, - resizable: false, - sortable: false, - formatter: ValueFormatter - }; - const fakeBlurCb = jest.fn(); - - function setup() { - return mount( - - ); - } - - it('should pass the onBlur fuction down to the input as a prop', () => { - setup().find('input').simulate('blur'); - expect(fakeBlurCb).toHaveBeenCalled(); - }); - - it('should return the value when getValue is called', () => { - expect(setup().instance().getValue().text).toBe('This is a test'); - }); - }); -}); diff --git a/src/editors/SimpleTextEditor.tsx b/src/editors/SimpleTextEditor.tsx deleted file mode 100644 index f8d4e18fe2..0000000000 --- a/src/editors/SimpleTextEditor.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import { Editor, EditorProps } from '../types'; - -type Props = Pick, 'value' | 'column' | 'onCommit'>; - -export default class SimpleTextEditor extends React.Component implements Editor<{ [key: string]: string }> { - private readonly input = React.createRef(); - - getInputNode() { - return this.input.current; - } - - getValue() { - return { - [this.props.column.key]: this.input.current!.value - }; - } - - render() { - return ( - - ); - } -} diff --git a/src/editors/TextEditor.tsx b/src/editors/TextEditor.tsx new file mode 100644 index 0000000000..e9130f76cd --- /dev/null +++ b/src/editors/TextEditor.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { EditorProps } from '../types'; + +function autoFocusAndSelect(input: HTMLInputElement | null) { + input?.focus(); + input?.select(); +} + +export default function TextEditor({ + row, + column, + onRowChange, + onClose +}: EditorProps) { + return ( + onRowChange({ ...row, [column.key]: event.target.value })} + onBlur={() => onClose(true)} + onKeyDown={event => { + if (/^(Arrow(Left|Right)|Home|End)$/.test(event.key)) { + event.stopPropagation(); + } + }} + /> + ); +} diff --git a/src/editors/index.ts b/src/editors/index.ts deleted file mode 100644 index 80adf056b9..0000000000 --- a/src/editors/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { default as SimpleTextEditor } from './SimpleTextEditor'; -export { default as EditorPortal } from './EditorPortal'; -export { default as EditorContainer } from './EditorContainer'; -export { default as EditorContainer2 } from './Editor2Container'; diff --git a/src/formatters/SelectCellFormatter.tsx b/src/formatters/SelectCellFormatter.tsx index 9231676924..23c5808523 100644 --- a/src/formatters/SelectCellFormatter.tsx +++ b/src/formatters/SelectCellFormatter.tsx @@ -11,7 +11,7 @@ type SharedInputProps = Pick, | 'aria-labelledby' >; -export interface SelectCellFormatterProps extends SharedInputProps { +interface SelectCellFormatterProps extends SharedInputProps { isCellSelected?: boolean; value: boolean; onChange: (value: boolean, isShiftClick: boolean) => void; diff --git a/src/formatters/SimpleCellFormatter.tsx b/src/formatters/SimpleCellFormatter.tsx deleted file mode 100644 index d41da5d77c..0000000000 --- a/src/formatters/SimpleCellFormatter.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; -import { FormatterProps } from '../types'; - -export function SimpleCellFormatter({ row, column }: FormatterProps) { - const value = row[column.key]; - return {value}; -} diff --git a/src/formatters/index.ts b/src/formatters/index.ts index e4af276e17..5c0eea864e 100644 --- a/src/formatters/index.ts +++ b/src/formatters/index.ts @@ -1,4 +1,3 @@ export * from './SelectCellFormatter'; -export * from './SimpleCellFormatter'; export * from './ValueFormatter'; export * from './ToggleGroupFormatter'; diff --git a/src/headerCells/ResizableHeaderCell.tsx b/src/headerCells/ResizableHeaderCell.tsx index b65f63b858..11a9da7d49 100644 --- a/src/headerCells/ResizableHeaderCell.tsx +++ b/src/headerCells/ResizableHeaderCell.tsx @@ -1,7 +1,7 @@ import React, { cloneElement } from 'react'; import { CalculatedColumn } from '../types'; -export interface ResizableHeaderCellProps { +interface ResizableHeaderCellProps { children: React.ReactElement>; column: CalculatedColumn; onResize: (column: CalculatedColumn, width: number) => void; diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts index f8a6c0c5fb..64d2a3a414 100644 --- a/src/hooks/useClickOutside.ts +++ b/src/hooks/useClickOutside.ts @@ -50,7 +50,15 @@ import { useRef, useEffect } from 'react'; */ export function useClickOutside(onClick: () => void) { - const clickedInsideRef = useRef(false); + const frameRequestRef = useRef(); + + function cancelAnimationFrameRequest() { + if (typeof frameRequestRef.current === 'number') { + cancelAnimationFrame(frameRequestRef.current); + frameRequestRef.current = undefined; + } + } + // We need to prevent the `useEffect` from cleaning up between re-renders, // as `handleDocumentClick` might otherwise miss valid click events. // To that end we instead access the latest `onClick` prop via a ref. @@ -63,40 +71,23 @@ export function useClickOutside(onClick: () => void) { }); useEffect(() => { - let animationFrameRequest: number | undefined; - - function cancelAnimationFrameRequest() { - if (typeof animationFrameRequest === 'number') { - cancelAnimationFrame(animationFrameRequest); - animationFrameRequest = undefined; - } - } - - function checkOutsideClick() { - animationFrameRequest = undefined; - - if (clickedInsideRef.current) { - clickedInsideRef.current = false; - } else { - onClickRef.current(); - } + function onOutsideClick() { + frameRequestRef.current = undefined; + onClickRef.current(); } - function handleWindowCaptureClick() { + function onWindowCaptureClick() { cancelAnimationFrameRequest(); - clickedInsideRef.current = false; - animationFrameRequest = requestAnimationFrame(checkOutsideClick); + frameRequestRef.current = requestAnimationFrame(onOutsideClick); } - window.addEventListener('click', handleWindowCaptureClick, { capture: true }); + window.addEventListener('click', onWindowCaptureClick, { capture: true }); return () => { - window.removeEventListener('click', handleWindowCaptureClick, { capture: true }); + window.removeEventListener('click', onWindowCaptureClick, { capture: true }); cancelAnimationFrameRequest(); }; }, []); - return function onClickCapture() { - clickedInsideRef.current = true; - }; + return cancelAnimationFrameRequest; } diff --git a/src/hooks/useCombinedRefs.ts b/src/hooks/useCombinedRefs.ts index d9b985cfcf..344fb608e5 100644 --- a/src/hooks/useCombinedRefs.ts +++ b/src/hooks/useCombinedRefs.ts @@ -1,9 +1,17 @@ -import { useMemo } from 'react'; -import { wrapRefs } from '../utils'; +import { useCallback } from 'react'; export function useCombinedRefs(...refs: readonly React.Ref[]) { - return useMemo( - () => wrapRefs(...refs), + return useCallback( + (handle: T | null) => { + for (const ref of refs) { + if (typeof ref === 'function') { + ref(handle); + } else if (ref !== null) { + // @ts-expect-error: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31065 + ref.current = handle; + } + } + }, // eslint-disable-next-line react-hooks/exhaustive-deps refs ); diff --git a/src/hooks/useFocusRef.tsx b/src/hooks/useFocusRef.ts similarity index 100% rename from src/hooks/useFocusRef.tsx rename to src/hooks/useFocusRef.ts diff --git a/src/index.ts b/src/index.ts index 00f08edf9c..32a145674d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,6 @@ export { default as Cell } from './Cell'; export { default as Row } from './Row'; export * from './Columns'; export * from './formatters'; -export { SimpleTextEditor } from './editors'; +export { default as TextEditor } from './editors/TextEditor'; export * from './enums'; export * from './types'; diff --git a/src/test/GridPropHelpers.ts b/src/test/GridPropHelpers.ts index 447829bd07..c9d1e88d46 100644 --- a/src/test/GridPropHelpers.ts +++ b/src/test/GridPropHelpers.ts @@ -1,4 +1,4 @@ -import { ValueFormatter, SimpleCellFormatter } from '../formatters'; +import { ValueFormatter } from '../formatters'; import { CalculatedColumn } from '../types'; export interface Row { @@ -16,7 +16,7 @@ const columns: CalculatedColumn[] = [{ left: 0, resizable: false, sortable: false, - formatter: SimpleCellFormatter + formatter: ValueFormatter }, { idx: 1, key: 'title', diff --git a/src/types.ts b/src/types.ts index 48aedb90bd..5432882255 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { KeyboardEvent } from 'react'; import { UpdateActions } from './enums'; import EventBus from './EventBus'; @@ -36,18 +35,17 @@ export interface Column { /** Sets the column sort order to be descending instead of ascending the first time the column is sorted */ sortDescendingFirst?: boolean; /** Editor to be rendered when cell of column is being edited. If set, then the column is automatically set to be editable */ - editor?: React.ComponentType>; - editor2?: React.ComponentType>; + editor?: React.ComponentType>; editorOptions?: { - /** Default: true for editor1 and false for editor2 */ + /** @default false */ createPortal?: boolean; - /** Default: false */ + /** @default false */ editOnClick?: boolean; /** Prevent default to cancel editing */ onCellKeyDown?: (event: React.KeyboardEvent) => void; // TODO: Do we need these options // editOnDoubleClick?: boolean; - /** Default: true for editor1 and false for editor2 */ + /** @default false */ // commitOnScroll?: boolean; }; /** Header renderer for each header cell */ @@ -72,15 +70,6 @@ export interface Position { rowIdx: number; } -export interface Editor { - getInputNode: () => Element | Text | undefined | null; - getValue: () => TValue; - hasResults?: () => boolean; - isSelectOpen?: () => boolean; - validate?: (value: unknown) => boolean; - readonly disableContainerStyles?: boolean; -} - export interface FormatterProps { rowIdx: number; column: CalculatedColumn; @@ -106,25 +95,14 @@ export interface GroupFormatterProps { toggleGroup: () => void; } -export interface EditorProps { - ref: React.Ref>; - column: CalculatedColumn; - value: TValue; - row: TRow; - height: number; - onCommit: () => void; - onCommitCancel: () => void; - onOverrideKeyDown: (e: KeyboardEvent) => void; -} - -export interface SharedEditor2Props { +export interface SharedEditorProps { row: Readonly; rowHeight: number; onRowChange: (row: Readonly, commitChanges?: boolean) => void; onClose: (commitChanges?: boolean) => void; } -export interface Editor2Props extends SharedEditor2Props { +export interface EditorProps extends SharedEditorProps { rowIdx: number; column: Readonly>; top: number; @@ -156,7 +134,7 @@ export interface EditCellProps extends SelectedCellPropsBase { mode: 'EDIT'; editorPortalTarget: Element; editorContainerProps: SharedEditorContainerProps; - editor2Props: SharedEditor2Props; + editorProps: SharedEditorProps; } export interface SelectedCellProps extends SelectedCellPropsBase { diff --git a/src/utils/columnUtils.test.ts b/src/utils/columnUtils.test.ts index a285755bd0..fd459232de 100644 --- a/src/utils/columnUtils.test.ts +++ b/src/utils/columnUtils.test.ts @@ -146,6 +146,7 @@ describe('canEdit', () => { it('should return the result of editable(row)', () => { const fnColumn = { ...column, + editor: () => null, editable(row: Row) { return row.id === 1; } }; expect(canEdit(fnColumn, { id: 1 })).toBe(true); @@ -158,9 +159,9 @@ describe('canEdit', () => { expect(canEdit(column, row)).toBe(false); expect(canEdit({ ...column, editable: false }, row)).toBe(false); - expect(canEdit({ ...column, editable: true }, row)).toBe(true); + expect(canEdit({ ...column, editable: true }, row)).toBe(false); expect(canEdit({ ...column, editor }, row)).toBe(true); - expect(canEdit({ ...column, editor, editable: false }, row)).toBe(true); + expect(canEdit({ ...column, editor, editable: false }, row)).toBe(false); expect(canEdit({ ...column, editor, editable: true }, row)).toBe(true); }); }); diff --git a/src/utils/columnUtils.ts b/src/utils/columnUtils.ts index a1abf4d411..0e090ed559 100644 --- a/src/utils/columnUtils.ts +++ b/src/utils/columnUtils.ts @@ -161,10 +161,8 @@ function clampColumnWidth( // Logic extented to allow for functions to be passed down in column.editable // this allows us to decide whether we can be editing from a cell level export function canEdit(column: Column, row: R): boolean { - if (typeof column.editable === 'function') { - return column.editable(row); - } - return Boolean(column.editor ?? column.editor2 ?? column.editable); + const isEditable = typeof column.editable === 'function' ? column.editable(row) : column.editable; + return column.editor != null && isEditable !== false; } export function getColumnScrollPosition(columns: readonly CalculatedColumn[], idx: number, currentScrollLeft: number, currentClientWidth: number): number { diff --git a/src/utils/index.ts b/src/utils/index.ts index dc768cd188..a14eee0f68 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -8,17 +8,3 @@ export function assertIsValidKeyGetter(keyGetter: unknown): asserts keyGetter throw new Error('Please specify the rowKeyGetter prop to use selection'); } } - -export function wrapRefs(...refs: readonly React.Ref[]) { - return (handle: T | null) => { - for (const ref of refs) { - if (typeof ref === 'function') { - ref(handle); - } else if (ref !== null) { - // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31065 - // @ts-expect-error - ref.current = handle; - } - } - }; -} diff --git a/stories/demos/AllFeatures.tsx b/stories/demos/AllFeatures.tsx index f1818451ac..b4059be834 100644 --- a/stories/demos/AllFeatures.tsx +++ b/stories/demos/AllFeatures.tsx @@ -1,13 +1,13 @@ import faker from 'faker'; -import React, { useState, useMemo, useCallback, useRef } from 'react'; -import DataGrid, { Column, SelectColumn, DataGridHandle, RowsUpdateEvent, CalculatedColumn } from '../../src'; +import React, { useState, useCallback, useRef } from 'react'; +import DataGrid, { Column, SelectColumn, DataGridHandle, RowsUpdateEvent, TextEditor } from '../../src'; import DropDownEditor from './components/Editors/DropDownEditor'; import { ImageFormatter } from './components/Formatters'; import Toolbar from './components/Toolbar/Toolbar'; import './AllFeatures.less'; -interface Row { +export interface Row { id: string; avatar: string; email: string; @@ -30,7 +30,109 @@ function rowKeyGetter(row: Row) { faker.locale = 'en_GB'; -const titles = ['Dr.', 'Mr.', 'Mrs.', 'Miss', 'Ms.']; +const columns: readonly Column[] = [ + SelectColumn, + { + key: 'id', + name: 'ID', + width: 80, + resizable: true, + frozen: true + }, + { + key: 'avatar', + name: 'Avatar', + width: 40, + resizable: true, + headerRenderer: () => , + formatter: ({ row }) => + }, + { + key: 'title', + name: 'Title', + width: 200, + resizable: true, + formatter(props) { + return <>{props.row.title}; + }, + editor: DropDownEditor, + editorOptions: { + editOnClick: true + } + }, + { + key: 'firstName', + name: 'First Name', + width: 200, + resizable: true, + frozen: true, + editor: TextEditor + }, + { + key: 'lastName', + name: 'Last Name', + width: 200, + resizable: true, + frozen: true, + editor: TextEditor + }, + { + key: 'email', + name: 'Email', + width: 200, + resizable: true, + editor: TextEditor + }, + { + key: 'street', + name: 'Street', + width: 200, + resizable: true, + editor: TextEditor + }, + { + key: 'zipCode', + name: 'ZipCode', + width: 200, + resizable: true, + editor: TextEditor + }, + { + key: 'date', + name: 'Date', + width: 200, + resizable: true, + editor: TextEditor + }, + { + key: 'bs', + name: 'bs', + width: 200, + resizable: true, + editor: TextEditor + }, + { + key: 'catchPhrase', + name: 'Catch Phrase', + width: 200, + resizable: true, + editor: TextEditor + }, + { + key: 'companyName', + name: 'Company Name', + width: 200, + resizable: true, + editor: TextEditor + }, + { + key: 'sentence', + name: 'Sentence', + width: 200, + resizable: true, + editor: TextEditor + } +]; function createFakeRowObjectData(index: number): Row { return { @@ -84,107 +186,6 @@ export function AllFeatures() { const [isLoading, setIsLoading] = useState(false); const gridRef = useRef(null); - const columns = useMemo((): Column[] => [ - SelectColumn, - { - key: 'id', - name: 'ID', - width: 80, - resizable: true, - frozen: true - }, - { - key: 'avatar', - name: 'Avatar', - width: 40, - resizable: true, - headerRenderer: () => , - formatter: ({ row }) => - }, - { - key: 'title', - name: 'Title', - editor: React.forwardRef((props, ref) => ), - width: 200, - resizable: true, - formatter(props) { - return <>{props.row.title}; - } - }, - { - key: 'firstName', - name: 'First Name', - editable: true, - width: 200, - resizable: true, - frozen: true - }, - { - key: 'lastName', - name: 'Last Name', - editable: true, - width: 200, - resizable: true, - frozen: true - }, - { - key: 'email', - name: 'Email', - editable: true, - width: 200, - resizable: true - }, - { - key: 'street', - name: 'Street', - editable: true, - width: 200, - resizable: true - }, - { - key: 'zipCode', - name: 'ZipCode', - editable: true, - width: 200, - resizable: true - }, - { - key: 'date', - name: 'Date', - editable: true, - width: 200, - resizable: true - }, - { - key: 'bs', - name: 'bs', - editable: true, - width: 200, - resizable: true - }, - { - key: 'catchPhrase', - name: 'Catch Phrase', - editable: true, - width: 200, - resizable: true - }, - { - key: 'companyName', - name: 'Company Name', - editable: true, - width: 200, - resizable: true - }, - { - key: 'sentence', - name: 'Sentence', - editable: true, - width: 200, - resizable: true - } - ], []); - const handleRowUpdate = useCallback(({ fromRow, toRow, updated, action }: RowsUpdateEvent>): void => { const newRows = [...rows]; let start: number; @@ -207,12 +208,6 @@ export function AllFeatures() { const handleAddRow = useCallback(({ newRowIndex }: { newRowIndex: number }): void => setRows([...rows, createFakeRowObjectData(newRowIndex)]), [rows]); - const handleRowClick = useCallback((rowIdx: number, row: Row, column: CalculatedColumn) => { - if (column.key === 'title') { - gridRef.current?.selectCell({ rowIdx, idx: column.idx }, true); - } - }, []); - async function handleScroll(event: React.UIEvent) { if (!isAtBottom(event)) return; @@ -233,7 +228,7 @@ export function AllFeatures() { rows={rows} rowKeyGetter={rowKeyGetter} onRowsUpdate={handleRowUpdate} - onRowClick={handleRowClick} + onRowsChange={setRows} rowHeight={30} selectedRows={selectedRows} onScroll={handleScroll} diff --git a/stories/demos/CommonFeatures.tsx b/stories/demos/CommonFeatures.tsx index d26e664359..817c82236d 100644 --- a/stories/demos/CommonFeatures.tsx +++ b/stories/demos/CommonFeatures.tsx @@ -1,7 +1,6 @@ import React, { useState, useCallback, useMemo } from 'react'; import faker from 'faker'; -import DataGrid, { SelectColumn, Column, RowsUpdateEvent, SortDirection } from '../../src'; -import { TextEditor } from './components/Editors/TextEditor'; +import DataGrid, { SelectColumn, Column, RowsUpdateEvent, SortDirection, TextEditor } from '../../src'; import { SelectEditor } from './components/Editors/SelectEditor'; const dateFormatter = new Intl.DateTimeFormat(navigator.language); @@ -59,8 +58,8 @@ function getColumns(countries: string[]): readonly Column[] { key: 'title', name: 'Task', width: 120, - editable: true, frozen: true, + editor: TextEditor, summaryFormatter({ row }) { return <>{row.totalCount} records; } @@ -69,19 +68,19 @@ function getColumns(countries: string[]): readonly Column[] { key: 'client', name: 'Client', width: 220, - editable: true + editor: TextEditor }, { key: 'area', name: 'Area', width: 120, - editable: true + editor: TextEditor }, { key: 'country', name: 'Country', width: 180, - editor2: p => ( + editor: p => ( p.onRowChange({ ...p.row, country: value }, true)} @@ -95,19 +94,13 @@ function getColumns(countries: string[]): readonly Column[] { key: 'contact', name: 'Contact', width: 160, - editor2: p => ( - p.onRowChange({ ...p.row, contact: value })} - rowHeight={p.rowHeight} - /> - ) + editor: TextEditor }, { key: 'assignee', name: 'Assignee', width: 150, - editable: true + editor: TextEditor }, { key: 'progress', @@ -158,7 +151,7 @@ function getColumns(countries: string[]): readonly Column[] { { key: 'version', name: 'Version', - editable: true + editor: TextEditor }, { key: 'available', diff --git a/stories/demos/components/Editors/DropDownEditor.tsx b/stories/demos/components/Editors/DropDownEditor.tsx index 5a495ee541..4915008a25 100644 --- a/stories/demos/components/Editors/DropDownEditor.tsx +++ b/stories/demos/components/Editors/DropDownEditor.tsx @@ -1,62 +1,20 @@ -import React, { forwardRef, useImperativeHandle, useRef } from 'react'; -import { Editor, EditorProps } from '../../../../src'; +import React from 'react'; +import { EditorProps } from '../../../../src'; +import { Row } from '../../AllFeatures'; -interface Option { - id: string; - title: string; - value: string; - text: string; -} - -interface DropDownEditorProps extends EditorProps { - options: Array