From dde32361c49e6368900853d2b27984622c73e80b Mon Sep 17 00:00:00 2001 From: Ashique Ansari Date: Wed, 28 Feb 2024 02:20:59 +0530 Subject: [PATCH] Unit Test Case for useDraggableTable --- .../__tests__/useDraggableTable.spec.ts | 322 ++++++++++++++++++ frontend/src/utilities/useDraggableTable.ts | 182 +++++----- 2 files changed, 423 insertions(+), 81 deletions(-) create mode 100644 frontend/src/utilities/__tests__/useDraggableTable.spec.ts diff --git a/frontend/src/utilities/__tests__/useDraggableTable.spec.ts b/frontend/src/utilities/__tests__/useDraggableTable.spec.ts new file mode 100644 index 0000000000..d73daa4000 --- /dev/null +++ b/frontend/src/utilities/__tests__/useDraggableTable.spec.ts @@ -0,0 +1,322 @@ +import { act } from '@testing-library/react'; +import useDraggableTable from '~/utilities/useDraggableTable'; +import { testHook } from '~/__tests__/unit/testUtils/hooks'; + +let setItemOrder = jest.fn(); +beforeEach(() => { + setItemOrder = jest.fn(); +}); + +afterEach(() => { + setItemOrder.mockReset(); +}); + +const generateRowsWithItems = (itemOrder: string[]) => { + const tbody = document.createElement('tbody'); + + itemOrder.forEach((itemId) => { + const row = document.createElement('tr'); + row.id = itemId; + tbody.appendChild(row); + }); + + return tbody; +}; +describe('initializeState', () => { + it('should initialize state and return tableProps and rowProps', async () => { + const renderResult = testHook(useDraggableTable)(['item1', 'item2', 'item3'], setItemOrder); + expect(renderResult).hookToHaveUpdateCount(1); + renderResult.rerender(['item1', 'item2', 'item3'], setItemOrder); + + expect(renderResult).hookToHaveUpdateCount(2); + expect(renderResult.result.current.tableProps.tbodyProps.ref.current).toBe(null); + expect(renderResult.result.current.tableProps.className).toBe(undefined); + + expect(renderResult).hookToBeStable({ + rowProps: { onDragEnd: false, onDragStart: false, onDrop: false }, + tableProps: { + className: false, + tbodyProps: { + onDragLeave: false, + onDragOver: false, + ref: false, + }, + }, + }); + }); +}); +describe('dragStart', () => { + it('should handle drag start behaviour correctly and setDraggedItemId accurately', () => { + const itemOrder = ['item1', 'item2', 'item3']; + const tableElement = generateRowsWithItems(itemOrder); + document.body.appendChild(tableElement); + + const renderResult = testHook(useDraggableTable)(itemOrder, setItemOrder); + const { rowProps, tableProps } = renderResult.result.current; + const { onDragStart } = rowProps; + const { ref } = tableProps.tbodyProps; + + ref.current = document.body.querySelector('tbody'); + + const dragStartEvent = { + dataTransfer: { effectAllowed: 'none', setData: jest.fn() }, + currentTarget: { + id: 'item3', + classList: { add: jest.fn() }, + setAttribute: jest.fn(), + }, + } as unknown as React.DragEvent; + + expect(renderResult.result.current.tableProps.tbodyProps.ref.current).toBe(ref.current); + expect(renderResult).hookToHaveUpdateCount(1); + + act(() => { + onDragStart(dragStartEvent); + }); + + expect(renderResult).hookToHaveUpdateCount(2); + expect(dragStartEvent.dataTransfer.effectAllowed).toBe('move'); + expect(dragStartEvent.dataTransfer.setData).toHaveBeenCalledWith('text/plain', 'item3'); + expect(dragStartEvent.currentTarget.classList.add).toHaveBeenCalledWith('pf-m-ghost-row'); + expect(dragStartEvent.currentTarget.setAttribute).toHaveBeenCalledWith('aria-pressed', 'true'); + expect(renderResult.result.current.tableProps.className).toBe('pf-m-drag-over'); + + tableProps.tbodyProps.ref.current = null; + }); +}); +describe('onDrop', () => { + it('should handle onDrop behaviour with valid ref and event', () => { + const itemOrder = ['item3', 'item1', 'item2']; + const tableElement = generateRowsWithItems(itemOrder); + document.body.appendChild(tableElement); + + const renderResult = testHook(useDraggableTable)(itemOrder, setItemOrder); + + const { rowProps, tableProps } = renderResult.result.current; + const { onDrop } = rowProps; + const { ref } = tableProps.tbodyProps; + ref.current = document.body.querySelector('tbody'); + + if (ref.current) { + ref.current.getBoundingClientRect = jest.fn().mockReturnValue({ + x: 40, + y: 40, + width: 20, + height: 20, + } as DOMRect); + } + + act(() => { + onDrop({ + clientX: 45, + clientY: 45, + } as unknown as React.DragEvent); + }); + + expect(renderResult).hookToHaveUpdateCount(1); + expect(setItemOrder).toHaveBeenCalledWith(itemOrder); + expect(renderResult.result.current.tableProps.tbodyProps.ref.current).toBe(ref.current); + expect(renderResult.result.current.tableProps.className).toBe(undefined); + }); + + it('should call onDragCancel when event is invalid and ref is null', () => { + const renderResult = testHook(useDraggableTable)(['item1', 'item2', 'item3'], setItemOrder); + + const { rowProps, tableProps } = renderResult.result.current; + const { onDrop } = rowProps; + const { ref } = tableProps.tbodyProps; + ref.current = null; + + expect(renderResult.result.current.tableProps.tbodyProps.ref.current).toBe(null); + + act(() => { + onDrop({} as unknown as React.DragEvent); + }); + + expect(renderResult).hookToHaveUpdateCount(1); + expect(setItemOrder).not.toHaveBeenCalled(); + + tableProps.tbodyProps.ref.current = null; + }); + + it('should call onDragCancel when event is invalid but ref is not null', () => { + const itemOrder = ['item1', 'item2', 'item3']; + const tableElement = generateRowsWithItems(itemOrder); + document.body.appendChild(tableElement); + + const renderResult = testHook(useDraggableTable)(itemOrder, setItemOrder); + + const { rowProps, tableProps } = renderResult.result.current; + const { onDrop } = rowProps; + const { ref } = tableProps.tbodyProps; + ref.current = document.body.querySelector('tbody'); + + act(() => { + onDrop({} as unknown as React.DragEvent); + }); + + expect(renderResult).hookToHaveUpdateCount(1); + expect(setItemOrder).not.toHaveBeenCalled(); + expect(renderResult.result.current.tableProps.tbodyProps.ref.current).toBe(ref.current); + + tableProps.tbodyProps.ref.current = null; + }); +}); +describe('onDragOver', () => { + it('should handle dragover behaviour correctly with different item id', () => { + const itemOrder = ['item1', 'item2', 'item3']; + const tableElement = generateRowsWithItems(itemOrder); + document.body.appendChild(tableElement); + + const renderResult = testHook(useDraggableTable)(itemOrder, setItemOrder); + + const { tableProps } = renderResult.result.current; + const { onDragOver, ref } = tableProps.tbodyProps; + ref.current = document.body.querySelector('tbody'); + + act(() => { + onDragOver({ + preventDefault: jest.fn(), + clientX: 50, + clientY: 50, + target: document.body.querySelector('tbody tr:first-child'), + } as unknown as React.DragEvent); + }); + + expect(tableProps.tbodyProps.ref).toBe(ref); + }); + it('should handle dragover behaviour correctly with same item id', () => { + const itemOrder = ['item1', 'item2', 'item3']; + const tableElement = generateRowsWithItems(itemOrder); + document.body.appendChild(tableElement); + + const renderResult = testHook(useDraggableTable)(itemOrder, setItemOrder); + + const { tableProps } = renderResult.result.current; + const { onDragOver, ref } = tableProps.tbodyProps; + ref.current = document.body.querySelector('tbody'); + + act(() => { + onDragOver({ + preventDefault: jest.fn(), + clientX: 50, + clientY: 50, + target: document.body.querySelector('tbody tr:first-child'), + } as unknown as React.DragEvent); + }); + expect(tableProps.tbodyProps.ref).toBe(ref); + }); + it('should handle dragover behaviour correctly when bodyRef is null', async () => { + const itemOrder = ['item1', 'item2', 'item3']; + const tableElement = generateRowsWithItems(itemOrder); + document.body.appendChild(tableElement); + + const renderResult = testHook(useDraggableTable)(['item1', 'item2', 'item3'], setItemOrder); + + const { tableProps } = renderResult.result.current; + const { onDragOver, ref } = tableProps.tbodyProps; + ref.current = null; + + act(() => { + onDragOver({ + preventDefault: jest.fn(), + clientX: 50, + clientY: 50, + target: document.body.querySelector('tbody tr:first-child'), + } as unknown as React.DragEvent); + }); + }); + it('should handle drag and drop behavior correctly with invalid element', async () => { + const itemOrder = ['item1', 'item2', 'item3']; + const inValidElement = document.createElement('td'); + inValidElement.id = 'item3'; + document.body.appendChild(inValidElement); + + const renderResult = testHook(useDraggableTable)(itemOrder, setItemOrder); + + const { tableProps } = renderResult.result.current; + const { onDragOver, ref } = tableProps.tbodyProps; + + ref.current = document.body.querySelector('tbody'); + + act(() => { + onDragOver({ + preventDefault: jest.fn(), + clientX: 50, + clientY: 50, + target: inValidElement, + } as unknown as React.DragEvent); + }); + + expect(tableProps.tbodyProps.ref).toBe(ref); + }); +}); +describe('onDragEnd', () => { + it('should handle drag end behaviour correctly', () => { + const itemOrder = ['item3', 'item1', 'item2']; + const tableElement = generateRowsWithItems(itemOrder); + document.body.appendChild(tableElement); + + const renderResult = testHook(useDraggableTable)(itemOrder, setItemOrder); + + const { rowProps, tableProps } = renderResult.result.current; + const { onDragEnd } = rowProps; + const { ref } = tableProps.tbodyProps; + ref.current = document.body.querySelector('tbody'); + + expect(renderResult.result.current.tableProps.tbodyProps.ref.current).toBe(ref.current); + + const dragEndEvent = { + target: { + id: 'item3', + classList: { remove: jest.fn() }, + setAttribute: jest.fn(), + }, + } as unknown as React.DragEvent; + + act(() => { + onDragEnd(dragEndEvent); + }); + expect(renderResult).hookToHaveUpdateCount(1); + expect((dragEndEvent.target as HTMLTableRowElement).classList.remove).toHaveBeenCalled(); + expect((dragEndEvent.target as HTMLTableRowElement).setAttribute).toHaveBeenCalledWith( + 'aria-pressed', + 'false', + ); + }); +}); +describe('onDragLeave', () => { + const dragLeaveEvent = {} as unknown as React.DragEvent; + + it('should handle drag leave behaviour correctly with invalid event', () => { + const itemOrder = ['item1', 'item2', 'item3']; + const renderResult = testHook(useDraggableTable)(itemOrder, setItemOrder); + + const { tableProps } = renderResult.result.current; + const { onDragLeave, ref } = tableProps.tbodyProps; + ref.current = null; + + act(() => { + onDragLeave(dragLeaveEvent); + }); + expect(renderResult.result.current.tableProps.tbodyProps.ref.current).toBe(null); + expect(renderResult.result.current.tableProps.className).toBe(undefined); + expect(renderResult).hookToHaveUpdateCount(1); + }); + + it('should remove the last child if ulNode has children', () => { + const itemOrder = ['item4']; + const tableElement = generateRowsWithItems(itemOrder); + document.body.appendChild(tableElement); + + const renderResult = testHook(useDraggableTable)(itemOrder, setItemOrder); + const { tableProps } = renderResult.result.current; + const { onDragLeave, ref } = tableProps.tbodyProps; + + ref.current = document.body.querySelector('tbody'); + expect(renderResult.result.current.tableProps.tbodyProps.ref.current).toBe(ref.current); + act(() => { + onDragLeave(dragLeaveEvent); + }); + }); +}); diff --git a/frontend/src/utilities/useDraggableTable.ts b/frontend/src/utilities/useDraggableTable.ts index b6ddabb193..687929cc09 100644 --- a/frontend/src/utilities/useDraggableTable.ts +++ b/frontend/src/utilities/useDraggableTable.ts @@ -1,5 +1,5 @@ -import * as React from 'react'; -import { TbodyProps, TrProps } from '@patternfly/react-table'; +import React from 'react'; +import { TrProps } from '@patternfly/react-table'; import styles from '@patternfly/react-styles/css/components/Table/table'; type UseDraggableTable = { @@ -8,7 +8,7 @@ type UseDraggableTable = { tbodyProps: { onDragOver: React.DragEventHandler; onDragLeave: React.DragEventHandler; - ref: React.RefObject; + ref: React.MutableRefObject; }; }; rowProps: { @@ -26,21 +26,24 @@ const useDraggableTable = ( const [draggingToItemIndex, setDraggingToItemIndex] = React.useState(-1); const [isDragging, setIsDragging] = React.useState(false); const [tempItemOrder, setTempItemOrder] = React.useState(itemOrder); - const bodyRef = React.useRef(null); + const bodyRef = React.useRef(null); - const onDragStart: TrProps['onDragStart'] = (assignableEvent) => { - assignableEvent.dataTransfer.effectAllowed = 'move'; - assignableEvent.dataTransfer.setData('text/plain', assignableEvent.currentTarget.id); - const currentDraggedItemId = assignableEvent.currentTarget.id; + const onDragStart: TrProps['onDragStart'] = React.useCallback( + (assignableEvent: React.DragEvent) => { + assignableEvent.dataTransfer.effectAllowed = 'move'; + assignableEvent.dataTransfer.setData('text/plain', assignableEvent.currentTarget.id); + const currentDraggedItemId = assignableEvent.currentTarget.id; - assignableEvent.currentTarget.classList.add(styles.modifiers.ghostRow); - assignableEvent.currentTarget.setAttribute('aria-pressed', 'true'); + assignableEvent.currentTarget.classList.add(styles.modifiers.ghostRow); + assignableEvent.currentTarget.setAttribute('aria-pressed', 'true'); - setDraggedItemId(currentDraggedItemId); - setIsDragging(true); - }; + setDraggedItemId(currentDraggedItemId); + setIsDragging(true); + }, + [], + ); - const moveItem = (arr: string[], i1: string, toIndex: number) => { + const moveItem = React.useCallback((arr: string[], i1: string, toIndex: number) => { const fromIndex = arr.indexOf(i1); if (fromIndex === toIndex) { return arr; @@ -49,9 +52,9 @@ const useDraggableTable = ( arr.splice(toIndex, 0, temp[0]); return arr; - }; + }, []); - const move = (currentItemOrder: string[]) => { + const move = React.useCallback((currentItemOrder: string[]) => { if (!bodyRef.current) { return; } @@ -72,12 +75,13 @@ const useDraggableTable = ( ulNode.appendChild(node); } }); - }; + }, []); - const onDragCancel = () => { + const onDragCancel = React.useCallback(() => { if (!bodyRef.current) { return; } + Array.from(bodyRef.current.children).forEach((el) => { el.classList.remove(styles.modifiers.ghostRow); el.setAttribute('aria-pressed', 'false'); @@ -85,71 +89,87 @@ const useDraggableTable = ( setDraggedItemId(''); setDraggingToItemIndex(-1); setIsDragging(false); - }; - - const onDragLeave: TbodyProps['onDragLeave'] = (evt) => { - if (!isValidDrop(evt)) { - move(itemOrder); - setDraggingToItemIndex(-1); - } - }; - - const isValidDrop = (evt: React.DragEvent) => { - if (!bodyRef.current) { - return; - } - const ulRect = bodyRef.current.getBoundingClientRect(); - return ( - evt.clientX > ulRect.x && - evt.clientX < ulRect.x + ulRect.width && - evt.clientY > ulRect.y && - evt.clientY < ulRect.y + ulRect.height - ); - }; + }, []); - const onDrop: TrProps['onDrop'] = (evt) => { - if (isValidDrop(evt)) { - setItemOrder(tempItemOrder); - } else { - onDragCancel(); - } - }; - - const onDragOver: TbodyProps['onDragOver'] = (evt) => { - evt.preventDefault(); - - if (!bodyRef.current) { - return; - } - - const curListItem = (evt.target as HTMLTableSectionElement).closest('tr'); - if ( - !curListItem || - !bodyRef.current.contains(curListItem) || - curListItem.id === draggedItemId - ) { - return; - } - const dragId = curListItem.id; - const newDraggingToItemIndex = Array.from(bodyRef.current.children).findIndex( - (item) => item.id === dragId, - ); - if (newDraggingToItemIndex !== draggingToItemIndex) { - const newItemOrder = moveItem([...itemOrder], draggedItemId, newDraggingToItemIndex); - move(newItemOrder); - setDraggingToItemIndex(newDraggingToItemIndex); - setTempItemOrder(newItemOrder); - } - }; + const isValidDrop = React.useCallback( + (evt: React.DragEvent) => { + if (!bodyRef.current) { + return; + } + const ulRect = bodyRef.current.getBoundingClientRect(); + return ( + evt.clientX > ulRect.x && + evt.clientX < ulRect.x + ulRect.width && + evt.clientY > ulRect.y && + evt.clientY < ulRect.y + ulRect.height + ); + }, + [], + ); + + const onDragLeave = React.useCallback< + UseDraggableTable['tableProps']['tbodyProps']['onDragLeave'] + >( + (evt) => { + if (!isValidDrop(evt)) { + move(itemOrder); + setDraggingToItemIndex(-1); + } + }, + [isValidDrop, move, itemOrder], + ); + + const onDragOver = React.useCallback( + (evt) => { + evt.preventDefault(); + if (!bodyRef.current) { + return; + } - const onDragEnd: TrProps['onDragEnd'] = (evt) => { - const target = evt.target as HTMLTableRowElement; - target.classList.remove(styles.modifiers.ghostRow); - target.setAttribute('aria-pressed', 'false'); - setDraggedItemId(''); - setDraggingToItemIndex(-1); - setIsDragging(false); - }; + const curListItem = (evt.target as HTMLTableSectionElement).closest('tr'); + if ( + !curListItem || + !bodyRef.current.contains(curListItem) || + curListItem.id === draggedItemId + ) { + return; + } + const dragId = curListItem.id; + const newDraggingToItemIndex = Array.from(bodyRef.current.children).findIndex( + (item) => item.id === dragId, + ); + if (newDraggingToItemIndex !== draggingToItemIndex) { + const newItemOrder = moveItem([...itemOrder], draggedItemId, newDraggingToItemIndex); + move(newItemOrder); + setDraggingToItemIndex(newDraggingToItemIndex); + setTempItemOrder(newItemOrder); + } + }, + [draggedItemId, draggingToItemIndex, itemOrder, move, moveItem], + ); + + const onDrop: TrProps['onDrop'] = React.useCallback( + (evt: React.DragEvent) => { + if (isValidDrop(evt)) { + setItemOrder(tempItemOrder); + } else { + onDragCancel(); + } + }, + [isValidDrop, onDragCancel, setItemOrder, tempItemOrder], + ); + + const onDragEnd: TrProps['onDragEnd'] = React.useCallback( + (evt: React.DragEvent) => { + const target = evt.target as HTMLTableRowElement; + target.classList.remove(styles.modifiers.ghostRow); + target.setAttribute('aria-pressed', 'false'); + setDraggedItemId(''); + setDraggingToItemIndex(-1); + setIsDragging(false); + }, + [], + ); return { tableProps: {