Implemented issue drag and drop
This commit is contained in:
@@ -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 });
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,9 @@ class Issue extends BaseEntity {
|
||||
@Column('varchar')
|
||||
priority: IssuePriority;
|
||||
|
||||
@Column('double precision')
|
||||
listPosition: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
description: string | null;
|
||||
|
||||
|
||||
16
api/src/serializers/issues.ts
Normal file
16
api/src/serializers/issues.ts
Normal 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',
|
||||
]);
|
||||
74
client/package-lock.json
generated
74
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
61
client/src/components/Project/Board/Lists/Issue/Styles.js
Normal file
61
client/src/components/Project/Board/Lists/Issue/Styles.js
Normal 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;
|
||||
`;
|
||||
67
client/src/components/Project/Board/Lists/Issue/index.jsx
Normal file
67
client/src/components/Project/Board/Lists/Issue/index.jsx
Normal 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;
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
<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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
22
client/src/shared/utils/javascript.js
Normal file
22
client/src/shared/utils/javascript.js
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user