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

@@ -3,6 +3,7 @@ import express from 'express';
import { Project } from 'entities';
import { catchErrors } from 'errors';
import { findEntityOrThrow, updateEntity } from 'utils/typeorm';
import { issuePartial } from 'serializers/issues';
const router = express.Router();
@@ -10,9 +11,14 @@ router.get(
'/project',
catchErrors(async (req, res) => {
const project = await findEntityOrThrow(Project, req.currentUser.projectId, {
relations: ['users', 'issues', 'issues.comments'],
relations: ['users', 'issues'],
});
res.respond({
project: {
...project,
issues: project.issues.map(issuePartial),
},
});
res.respond({ project });
}),
);

View File

@@ -24,10 +24,11 @@ const seedProjects = (users: User[]): Promise<Project[]> => {
const seedIssues = (projects: Project[]): Promise<Issue[]> => {
const issues = projects
.map(project =>
times(10, () =>
times(10, i =>
createEntity(
Issue,
generateIssue({
listPosition: i + 1,
reporterId: (sample(project.users) as User).id,
project,
users: [sample(project.users) as User],

View File

@@ -40,6 +40,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
type: IssueType.TASK,
status: IssueStatus.BACKLOG,
priority: IssuePriority.LOWEST,
listPosition: 1,
estimate: 8,
reporterId: getRandomUser().id,
project,
@@ -50,6 +51,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
type: IssueType.TASK,
status: IssueStatus.BACKLOG,
priority: IssuePriority.LOW,
listPosition: 2,
description: 'Nothing in particular.',
estimate: 40,
reporterId: getRandomUser().id,
@@ -60,6 +62,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
type: IssueType.BUG,
status: IssueStatus.BACKLOG,
priority: IssuePriority.MEDIUM,
listPosition: 3,
estimate: 15,
reporterId: getRandomUser().id,
project,
@@ -70,6 +73,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
type: IssueType.STORY,
status: IssueStatus.BACKLOG,
priority: IssuePriority.HIGH,
listPosition: 4,
description:
"#### Colons can be used to align columns.\n\n| Tables | Are | Cool |\n| ------------- |:-------------:| -----:|\n| col 3 is | right-aligned | |\n| col 2 is | centered | |\n| zebra stripes | are neat | |\n\nThe outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.\n\nMarkdown | Less | Pretty\n--- | --- | ---\n*Still* | `renders` | **nicely**\n1 | 2 | 3",
estimate: 4,
@@ -82,6 +86,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
type: IssueType.TASK,
status: IssueStatus.SELECTED,
priority: IssuePriority.HIGHEST,
listPosition: 5,
estimate: 15,
reporterId: getRandomUser().id,
project,
@@ -91,6 +96,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
type: IssueType.STORY,
status: IssueStatus.SELECTED,
priority: IssuePriority.MEDIUM,
listPosition: 6,
estimate: 55,
reporterId: getRandomUser().id,
project,
@@ -101,6 +107,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
type: IssueType.TASK,
status: IssueStatus.SELECTED,
priority: IssuePriority.MEDIUM,
listPosition: 7,
estimate: 12,
reporterId: getRandomUser().id,
project,

View File

@@ -41,6 +41,9 @@ class Issue extends BaseEntity {
@Column('varchar')
priority: IssuePriority;
@Column('double precision')
listPosition: number;
@Column('text', { nullable: true })
description: string | null;

View File

@@ -0,0 +1,16 @@
import { pick } from 'lodash';
import { Issue } from 'entities';
export const issuePartial = (issue: Issue): Partial<Issue> =>
pick(issue, [
'id',
'title',
'type',
'status',
'priority',
'listPosition',
'createdAt',
'updatedAt',
'userIds',
]);

View File

@@ -891,6 +891,22 @@
"regenerator-runtime": "^0.13.2"
}
},
"@babel/runtime-corejs2": {
"version": "7.7.6",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.7.6.tgz",
"integrity": "sha512-QYp/8xdH8iMin3pH5gtT/rUuttVfIcOhWBC3wh9Eh/qs4jEe39+3DpCDLgWXhMQgiCTOH8mrLSvQ0OHOCcox9g==",
"requires": {
"core-js": "^2.6.5",
"regenerator-runtime": "^0.13.2"
},
"dependencies": {
"core-js": {
"version": "2.6.11",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz",
"integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg=="
}
}
},
"@babel/runtime-corejs3": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.7.4.tgz",
@@ -2570,6 +2586,14 @@
"randomfill": "^1.0.3"
}
},
"css-box-model": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.0.tgz",
"integrity": "sha512-lri0br+jSNV0kkkiGEp9y9y3Njq2PmpqbeGWRFQJuZteZzY9iC9GZhQ8Y4WpPwM/2YocjHePxy14igJY7YKzkA==",
"requires": {
"tiny-invariant": "^1.0.6"
}
},
"css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
@@ -5415,7 +5439,6 @@
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"dev": true,
"requires": {
"loose-envify": "^1.0.0"
}
@@ -7554,6 +7577,11 @@
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==",
"dev": true
},
"raf-schd": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz",
"integrity": "sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -7609,6 +7637,20 @@
"prop-types": "^15.6.2"
}
},
"react-beautiful-dnd": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-12.2.0.tgz",
"integrity": "sha512-s5UrOXNDgeEC+sx65IgbeFlqKKgK3c0UfbrJLWufP34WBheyu5kJ741DtJbsSgPKyNLkqfswpMYr0P8lRj42cA==",
"requires": {
"@babel/runtime-corejs2": "^7.6.3",
"css-box-model": "^1.2.0",
"memoize-one": "^5.1.1",
"raf-schd": "^4.0.2",
"react-redux": "^7.1.1",
"redux": "^4.0.4",
"use-memo-one": "^1.1.1"
}
},
"react-dom": {
"version": "16.12.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.12.0.tgz",
@@ -7625,6 +7667,19 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz",
"integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q=="
},
"react-redux": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.1.3.tgz",
"integrity": "sha512-uI1wca+ECG9RoVkWQFF4jDMqmaw0/qnvaSvOoL/GA4dNxf6LoV8sUAcNDvE5NWKs4hFpn0t6wswNQnY3f7HT3w==",
"requires": {
"@babel/runtime": "^7.5.5",
"hoist-non-react-statics": "^3.3.0",
"invariant": "^2.2.4",
"loose-envify": "^1.4.0",
"prop-types": "^15.7.2",
"react-is": "^16.9.0"
}
},
"react-router": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz",
@@ -7805,6 +7860,15 @@
}
}
},
"redux": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.4.tgz",
"integrity": "sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q==",
"requires": {
"loose-envify": "^1.4.0",
"symbol-observable": "^1.2.0"
}
},
"regenerate": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz",
@@ -8972,8 +9036,7 @@
"symbol-observable": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==",
"dev": true
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="
},
"table": {
"version": "5.4.6",
@@ -9438,6 +9501,11 @@
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
"dev": true
},
"use-memo-one": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.1.tgz",
"integrity": "sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ=="
},
"util": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",

View File

@@ -45,6 +45,7 @@
"prop-types": "^15.7.2",
"query-string": "^6.9.0",
"react": "^16.12.0",
"react-beautiful-dnd": "^12.2.0",
"react-dom": "^16.12.0",
"react-router-dom": "^5.1.2",
"react-textarea-autosize": "^7.1.2",

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 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 ProjectBoardLists = ({ project, filteredIssues }) => {
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>
<Droppable key={status} droppableId={status}>
{provided => (
<List>
<Title>
{`${issueStatusCopy[status]} `}
<ListIssuesCount>{issuesCount}</ListIssuesCount>
</ListTitle>
<Issues>{filteredListIssues.map(renderIssue)}</Issues>
</List>
);
};
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}
/>
<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} />
))}
</IssueAssignees>
</IssueBottom>
</Issue>
{provided.placeholder}
</Issues>
</List>
)}
</Droppable>
);
};
return <Lists>{Object.values(IssueStatus).map(renderList)}</Lists>;
return (
<DragDropContext onDragEnd={handleIssueDrop}>
<Lists>{Object.values(IssueStatus).map(renderList)}</Lists>
</DragDropContext>
);
};
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;
};