Implemented issue drag and drop

This commit is contained in:
ireic
2019-12-14 01:20:54 +01:00
parent 73b4ff97b2
commit f48b2a9d40
17 changed files with 453 additions and 181 deletions

View File

@@ -1,9 +1,7 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import { intersection, xor } from 'lodash';
import { xor, debounce } from 'lodash';
import useDebounceValue from 'shared/hooks/debounceValue';
import {
Filters,
SearchInput,
@@ -15,73 +13,53 @@ import {
} from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
currentUser: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
projectUsers: PropTypes.array.isRequired,
defaultFilters: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
setFilters: PropTypes.func.isRequired,
};
const ProjectBoardFilters = ({ project, currentUser, onChange }) => {
const [searchQuery, setSearchQuery] = useState('');
const [userIds, setUserIds] = useState([]);
const [myOnly, setMyOnly] = useState(false);
const [recent, setRecent] = useState(false);
const debouncedSearchQuery = useDebounceValue(searchQuery, 500);
const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, setFilters }) => {
const { searchQuery, userIds, myOnly, recent } = filters;
const clearFilters = () => {
setSearchQuery('');
setUserIds([]);
setMyOnly(false);
setRecent(false);
};
const setFiltersMerge = newFilters => setFilters({ ...filters, ...newFilters });
const areFiltersCleared = !searchQuery && userIds.length === 0 && !myOnly && !recent;
useEffect(() => {
const getFilteredIssues = () => {
let { issues } = project;
if (debouncedSearchQuery) {
issues = issues.filter(issue =>
issue.title.toLowerCase().includes(debouncedSearchQuery.toLowerCase()),
);
}
if (userIds.length > 0) {
issues = issues.filter(issue => intersection(issue.userIds, userIds).length > 0);
}
if (myOnly) {
issues = issues.filter(issue => issue.userIds.includes(currentUser.id));
}
if (recent) {
issues = issues.filter(issue =>
moment(issue.updatedAt).isAfter(moment().subtract(3, 'days')),
);
}
return issues;
};
onChange(getFilteredIssues());
}, [project, currentUser, onChange, debouncedSearchQuery, userIds, myOnly, recent]);
return (
<Filters>
<SearchInput icon="search" value={searchQuery} onChange={setSearchQuery} />
<SearchInput
icon="search"
onChange={debounce(value => setFiltersMerge({ searchQuery: value }), 500)}
/>
<Avatars>
{project.users.map(user => (
{projectUsers.map(user => (
<AvatarIsActiveBorder key={user.id} isActive={userIds.includes(user.id)}>
<StyledAvatar
avatarUrl={user.avatarUrl}
name={user.name}
onClick={() => setUserIds(value => xor(value, [user.id]))}
onClick={() => setFiltersMerge({ userIds: xor(userIds, [user.id]) })}
/>
</AvatarIsActiveBorder>
))}
</Avatars>
<StyledButton color="empty" isActive={myOnly} onClick={() => setMyOnly(!myOnly)}>
<StyledButton
color="empty"
isActive={myOnly}
onClick={() => setFiltersMerge({ myOnly: !myOnly })}
>
Only My Issues
</StyledButton>
<StyledButton color="empty" isActive={recent} onClick={() => setRecent(!recent)}>
<StyledButton
color="empty"
isActive={recent}
onClick={() => setFiltersMerge({ recent: !recent })}
>
Recently Updated
</StyledButton>
{!areFiltersCleared && <ClearAll onClick={clearFilters}>Clear all</ClearAll>}
{!areFiltersCleared && (
<ClearAll onClick={() => setFilters(defaultFilters)}>Clear all</ClearAll>
)}
</Filters>
);
};

View File

@@ -0,0 +1,61 @@
import styled, { css } from 'styled-components';
import { Avatar, Icon } from 'shared/components';
import { color, issueTypeColors, issuePriorityColors, font, mixin } from 'shared/utils/styles';
export const IssueWrapper = styled.div`
margin-bottom: 5px;
`;
export const Issue = styled.div`
padding: 10px;
border-radius: 3px;
background: #fff;
box-shadow: 0px 1px 2px 0px rgba(9, 30, 66, 0.25);
transition: background 0.1s;
${mixin.clickable}
&:hover {
background: ${color.backgroundLight};
}
${props =>
props.isBeingDragged &&
css`
transform: rotate(3deg);
box-shadow: 5px 10px 30px 0px rgba(9, 30, 66, 0.15);
`}
`;
export const Title = styled.p`
padding-bottom: 11px;
${font.size(15)}
`;
export const Bottom = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
export const TypeIcon = styled(Icon)`
font-size: 19px;
color: ${props => issueTypeColors[props.color]};
`;
export const PriorityIcon = styled(Icon)`
position: relative;
top: -1px;
margin-left: 4px;
font-size: 18px;
color: ${props => issuePriorityColors[props.color]};
`;
export const Assignees = styled.div`
display: flex;
flex-direction: row-reverse;
margin-left: 2px;
`;
export const AssigneeAvatar = styled(Avatar)`
margin-left: -2px;
box-shadow: 0 0 0 2px #fff;
`;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Draggable } from 'react-beautiful-dnd';
import { IssuePriority } from 'shared/constants/issues';
import {
IssueWrapper,
Issue,
Title,
Bottom,
TypeIcon,
PriorityIcon,
Assignees,
AssigneeAvatar,
} from './Styles';
const propTypes = {
projectUsers: PropTypes.array.isRequired,
issue: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
};
const ProjectBoardListsIssue = ({ projectUsers, issue, index }) => {
const getUserById = userId => projectUsers.find(user => user.id === userId);
const assignees = issue.userIds.map(getUserById);
const priorityIconType = [IssuePriority.LOW || IssuePriority.LOWEST].includes(issue.priority)
? 'arrow-down'
: 'arrow-up';
return (
<Draggable draggableId={issue.id.toString()} index={index}>
{(provided, snapshot) => (
<IssueWrapper
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
>
<Issue isBeingDragged={snapshot.isDragging && !snapshot.isDropAnimating}>
<Title>{issue.title}</Title>
<Bottom>
<div>
<TypeIcon type={issue.type} color={issue.type} />
<PriorityIcon type={priorityIconType} color={issue.priority} />
</div>
<Assignees>
{assignees.map(user => (
<AssigneeAvatar
key={user.id}
size={24}
avatarUrl={user.avatarUrl}
name={user.name}
/>
))}
</Assignees>
</Bottom>
</Issue>
</IssueWrapper>
)}
</Draggable>
);
};
ProjectBoardListsIssue.propTypes = propTypes;
export default ProjectBoardListsIssue;

View File

@@ -1,7 +1,6 @@
import styled from 'styled-components';
import { Avatar, Icon } from 'shared/components';
import { color, issueTypeColors, issuePriorityColors, font, mixin } from 'shared/utils/styles';
import { color, font } from 'shared/utils/styles';
export const Lists = styled.div`
display: flex;
@@ -9,72 +8,28 @@ export const Lists = styled.div`
`;
export const List = styled.div`
display: flex;
flex-direction: column;
margin: 0 5px;
min-height: 400px;
width: 25%;
border-radius: 3px;
background: ${color.backgroundLightest};
`;
export const ListTitle = styled.div`
export const Title = styled.div`
padding: 13px 10px 17px;
text-transform: uppercase;
color: ${color.textMedium};
${font.size(12.5)};
`;
export const ListIssuesCount = styled.span`
export const IssuesCount = styled.span`
text-transform: lowercase;
${font.size(13)};
`;
export const Issues = styled.div`
height: 100%;
padding: 0 5px;
`;
export const Issue = styled.div`
margin-bottom: 5px;
padding: 10px;
border-radius: 3px;
background: #fff;
box-shadow: 0px 1px 2px 0px rgba(9, 30, 66, 0.25);
transition: background 0.1s;
${mixin.clickable}
&:hover {
background: ${color.backgroundLight};
}
`;
export const IssueTitle = styled.p`
padding-bottom: 11px;
${font.size(15)}
`;
export const IssueBottom = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
export const IssueTypeIcon = styled(Icon)`
font-size: 19px;
color: ${props => issueTypeColors[props.color]};
`;
export const IssuePriorityIcon = styled(Icon)`
position: relative;
top: -1px;
margin-left: 4px;
font-size: 18px;
color: ${props => issuePriorityColors[props.color]};
`;
export const IssueAssignees = styled.div`
display: flex;
flex-direction: row-reverse;
margin-left: 2px;
`;
export const IssueAssigneeAvatar = styled(Avatar)`
margin-left: -2px;
box-shadow: 0 0 0 2px #fff;
`;

View File

@@ -1,32 +1,70 @@
import React from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { intersection } from 'lodash';
import { IssueStatus, IssuePriority } from 'shared/constants/issues';
import api from 'shared/utils/api';
import {
Lists,
List,
ListTitle,
ListIssuesCount,
Issues,
Issue,
IssueTitle,
IssueBottom,
IssueTypeIcon,
IssuePriorityIcon,
IssueAssignees,
IssueAssigneeAvatar,
} from './Styles';
moveItemWithinArray,
insertItemIntoArray,
updateArrayItemById,
} from 'shared/utils/javascript';
import { IssueStatus } from 'shared/constants/issues';
import Issue from './Issue';
import { Lists, List, Title, IssuesCount, Issues } from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
filteredIssues: PropTypes.array.isRequired,
filters: PropTypes.object.isRequired,
currentUserId: PropTypes.number,
setLocalProjectData: PropTypes.func.isRequired,
};
const ProjectBoardLists = ({ project, filteredIssues }) => {
const defaultProps = {
currentUserId: null,
};
const ProjectBoardLists = ({ project, filters, currentUserId, setLocalProjectData }) => {
const filteredIssues = filterIssues(project.issues, filters, currentUserId);
const handleIssueDrop = ({ draggableId, destination, source }) => {
if (!destination) return;
const isSameList = destination.droppableId === source.droppableId;
const isSamePosition = destination.index === source.index;
if (isSameList && isSamePosition) return;
const issueId = parseInt(draggableId);
const { prevIssue, nextIssue } = getAfterDropPrevNextIssue(
project.issues,
destination,
isSameList,
issueId,
);
const afterDropListPosition = calculateListPosition(prevIssue, nextIssue);
const issueFieldsToUpdate = {
status: destination.droppableId,
listPosition: afterDropListPosition,
};
setLocalProjectData(data => ({
project: {
...data.project,
issues: updateArrayItemById(data.project.issues, issueId, issueFieldsToUpdate),
},
}));
api.put(`/issues/${issueId}`, issueFieldsToUpdate);
};
const renderList = status => {
const getListIssues = issues => issues.filter(issue => issue.status === status);
const allListIssues = getListIssues(project.issues);
const filteredListIssues = getListIssues(filteredIssues);
const filteredListIssues = getSortedListIssues(filteredIssues, status);
const allListIssues = getSortedListIssues(project.issues, status);
const issuesCount =
allListIssues.length !== filteredListIssues.length
@@ -34,50 +72,81 @@ const ProjectBoardLists = ({ project, filteredIssues }) => {
: allListIssues.length;
return (
<List key={status}>
<ListTitle>
{`${issueStatusCopy[status]} `}
<ListIssuesCount>{issuesCount}</ListIssuesCount>
</ListTitle>
<Issues>{filteredListIssues.map(renderIssue)}</Issues>
</List>
<Droppable key={status} droppableId={status}>
{provided => (
<List>
<Title>
{`${issueStatusCopy[status]} `}
<IssuesCount>{issuesCount}</IssuesCount>
</Title>
<Issues {...provided.droppableProps} ref={provided.innerRef}>
{filteredListIssues.map((issue, i) => (
<Issue key={issue.id} projectUsers={project.users} issue={issue} index={i} />
))}
{provided.placeholder}
</Issues>
</List>
)}
</Droppable>
);
};
const renderIssue = issue => {
const getUserById = userId => project.users.find(user => user.id === userId);
const assignees = issue.userIds.map(getUserById);
return (
<Issue key={issue.id}>
<IssueTitle>{issue.title}</IssueTitle>
<IssueBottom>
<div>
<IssueTypeIcon type={issue.type} color={issue.type} />
<IssuePriorityIcon
type={
[IssuePriority.LOW || IssuePriority.LOWEST].includes(issue.priority)
? 'arrow-down'
: 'arrow-up'
}
color={issue.priority}
/>
</div>
<IssueAssignees>
{assignees.map(user => (
<IssueAssigneeAvatar
key={user.id}
size={24}
avatarUrl={user.avatarUrl}
name={user.name}
/>
))}
</IssueAssignees>
</IssueBottom>
</Issue>
);
};
return (
<DragDropContext onDragEnd={handleIssueDrop}>
<Lists>{Object.values(IssueStatus).map(renderList)}</Lists>
</DragDropContext>
);
};
return <Lists>{Object.values(IssueStatus).map(renderList)}</Lists>;
const filterIssues = (projectIssues, filters, currentUserId) => {
let issues = projectIssues;
const { searchQuery, userIds, myOnly, recent } = filters;
if (searchQuery) {
issues = issues.filter(issue => issue.title.toLowerCase().includes(searchQuery.toLowerCase()));
}
if (userIds.length > 0) {
issues = issues.filter(issue => intersection(issue.userIds, userIds).length > 0);
}
if (myOnly && currentUserId) {
issues = issues.filter(issue => issue.userIds.includes(currentUserId));
}
if (recent) {
issues = issues.filter(issue => moment(issue.updatedAt).isAfter(moment().subtract(3, 'days')));
}
return issues;
};
const getSortedListIssues = (issues, status) =>
issues.filter(issue => issue.status === status).sort((a, b) => a.listPosition - b.listPosition);
const calculateListPosition = (prevIssue, nextIssue) => {
let position;
if (!prevIssue && !nextIssue) {
position = 1;
} else if (!prevIssue) {
position = nextIssue.listPosition - 1;
} else if (!nextIssue) {
position = prevIssue.listPosition + 1;
} else {
position = prevIssue.listPosition + (nextIssue.listPosition - prevIssue.listPosition) / 2;
}
return position;
};
const getAfterDropPrevNextIssue = (allIssues, destination, isSameList, droppedIssueId) => {
const destinationIssues = getSortedListIssues(allIssues, destination.droppableId);
const droppedIssue = allIssues.find(issue => issue.id === droppedIssueId);
const afterDropDestinationIssues = isSameList
? moveItemWithinArray(destinationIssues, droppedIssue, destination.index)
: insertItemIntoArray(destinationIssues, droppedIssue, destination.index);
return {
prevIssue: afterDropDestinationIssues[destination.index - 1],
nextIssue: afterDropDestinationIssues[destination.index + 1],
};
};
const issueStatusCopy = {
@@ -88,5 +157,6 @@ const issueStatusCopy = {
};
ProjectBoardLists.propTypes = propTypes;
ProjectBoardLists.defaultProps = defaultProps;
export default ProjectBoardLists;

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { get } from 'lodash';
import useApi from 'shared/hooks/api';
import Header from './Header';
@@ -8,21 +9,31 @@ import Lists from './Lists';
const propTypes = {
project: PropTypes.object.isRequired,
setLocalProjectData: PropTypes.func.isRequired,
};
const ProjectBoard = ({ project }) => {
const [filteredIssues, setFilteredIssues] = useState([]);
const defaultFilters = { searchQuery: '', userIds: [], myOnly: false, recent: false };
const ProjectBoard = ({ project, setLocalProjectData }) => {
const [filters, setFilters] = useState(defaultFilters);
const [{ data }] = useApi.get('/currentUser');
const { currentUser } = data || {};
return (
<>
<Header projectName={project.name} />
{currentUser && (
<Filters project={project} currentUser={currentUser} onChange={setFilteredIssues} />
)}
<Lists project={project} filteredIssues={filteredIssues} />
<Filters
projectUsers={project.users}
defaultFilters={defaultFilters}
filters={filters}
setFilters={setFilters}
/>
<Lists
project={project}
filters={filters}
currentUserId={get(data, 'currentUser.id')}
setLocalProjectData={setLocalProjectData}
/>
</>
);
};

View File

@@ -7,16 +7,17 @@ import Board from './Board';
import { ProjectPage } from './Styles';
const Project = () => {
const [{ data, error, isLoading }] = useApi.get('/project');
const [{ data, error, setLocalData: setLocalProjectData }] = useApi.get('/project');
if (isLoading) return <PageLoader />;
if (!data) return <PageLoader />;
if (error) return <PageError />;
const { project } = data;
return (
<ProjectPage>
<Sidebar projectName={project.name} />
<Board project={project} />
<Board project={project} setLocalProjectData={setLocalProjectData} />
</ProjectPage>
);
};

View File

@@ -40,7 +40,7 @@ const Modal = ({
const modalIdRef = useRef(uniqueIncreasingIntegerId());
const closeModal = useCallback(() => {
if (shouldNotCloseBecauseHasOpenChildModal(modalIdRef.current)) {
if (hasChildModal(modalIdRef.current)) {
return;
}
if (!isControlled) {
@@ -80,8 +80,7 @@ const getIdsOfAllOpenModals = () => {
return $modalNodes.map($node => parseInt($node.getAttribute('data-jira-modal-id')));
};
const shouldNotCloseBecauseHasOpenChildModal = modalId =>
getIdsOfAllOpenModals().some(id => id > modalId);
const hasChildModal = modalId => getIdsOfAllOpenModals().some(id => id > modalId);
const setBodyScrollLock = () => {
const areAnyModalsOpen = getIdsOfAllOpenModals().length > 0;

View File

@@ -14,6 +14,12 @@ const useApi = (method, url, paramsOrData = {}, { lazy = false } = {}) => {
variables: {},
});
const setLocalData = useCallback(
set => setState(currentState => ({ ...currentState, data: set(currentState.data) })),
[],
);
const updateState = newState => setState(currentState => ({ ...currentState, ...newState }));
const wasCalledRef = useRef(false);
const paramsOrDataMemoized = useDeepCompareMemoize(paramsOrData);
@@ -23,7 +29,6 @@ const useApi = (method, url, paramsOrData = {}, { lazy = false } = {}) => {
const makeRequest = useCallback(
(newVariables = {}) =>
new Promise((resolve, reject) => {
const updateState = newState => setState({ ...stateRef.current, ...newState });
const variables = { ...stateRef.current.variables, ...newVariables };
if (!isCalledAutomatically || wasCalledRef.current) {
@@ -57,6 +62,7 @@ const useApi = (method, url, paramsOrData = {}, { lazy = false } = {}) => {
...state,
wasCalled: wasCalledRef.current,
variables: { ...paramsOrDataMemoized, ...state.variables },
setLocalData,
},
makeRequest,
];

View File

@@ -0,0 +1,22 @@
export const moveItemWithinArray = (arr, item, newIndex) => {
const arrClone = [...arr];
const oldIndex = arrClone.indexOf(item);
arrClone.splice(newIndex, 0, arrClone.splice(oldIndex, 1)[0]);
return arrClone;
};
export const insertItemIntoArray = (arr, item, index) => {
const arrClone = [...arr];
arrClone.splice(index, 0, item);
return arrClone;
};
export const updateArrayItemById = (arr, itemId, newFields) => {
const arrClone = [...arr];
const item = arrClone.find(({ id }) => id === itemId);
const itemIndex = arrClone.indexOf(item);
if (itemIndex > -1) {
arrClone.splice(itemIndex, 1, { ...item, ...newFields });
}
return arrClone;
};