diff --git a/src/components/builder/lists/List.js b/src/components/builder/lists/List.js index 7a5f2163b..51beb3367 100644 --- a/src/components/builder/lists/List.js +++ b/src/components/builder/lists/List.js @@ -1,10 +1,11 @@ import { get, isEmpty } from 'lodash'; import React, { memo, useContext } from 'react'; +import { DragDropContext, Droppable } from 'react-beautiful-dnd'; import { useTranslation } from 'react-i18next'; import { MdAdd } from 'react-icons/md'; import ModalContext from '../../../contexts/ModalContext'; -import { useSelector } from '../../../contexts/ResumeContext'; -import { formatDateRange } from '../../../utils'; +import { useDispatch, useSelector } from '../../../contexts/ResumeContext'; +import { formatDateRange, reorder } from '../../../utils'; import Button from '../../shared/Button'; import EmptyList from './EmptyList'; import styles from './List.module.css'; @@ -24,42 +25,83 @@ const List = ({ const { t, i18n } = useTranslation(); const items = useSelector(path, []); const { emitter } = useContext(ModalContext); + const dispatch = useDispatch(); const handleAdd = () => emitter.emit(event); const handleEdit = (data) => emitter.emit(event, data); + const onDragEnd = (result) => { + const { source, destination } = result; + + if (!destination) { + return; + } + + const sourceDroppableId = source.droppableId; + const destinationDroppableId = destination.droppableId; + if (sourceDroppableId !== destinationDroppableId) { + return; + } + + if (source.index === destination.index) { + return; + } + + const itemsReordered = reorder(items, source.index, destination.index); + dispatch({ + type: 'on_input', + payload: { + path, + value: itemsReordered, + }, + }); + }; + return (
{isEmpty(items) ? ( ) : ( - items.map((x, i) => ( - handleEdit(x)} - isFirst={i === 0} - isLast={i === items.length - 1} - /> - )) + + + {(dropProvided) => ( +
+ {items.map((x, i) => ( + handleEdit(x)} + isFirst={i === 0} + isLast={i === items.length - 1} + index={i} + /> + ))} + {dropProvided.placeholder} +
+ )} +
+
)}
diff --git a/src/components/builder/lists/ListItem.js b/src/components/builder/lists/ListItem.js index 7dfd406b5..fa51b7316 100644 --- a/src/components/builder/lists/ListItem.js +++ b/src/components/builder/lists/ListItem.js @@ -1,11 +1,14 @@ import { Menu, MenuItem } from '@material-ui/core'; import React, { memo, useState } from 'react'; +import { Draggable } from 'react-beautiful-dnd'; import { useTranslation } from 'react-i18next'; import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io'; import { MdMoreVert } from 'react-icons/md'; import { useDispatch } from '../../../contexts/ResumeContext'; import styles from './ListItem.module.css'; +const dataTestIdPrefix = 'list-item-'; + const ListItem = ({ title, subtitle, @@ -15,6 +18,7 @@ const ListItem = ({ isFirst, isLast, onEdit, + index, }) => { const dispatch = useDispatch(); const { t } = useTranslation(); @@ -66,50 +70,68 @@ const ListItem = ({ }; return ( -
-
- {title} - - {subtitle && ( - {subtitle} - )} - - {text && ( - {text} - )} -
- -
- - + {(dragProvided) => ( +
-
- - - - - - +
+ {title} + + {subtitle && ( + + {subtitle} + + )} + + {text && ( + + {text} + + )}
- {t('shared.buttons.edit')} - - - {t('shared.buttons.delete')} - - -
-
-
+ +
+ + +
+ + + + + + +
+ + {t('shared.buttons.edit')} + + + + {t('shared.buttons.delete')} + + +
+
+
+ )} + ); }; export default memo(ListItem); + +export { dataTestIdPrefix }; diff --git a/src/pages/app/__tests__/builder.skills.test.js b/src/pages/app/__tests__/builder.skills.test.js new file mode 100644 index 000000000..1677ac366 --- /dev/null +++ b/src/pages/app/__tests__/builder.skills.test.js @@ -0,0 +1,63 @@ +import { get } from 'lodash'; +import { screen } from '@testing-library/react'; + +import { DatabaseConstants } from 'gatsby-plugin-firebase'; + +import { dataTestIdPrefix as listItemDataTestIdPrefix } from '../../../components/builder/lists/ListItem'; + +import { + setupAndWait, + expectDatabaseUpdateToHaveCompleted, + dragAndDropDirectionDown, + dragAndDropListItem, +} from './helpers/builder'; + +const testTimeoutInMilliseconds = 20000; +jest.setTimeout(testTimeoutInMilliseconds); + +test('allows to drag & drop', async () => { + const resumeId = DatabaseConstants.demoStateResume1Id; + const { resume, mockDatabaseUpdateFunction } = await setupAndWait( + resumeId, + true, + true, + ); + + const now = new Date().getTime(); + + const dataPath = 'skills.items'; + const dataItems = get(resume, dataPath); + expect(dataItems.length).toBeGreaterThan(1); + + const listItems = screen.getAllByTestId( + `${listItemDataTestIdPrefix}${dataPath}`, + ); + const firstListItem = listItems[0]; + expect(firstListItem).toHaveTextContent(dataItems[0].name); + const secondListItem = listItems[1]; + expect(secondListItem).toHaveTextContent(dataItems[1].name); + + dragAndDropListItem(firstListItem, dragAndDropDirectionDown); + + const actualListItems = screen.getAllByTestId( + `${listItemDataTestIdPrefix}${dataPath}`, + ); + const actualFirstListItem = actualListItems[0]; + expect(actualFirstListItem).toHaveTextContent(dataItems[1].name); + const actualSecondListItem = actualListItems[1]; + expect(actualSecondListItem).toHaveTextContent(dataItems[0].name); + + await expectDatabaseUpdateToHaveCompleted(mockDatabaseUpdateFunction); + const mockDatabaseUpdateFunctionCallArgument = + mockDatabaseUpdateFunction.mock.calls[0][0]; + expect(mockDatabaseUpdateFunctionCallArgument.id).toBe(resumeId); + expect(mockDatabaseUpdateFunctionCallArgument.skills.items[0]).toEqual( + dataItems[1], + ); + expect(mockDatabaseUpdateFunctionCallArgument.skills.items[1]).toEqual( + dataItems[0], + ); + expect( + mockDatabaseUpdateFunctionCallArgument.updatedAt, + ).toBeGreaterThanOrEqual(now); +}); diff --git a/src/pages/app/__tests__/helpers/builder.js b/src/pages/app/__tests__/helpers/builder.js index b05eca1b7..e63c93468 100644 --- a/src/pages/app/__tests__/helpers/builder.js +++ b/src/pages/app/__tests__/helpers/builder.js @@ -1,5 +1,6 @@ import React from 'react'; import { + fireEvent, render, screen, waitFor, @@ -46,6 +47,36 @@ const expectDatabaseUpdateToHaveCompleted = async ( ); }; +const dragAndDropDirectionUp = 'DND_DIRECTION_UP'; +const dragAndDropDirectionDown = 'DND_DIRECTION_DOWN'; + +const dragAndDropListItem = (listItemElement, direction) => { + const spaceKey = { keyCode: 32 }; + const arrowUpKey = { keyCode: 38 }; + const arrowDownKey = { keyCode: 40 }; + const getKeyForDirection = () => { + switch (direction) { + case dragAndDropDirectionUp: + return arrowUpKey; + case dragAndDropDirectionDown: + return arrowDownKey; + default: + throw new Error('Unhandled `direction`!'); + } + }; + + listItemElement.focus(); + + // start the drag + fireEvent.keyDown(listItemElement, spaceKey); + + // move element based on direction + fireEvent.keyDown(listItemElement, getKeyForDirection()); + + // drop + fireEvent.keyDown(listItemElement, spaceKey); +}; + // eslint-disable-next-line no-underscore-dangle async function _setup( resumeId, @@ -124,4 +155,7 @@ export { setupAndWait, waitForDatabaseUpdateToHaveCompletedFn as waitForDatabaseUpdateToHaveCompleted, expectDatabaseUpdateToHaveCompleted, + dragAndDropDirectionUp, + dragAndDropDirectionDown, + dragAndDropListItem, };