import React from 'react' import { act, renderHook } from '@testing-library/react' import { GridProvider } from '../context/grid-context' import { useRangePaste } from './use-range-paste' import type { GridRangeSelection, GridRowData } from '../types' import type { UseGridReturn } from './use-grid' import { createGrid } from './use-grid' describe('useRangePaste', () => { let grid: UseGridReturn const mockContainer = document.createElement('div') const mockRange: GridRangeSelection = { from: { rowId: 'row1', columnId: 'col1' }, to: { rowId: 'row2', columnId: 'col2' }, } const mockRangeData = { rowIds: ['row1', 'row2'], columnIds: ['col1', 'col2'], } const mockOnBulkCellChange = jest.fn() beforeEach(() => { grid = createGrid() grid.dispatch({ type: 'applyProps', payload: { actionsMenuPresent: false, columns: [ { id: 'col1', label: 'Column 1', cell: { editable: true, }, }, { id: 'col2', label: 'Column 2', cell: { editable: true, }, }, ], rows: [ { id: 'row1', col1: 'R1C1', col2: 'R1C2' }, { id: 'row2', col1: 'R2C1', col2: 'R2C2' }, ], footerEnabled: false, rowDrag: { enabled: false }, }, }) jest.spyOn(grid.selectors, 'selectIsEditing').mockReturnValue(false) jest.spyOn(grid.selectors, 'selectRangeSelection').mockReturnValue( mockRange ) jest.spyOn(grid.selectors, 'selectRangeIds').mockReturnValue( mockRangeData ) jest.spyOn(grid.selectors, 'selectIsSpreadsheet').mockReturnValue(true) jest.spyOn(grid.selectors, 'selectCurrentFocus').mockReturnValue({ area: 'body', rowId: 'row1', columnId: 'col1', subFocus: 'first', }) jest.spyOn(grid.api.rangeSelection, 'select').mockImplementation() jest.spyOn(grid.api.focus, 'set').mockImplementation() mockOnBulkCellChange.mockReset().mockReturnValue(true) }) it('should return undefined when not a spreadsheet', () => { jest.mocked(grid.selectors.selectIsSpreadsheet).mockReturnValue(false) const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) expect(result.current).toBeUndefined() }) it('should return undefined when onBulkCellChange is not provided', () => { const { result } = renderHook( () => useRangePaste(undefined, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) expect(result.current).toBeUndefined() }) it('should return handlePaste function when is a spreadsheet and onBulkCellChange is provided', () => { const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) expect(result.current).toBeInstanceOf(Function) }) it('handlePaste should do nothing when there is no range selection', () => { jest.mocked(grid.selectors.selectRangeSelection).mockReturnValue(null) const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn(), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockEvent.preventDefault).not.toHaveBeenCalled() expect(mockOnBulkCellChange).not.toHaveBeenCalled() }) it('handlePaste should do nothing when no clipboard data is available', () => { const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn().mockReturnValue(''), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockEvent.preventDefault).toHaveBeenCalled() expect(mockOnBulkCellChange).not.toHaveBeenCalled() }) it('handlePaste should do nothing if the grid is currently in editing mode', () => { jest.spyOn(grid.selectors, 'selectIsEditing').mockReturnValue(true) const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'text/plain') { return 'newValue1\tnewValue2\nnewValue3\tnewValue4' } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockEvent.preventDefault).not.toHaveBeenCalled() expect(mockOnBulkCellChange).not.toHaveBeenCalled() }) it('handlePaste should process plain text data', () => { const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'text/plain') { return 'newValue1\tnewValue2\nnewValue3\tnewValue4' } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockEvent.preventDefault).toHaveBeenCalled() expect(mockOnBulkCellChange).toHaveBeenCalledWith( new Map([ [ 'row1', [ { rowId: 'row1', columnId: 'col1', nextValue: 'newValue1', previousValue: 'R1C1', }, { rowId: 'row1', columnId: 'col2', nextValue: 'newValue2', previousValue: 'R1C2', }, ], ], [ 'row2', [ { rowId: 'row2', columnId: 'col1', nextValue: 'newValue3', previousValue: 'R2C1', }, { rowId: 'row2', columnId: 'col2', nextValue: 'newValue4', previousValue: 'R2C2', }, ], ], ]) ) expect(mockContainer).toHaveAttribute( 'data-pvds-grid-range-pasted', 'true' ) }) it('handlePaste should process HTML table data', () => { const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const htmlTable = `
Header 1 Header 2
Row header htmlValue1 htmlValue2
Row header htmlValue3 htmlValue4
` const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'text/html') { return htmlTable } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockEvent.preventDefault).toHaveBeenCalled() expect(mockOnBulkCellChange).toHaveBeenCalledWith( new Map([ [ 'row1', [ { rowId: 'row1', columnId: 'col1', nextValue: 'htmlValue1', previousValue: 'R1C1', }, { rowId: 'row1', columnId: 'col2', nextValue: 'htmlValue2', previousValue: 'R1C2', }, ], ], [ 'row2', [ { rowId: 'row2', columnId: 'col1', nextValue: 'htmlValue3', previousValue: 'R2C1', }, { rowId: 'row2', columnId: 'col2', nextValue: 'htmlValue4', previousValue: 'R2C2', }, ], ], ]) ) }) it('handlePaste should process custom data format', () => { const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const customData = JSON.stringify([ { rowId: 'customRow1', values: { col1: { label: 'Custom1', value: 'customValue1' }, col2: { label: 'Custom2', value: 'customValue2' }, }, }, { rowId: 'customRow2', values: { col1: { label: 'Custom3', value: 'customValue3' }, col2: { label: 'Custom4', value: 'customValue4' }, }, }, ]) const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'application/x-pvds-grid') { return customData } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockEvent.preventDefault).toHaveBeenCalled() expect(mockOnBulkCellChange).toHaveBeenCalledWith( new Map([ [ 'row1', [ { rowId: 'row1', columnId: 'col1', nextValue: 'customValue1', previousValue: 'R1C1', }, { rowId: 'row1', columnId: 'col2', nextValue: 'customValue2', previousValue: 'R1C2', }, ], ], [ 'row2', [ { rowId: 'row2', columnId: 'col1', nextValue: 'customValue3', previousValue: 'R2C1', }, { rowId: 'row2', columnId: 'col2', nextValue: 'customValue4', previousValue: 'R2C2', }, ], ], ]) ) }) it('should respect if cell can be edited when processing paste data', () => { grid.dispatch({ type: 'applyProps', payload: { actionsMenuPresent: false, columns: [ { id: 'col1', label: 'Column 1', cell: { editable: true, }, }, { id: 'col2', label: 'Column 2', cell: { editable: false, }, }, ], rows: [ { id: 'row1', col1: 'R1C1', col2: 'R1C2' }, { id: 'row2', col1: 'R2C1', col2: 'R2C2' }, ], footerEnabled: false, rowDrag: { enabled: false }, }, }) const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'text/plain') { return 'newValue1\tnewValue2\nnewValue3\tnewValue4' } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockOnBulkCellChange).toHaveBeenCalledWith( new Map([ [ 'row1', [ { rowId: 'row1', columnId: 'col1', nextValue: 'newValue1', previousValue: 'R1C1', }, ], ], [ 'row2', [ { rowId: 'row2', columnId: 'col1', nextValue: 'newValue3', previousValue: 'R2C1', }, ], ], ]) ) }) describe('column spanning', () => { describe('when the clipboard has colspan, but the target cells do not', () => { it('should handle colspan in pasted data', () => { const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const customData = JSON.stringify([ { rowId: 'customRow1', values: { col1: { label: 'Custom1', value: 'customValue1', colSpan: 2, }, }, }, { rowId: 'customRow2', values: { col1: { label: 'Custom3', value: 'customValue3' }, col2: { label: 'Custom4', value: 'customValue4' }, }, }, ]) const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'application/x-pvds-grid') { return customData } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockOnBulkCellChange).toHaveBeenCalledWith( new Map([ [ 'row1', [ { rowId: 'row1', columnId: 'col1', nextValue: 'customValue1', previousValue: 'R1C1', }, { rowId: 'row1', columnId: 'col2', nextValue: '', previousValue: 'R1C2', }, ], ], [ 'row2', [ { rowId: 'row2', columnId: 'col1', nextValue: 'customValue3', previousValue: 'R2C1', }, { rowId: 'row2', columnId: 'col2', nextValue: 'customValue4', previousValue: 'R2C2', }, ], ], ]) ) }) it('should handle colspan in HTML table data', () => { const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const htmlTable = `
htmlValueWithColspan
htmlValue3 htmlValue4
` const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'text/html') { return htmlTable } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockEvent.preventDefault).toHaveBeenCalled() expect(mockOnBulkCellChange).toHaveBeenCalledWith( new Map([ [ 'row1', [ { rowId: 'row1', columnId: 'col1', nextValue: 'htmlValueWithColspan', previousValue: 'R1C1', }, { rowId: 'row1', columnId: 'col2', nextValue: '', previousValue: 'R1C2', }, ], ], [ 'row2', [ { rowId: 'row2', columnId: 'col1', nextValue: 'htmlValue3', previousValue: 'R2C1', }, { rowId: 'row2', columnId: 'col2', nextValue: 'htmlValue4', previousValue: 'R2C2', }, ], ], ]) ) }) }) describe('when the clipboard does not have colspan, but the target cells do', () => { it('should skip over spanned cells', () => { grid.dispatch({ type: 'applyProps', payload: { actionsMenuPresent: false, columns: [ { id: 'col1', label: 'Column 1', cell: { editable: true, }, }, { id: 'col2', label: 'Column 2', cell: { editable: true, colSpan({ row }) { return row.id === 'row1' ? ['col2', 'col3'] : ['col1', 'col2'] }, }, }, { id: 'col3', label: 'Column 3', cell: { editable: true, }, }, ], rows: [ { id: 'row1', col1: 'R1C1', col2: 'R1C2', col3: 'R1C3', }, { id: 'row2', col1: 'R2C1', col2: 'R2C2', col3: 'R2C3', }, ], footerEnabled: false, rowDrag: { enabled: false }, }, }) const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const htmlTable = `
htmlValue1 htmlValue2 htmlValue3
htmlValue4 htmlValue5 htmlValue6
` const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'text/html') { return htmlTable } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockEvent.preventDefault).toHaveBeenCalled() expect(mockOnBulkCellChange).toHaveBeenCalledWith( new Map([ [ 'row1', [ { rowId: 'row1', columnId: 'col1', nextValue: 'htmlValue1', previousValue: 'R1C1', }, { rowId: 'row1', columnId: 'col2', nextValue: 'htmlValue2', previousValue: 'R1C2', }, ], ], [ 'row2', [ { rowId: 'row2', columnId: 'col2', nextValue: 'htmlValue4', previousValue: 'R2C2', }, { rowId: 'row2', columnId: 'col3', nextValue: 'htmlValue6', previousValue: 'R2C3', }, ], ], ]) ) }) }) describe('when both the clipboard and target cells have colspan', () => { it('should properly paste spanned cells', () => { grid.dispatch({ type: 'applyProps', payload: { actionsMenuPresent: false, columns: [ { id: 'col1', label: 'Column 1', cell: { editable: true, }, }, { id: 'col2', label: 'Column 2', cell: { editable: true, colSpan({ row }) { return row.id === 'row1' ? ['col2', 'col3'] : ['col1', 'col2'] }, }, }, { id: 'col3', label: 'Column 3', cell: { editable: true, }, }, ], rows: [ { id: 'row1', col1: 'R1C1', col2: 'R1C2', col3: 'R1C3', }, { id: 'row2', col1: 'R2C1', col2: 'R2C2', col3: 'R2C3', }, ], footerEnabled: false, rowDrag: { enabled: false }, }, }) const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const htmlTable = `
htmlValue1 htmlValue3
htmlValue4 htmlValue6
` const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'text/html') { return htmlTable } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockEvent.preventDefault).toHaveBeenCalled() expect(mockOnBulkCellChange).toHaveBeenCalledWith( new Map([ [ 'row1', [ { rowId: 'row1', columnId: 'col1', nextValue: 'htmlValue1', previousValue: 'R1C1', }, { rowId: 'row1', columnId: 'col2', nextValue: '', previousValue: 'R1C2', }, ], ], [ 'row2', [ { rowId: 'row2', columnId: 'col2', nextValue: 'htmlValue4', previousValue: 'R2C2', }, { rowId: 'row2', columnId: 'col3', nextValue: 'htmlValue6', previousValue: 'R2C3', }, ], ], ]) ) }) }) }) it('should set error status when onBulkCellChange returns false', () => { mockOnBulkCellChange.mockReturnValue(false) const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'text/plain') { return 'newValue1\tnewValue2\nnewValue3\tnewValue4' } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockContainer).toHaveAttribute( 'data-pvds-grid-range-pasted', 'error' ) expect(grid.api.rangeSelection.select).not.toHaveBeenCalled() expect(grid.api.focus.set).not.toHaveBeenCalled() }) it('should update focus if current focus differs from paste target', () => { jest.mocked(grid.selectors.selectCurrentFocus).mockReturnValue({ area: 'header', rowId: 'row2', columnId: 'col2', subFocus: 'first', }) const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'text/plain') { return 'newValue1\tnewValue2\nnewValue3\tnewValue4' } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(grid.api.focus.set).toHaveBeenCalledWith( 'row1', 'col1', 'body', 'first' ) }) it('should remove attribute after animation duration', () => { jest.useFakeTimers() const { result } = renderHook( () => useRangePaste(mockOnBulkCellChange, mockContainer), { wrapper: ({ children }) => ( {children} ), } ) const mockEvent = { preventDefault: jest.fn(), clipboardData: { getData: jest.fn((type) => { if (type === 'text/plain') { return 'newValue1\tnewValue2\nnewValue3\tnewValue4' } return '' }), }, } as unknown as React.ClipboardEvent act(() => { result.current?.(mockEvent) }) expect(mockContainer).toHaveAttribute( 'data-pvds-grid-range-pasted', 'true' ) act(() => { jest.advanceTimersByTime(300) }) expect(mockContainer).not.toHaveAttribute('data-pvds-grid-range-pasted') jest.useRealTimers() }) })