From f48b2a9d4009613abee71ca451a2a17e109bcc67 Mon Sep 17 00:00:00 2001 From: ireic Date: Sat, 14 Dec 2019 01:20:54 +0100 Subject: [PATCH] Implemented issue drag and drop --- api/src/controllers/projects.ts | 10 +- api/src/database/seeds/development/index.ts | 3 +- api/src/database/seeds/guestUser.ts | 7 + api/src/entities/Issue.ts | 3 + api/src/serializers/issues.ts | 16 ++ client/package-lock.json | 74 ++++++- client/package.json | 1 + .../Project/Board/Filters/index.jsx | 78 +++----- .../Project/Board/Lists/Issue/Styles.js | 61 ++++++ .../Project/Board/Lists/Issue/index.jsx | 67 +++++++ .../components/Project/Board/Lists/Styles.js | 59 +----- .../components/Project/Board/Lists/index.jsx | 188 ++++++++++++------ client/src/components/Project/Board/index.jsx | 25 ++- client/src/components/Project/index.jsx | 7 +- client/src/shared/components/Modal/index.jsx | 5 +- client/src/shared/hooks/api.js | 8 +- client/src/shared/utils/javascript.js | 22 ++ 17 files changed, 453 insertions(+), 181 deletions(-) create mode 100644 api/src/serializers/issues.ts create mode 100644 client/src/components/Project/Board/Lists/Issue/Styles.js create mode 100644 client/src/components/Project/Board/Lists/Issue/index.jsx create mode 100644 client/src/shared/utils/javascript.js diff --git a/api/src/controllers/projects.ts b/api/src/controllers/projects.ts index d8a46e6..8ed67de 100644 --- a/api/src/controllers/projects.ts +++ b/api/src/controllers/projects.ts @@ -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 }); }), ); diff --git a/api/src/database/seeds/development/index.ts b/api/src/database/seeds/development/index.ts index b592ac1..3debc19 100644 --- a/api/src/database/seeds/development/index.ts +++ b/api/src/database/seeds/development/index.ts @@ -24,10 +24,11 @@ const seedProjects = (users: User[]): Promise => { const seedIssues = (projects: Project[]): Promise => { 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], diff --git a/api/src/database/seeds/guestUser.ts b/api/src/database/seeds/guestUser.ts index 75187dd..8dcc52c 100644 --- a/api/src/database/seeds/guestUser.ts +++ b/api/src/database/seeds/guestUser.ts @@ -40,6 +40,7 @@ const seedIssues = (project: Project): Promise => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { type: IssueType.TASK, status: IssueStatus.SELECTED, priority: IssuePriority.MEDIUM, + listPosition: 7, estimate: 12, reporterId: getRandomUser().id, project, diff --git a/api/src/entities/Issue.ts b/api/src/entities/Issue.ts index 74d43a5..acff764 100644 --- a/api/src/entities/Issue.ts +++ b/api/src/entities/Issue.ts @@ -41,6 +41,9 @@ class Issue extends BaseEntity { @Column('varchar') priority: IssuePriority; + @Column('double precision') + listPosition: number; + @Column('text', { nullable: true }) description: string | null; diff --git a/api/src/serializers/issues.ts b/api/src/serializers/issues.ts new file mode 100644 index 0000000..19fceb4 --- /dev/null +++ b/api/src/serializers/issues.ts @@ -0,0 +1,16 @@ +import { pick } from 'lodash'; + +import { Issue } from 'entities'; + +export const issuePartial = (issue: Issue): Partial => + pick(issue, [ + 'id', + 'title', + 'type', + 'status', + 'priority', + 'listPosition', + 'createdAt', + 'updatedAt', + 'userIds', + ]); diff --git a/client/package-lock.json b/client/package-lock.json index f044fd1..943806f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index ad9cae1..2b9c7c1 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/Project/Board/Filters/index.jsx b/client/src/components/Project/Board/Filters/index.jsx index 02e1ed0..b272496 100644 --- a/client/src/components/Project/Board/Filters/index.jsx +++ b/client/src/components/Project/Board/Filters/index.jsx @@ -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 ( - + setFiltersMerge({ searchQuery: value }), 500)} + /> - {project.users.map(user => ( + {projectUsers.map(user => ( setUserIds(value => xor(value, [user.id]))} + onClick={() => setFiltersMerge({ userIds: xor(userIds, [user.id]) })} /> ))} - setMyOnly(!myOnly)}> + setFiltersMerge({ myOnly: !myOnly })} + > Only My Issues - setRecent(!recent)}> + setFiltersMerge({ recent: !recent })} + > Recently Updated - {!areFiltersCleared && Clear all} + {!areFiltersCleared && ( + setFilters(defaultFilters)}>Clear all + )} ); }; diff --git a/client/src/components/Project/Board/Lists/Issue/Styles.js b/client/src/components/Project/Board/Lists/Issue/Styles.js new file mode 100644 index 0000000..1143fbf --- /dev/null +++ b/client/src/components/Project/Board/Lists/Issue/Styles.js @@ -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; +`; diff --git a/client/src/components/Project/Board/Lists/Issue/index.jsx b/client/src/components/Project/Board/Lists/Issue/index.jsx new file mode 100644 index 0000000..905fb9c --- /dev/null +++ b/client/src/components/Project/Board/Lists/Issue/index.jsx @@ -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 ( + + {(provided, snapshot) => ( + + + {issue.title} + +
+ + +
+ + {assignees.map(user => ( + + ))} + +
+
+
+ )} +
+ ); +}; + +ProjectBoardListsIssue.propTypes = propTypes; + +export default ProjectBoardListsIssue; diff --git a/client/src/components/Project/Board/Lists/Styles.js b/client/src/components/Project/Board/Lists/Styles.js index 1a0f2af..7703c1c 100644 --- a/client/src/components/Project/Board/Lists/Styles.js +++ b/client/src/components/Project/Board/Lists/Styles.js @@ -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; -`; diff --git a/client/src/components/Project/Board/Lists/index.jsx b/client/src/components/Project/Board/Lists/index.jsx index 74480ff..4aa3882 100644 --- a/client/src/components/Project/Board/Lists/index.jsx +++ b/client/src/components/Project/Board/Lists/index.jsx @@ -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 ( - - - {`${issueStatusCopy[status]} `} - {issuesCount} - - {filteredListIssues.map(renderIssue)} - + + {provided => ( + + + {`${issueStatusCopy[status]} `} + <IssuesCount>{issuesCount}</IssuesCount> + + + {filteredListIssues.map((issue, i) => ( + + ))} + {provided.placeholder} + + + )} + ); }; - const renderIssue = issue => { - const getUserById = userId => project.users.find(user => user.id === userId); - const assignees = issue.userIds.map(getUserById); - return ( - - {issue.title} - -
- - -
- - {assignees.map(user => ( - - ))} - -
-
- ); - }; + return ( + + {Object.values(IssueStatus).map(renderList)} + + ); +}; - return {Object.values(IssueStatus).map(renderList)}; +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; diff --git a/client/src/components/Project/Board/index.jsx b/client/src/components/Project/Board/index.jsx index 00dcdb8..7d3f001 100644 --- a/client/src/components/Project/Board/index.jsx +++ b/client/src/components/Project/Board/index.jsx @@ -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 ( <>
- {currentUser && ( - - )} - + + ); }; diff --git a/client/src/components/Project/index.jsx b/client/src/components/Project/index.jsx index 138a56c..c657f6a 100644 --- a/client/src/components/Project/index.jsx +++ b/client/src/components/Project/index.jsx @@ -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 ; + if (!data) return ; if (error) return ; const { project } = data; + return ( - + ); }; diff --git a/client/src/shared/components/Modal/index.jsx b/client/src/shared/components/Modal/index.jsx index 1b86952..a3dfc6b 100644 --- a/client/src/shared/components/Modal/index.jsx +++ b/client/src/shared/components/Modal/index.jsx @@ -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; diff --git a/client/src/shared/hooks/api.js b/client/src/shared/hooks/api.js index 9a50367..26a0040 100644 --- a/client/src/shared/hooks/api.js +++ b/client/src/shared/hooks/api.js @@ -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, ]; diff --git a/client/src/shared/utils/javascript.js b/client/src/shared/utils/javascript.js new file mode 100644 index 0000000..fa358b9 --- /dev/null +++ b/client/src/shared/utils/javascript.js @@ -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; +};