Merge pull request #450 from gianantoniopini/develop

Add support for drag & drop to the List component
This commit is contained in:
Amruth Pillai
2021-02-12 11:17:43 +05:30
committed by GitHub
4 changed files with 229 additions and 68 deletions
+69 -27
View File
@@ -1,10 +1,11 @@
import { get, isEmpty } from 'lodash'; import { get, isEmpty } from 'lodash';
import React, { memo, useContext } from 'react'; import React, { memo, useContext } from 'react';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MdAdd } from 'react-icons/md'; import { MdAdd } from 'react-icons/md';
import ModalContext from '../../../contexts/ModalContext'; import ModalContext from '../../../contexts/ModalContext';
import { useSelector } from '../../../contexts/ResumeContext'; import { useDispatch, useSelector } from '../../../contexts/ResumeContext';
import { formatDateRange } from '../../../utils'; import { formatDateRange, reorder } from '../../../utils';
import Button from '../../shared/Button'; import Button from '../../shared/Button';
import EmptyList from './EmptyList'; import EmptyList from './EmptyList';
import styles from './List.module.css'; import styles from './List.module.css';
@@ -24,42 +25,83 @@ const List = ({
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const items = useSelector(path, []); const items = useSelector(path, []);
const { emitter } = useContext(ModalContext); const { emitter } = useContext(ModalContext);
const dispatch = useDispatch();
const handleAdd = () => emitter.emit(event); const handleAdd = () => emitter.emit(event);
const handleEdit = (data) => emitter.emit(event, data); 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 ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className={styles.list}> <div className={styles.list}>
{isEmpty(items) ? ( {isEmpty(items) ? (
<EmptyList /> <EmptyList />
) : ( ) : (
items.map((x, i) => ( <DragDropContext onDragEnd={onDragEnd}>
<ListItem <Droppable droppableId={path}>
key={x.id} {(dropProvided) => (
data={x} <div
path={path} ref={dropProvided.innerRef}
title={title || get(x, titlePath, '')} {...dropProvided.droppableProps}
subtitle={ >
subtitle || {items.map((x, i) => (
get(x, subtitlePath, '') || <ListItem
(hasDate && key={x.id}
formatDateRange( data={x}
{ path={path}
startDate: x.startDate, title={title || get(x, titlePath, '')}
endDate: x.endDate, subtitle={
language: i18n.language, subtitle ||
}, get(x, subtitlePath, '') ||
t, (hasDate &&
)) formatDateRange(
} {
text={text || get(x, textPath, '')} startDate: x.startDate,
onEdit={() => handleEdit(x)} endDate: x.endDate,
isFirst={i === 0} language: i18n.language,
isLast={i === items.length - 1} },
/> t,
)) ))
}
text={text || get(x, textPath, '')}
onEdit={() => handleEdit(x)}
isFirst={i === 0}
isLast={i === items.length - 1}
index={i}
/>
))}
{dropProvided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)} )}
</div> </div>
+63 -41
View File
@@ -1,11 +1,14 @@
import { Menu, MenuItem } from '@material-ui/core'; import { Menu, MenuItem } from '@material-ui/core';
import React, { memo, useState } from 'react'; import React, { memo, useState } from 'react';
import { Draggable } from 'react-beautiful-dnd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io'; import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io';
import { MdMoreVert } from 'react-icons/md'; import { MdMoreVert } from 'react-icons/md';
import { useDispatch } from '../../../contexts/ResumeContext'; import { useDispatch } from '../../../contexts/ResumeContext';
import styles from './ListItem.module.css'; import styles from './ListItem.module.css';
const dataTestIdPrefix = 'list-item-';
const ListItem = ({ const ListItem = ({
title, title,
subtitle, subtitle,
@@ -15,6 +18,7 @@ const ListItem = ({
isFirst, isFirst,
isLast, isLast,
onEdit, onEdit,
index,
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -66,50 +70,68 @@ const ListItem = ({
}; };
return ( return (
<div className={styles.listItem}> <Draggable draggableId={data.id} index={index}>
<div className="grid"> {(dragProvided) => (
<span className="font-medium truncate">{title}</span> <div
ref={dragProvided.innerRef}
{subtitle && ( className={styles.listItem}
<span className="mt-1 text-sm opacity-75 truncate">{subtitle}</span> data-testid={`${dataTestIdPrefix}${path}`}
)} {...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
{text && (
<span className="w-4/5 mt-5 text-sm opacity-75 truncate">{text}</span>
)}
</div>
<div className={styles.menu}>
<MdMoreVert
size="18px"
aria-haspopup="true"
onClick={handleClick}
className="cursor-context-menu"
/>
<Menu
keepMounted
anchorEl={anchorEl}
onClose={handleClose}
open={Boolean(anchorEl)}
> >
<div className="flex items-center space-around"> <div className="grid">
<MenuItem disabled={isFirst} onClick={handleMoveUp}> <span className="font-medium truncate">{title}</span>
<IoIosArrowUp size="18px" />
</MenuItem> {subtitle && (
<MenuItem disabled={isLast} onClick={handleMoveDown}> <span className="mt-1 text-sm opacity-75 truncate">
<IoIosArrowDown size="18px" /> {subtitle}
</MenuItem> </span>
)}
{text && (
<span className="w-4/5 mt-5 text-sm opacity-75 truncate">
{text}
</span>
)}
</div> </div>
<MenuItem onClick={handleEdit}>{t('shared.buttons.edit')}</MenuItem>
<MenuItem onClick={handleDelete}> <div className={styles.menu}>
<span className="text-red-600 font-medium"> <MdMoreVert
{t('shared.buttons.delete')} size="18px"
</span> aria-haspopup="true"
</MenuItem> onClick={handleClick}
</Menu> className="cursor-context-menu"
</div> />
</div> <Menu
keepMounted
anchorEl={anchorEl}
onClose={handleClose}
open={Boolean(anchorEl)}
>
<div className="flex items-center space-around">
<MenuItem disabled={isFirst} onClick={handleMoveUp}>
<IoIosArrowUp size="18px" />
</MenuItem>
<MenuItem disabled={isLast} onClick={handleMoveDown}>
<IoIosArrowDown size="18px" />
</MenuItem>
</div>
<MenuItem onClick={handleEdit}>
{t('shared.buttons.edit')}
</MenuItem>
<MenuItem onClick={handleDelete}>
<span className="text-red-600 font-medium">
{t('shared.buttons.delete')}
</span>
</MenuItem>
</Menu>
</div>
</div>
)}
</Draggable>
); );
}; };
export default memo(ListItem); export default memo(ListItem);
export { dataTestIdPrefix };
@@ -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);
});
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { import {
fireEvent,
render, render,
screen, screen,
waitFor, 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 // eslint-disable-next-line no-underscore-dangle
async function _setup( async function _setup(
resumeId, resumeId,
@@ -124,4 +155,7 @@ export {
setupAndWait, setupAndWait,
waitForDatabaseUpdateToHaveCompletedFn as waitForDatabaseUpdateToHaveCompleted, waitForDatabaseUpdateToHaveCompletedFn as waitForDatabaseUpdateToHaveCompleted,
expectDatabaseUpdateToHaveCompleted, expectDatabaseUpdateToHaveCompleted,
dragAndDropDirectionUp,
dragAndDropDirectionDown,
dragAndDropListItem,
}; };