mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-23 00:13:36 +10:00
Merge pull request #450 from gianantoniopini/develop
Add support for drag & drop to the List component
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user