diff --git a/api/src/controllers/comments.ts b/api/src/controllers/comments.ts
new file mode 100644
index 0000000..3b4087c
--- /dev/null
+++ b/api/src/controllers/comments.ts
@@ -0,0 +1,33 @@
+import express from 'express';
+
+import { Comment } from 'entities';
+import { catchErrors } from 'errors';
+import { updateEntity, deleteEntity, createEntity } from 'utils/typeorm';
+
+const router = express.Router();
+
+router.post(
+ '/comments',
+ catchErrors(async (req, res) => {
+ const comment = await createEntity(Comment, req.body);
+ res.respond({ comment });
+ }),
+);
+
+router.put(
+ '/comments/:commentId',
+ catchErrors(async (req, res) => {
+ const comment = await updateEntity(Comment, req.params.commentId, req.body);
+ res.respond({ comment });
+ }),
+);
+
+router.delete(
+ '/comments/:commentId',
+ catchErrors(async (req, res) => {
+ const comment = await deleteEntity(Comment, req.params.commentId);
+ res.respond({ comment });
+ }),
+);
+
+export default router;
diff --git a/api/src/controllers/issues.ts b/api/src/controllers/issues.ts
index bab172f..cd7d12b 100644
--- a/api/src/controllers/issues.ts
+++ b/api/src/controllers/issues.ts
@@ -10,7 +10,7 @@ router.get(
'/issues/:issueId',
catchErrors(async (req, res) => {
const issue = await findEntityOrThrow(Issue, req.params.issueId, {
- relations: ['users', 'comments'],
+ relations: ['users', 'comments', 'comments.user'],
});
res.respond({ issue });
}),
diff --git a/api/src/entities/Comment.ts b/api/src/entities/Comment.ts
index a96d193..f77f4a0 100644
--- a/api/src/entities/Comment.ts
+++ b/api/src/entities/Comment.ts
@@ -6,7 +6,6 @@ import {
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
- RelationId,
} from 'typeorm';
import is from 'utils/validation';
@@ -36,7 +35,7 @@ class Comment extends BaseEntity {
)
user: User;
- @RelationId((comment: Comment) => comment.user)
+ @Column('integer')
userId: number;
@ManyToOne(
@@ -45,6 +44,9 @@ class Comment extends BaseEntity {
{ onDelete: 'CASCADE' },
)
issue: Issue;
+
+ @Column('integer')
+ issueId: number;
}
export default Comment;
diff --git a/api/src/index.ts b/api/src/index.ts
index a38bb9a..4184769 100644
--- a/api/src/index.ts
+++ b/api/src/index.ts
@@ -7,8 +7,9 @@ import cors from 'cors';
import createDatabaseConnection from 'database/connection';
import { authenticateUser } from 'middleware/authentication';
import authenticationRoutes from 'controllers/authentication';
-import projectsRoutes from 'controllers/projects';
+import commentsRoutes from 'controllers/comments';
import issuesRoutes from 'controllers/issues';
+import projectsRoutes from 'controllers/projects';
import usersRoutes from 'controllers/users';
import { RouteNotFoundError } from 'errors';
import { errorHandler } from 'errors/errorHandler';
@@ -40,8 +41,9 @@ const initializeExpress = (): void => {
app.use('/', authenticateUser);
- app.use('/', projectsRoutes);
+ app.use('/', commentsRoutes);
app.use('/', issuesRoutes);
+ app.use('/', projectsRoutes);
app.use('/', usersRoutes);
app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl)));
diff --git a/client/src/components/Project/Board/IssueDetails/Comments/BodyForm/Styles.js b/client/src/components/Project/Board/IssueDetails/Comments/BodyForm/Styles.js
new file mode 100644
index 0000000..c25a3d6
--- /dev/null
+++ b/client/src/components/Project/Board/IssueDetails/Comments/BodyForm/Styles.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+import { Button } from 'shared/components';
+
+export const Actions = styled.div`
+ display: flex;
+ padding-top: 10px;
+`;
+
+export const FormButton = styled(Button)`
+ margin-right: 6px;
+`;
diff --git a/client/src/components/Project/Board/IssueDetails/Comments/BodyForm/index.jsx b/client/src/components/Project/Board/IssueDetails/Comments/BodyForm/index.jsx
new file mode 100644
index 0000000..d96a728
--- /dev/null
+++ b/client/src/components/Project/Board/IssueDetails/Comments/BodyForm/index.jsx
@@ -0,0 +1,54 @@
+import React, { useRef } from 'react';
+import PropTypes from 'prop-types';
+
+import { Textarea } from 'shared/components';
+import { Actions, FormButton } from './Styles';
+
+const propTypes = {
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ isWorking: PropTypes.bool.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired,
+};
+
+const ProjectBoardIssueDetailsCommentsBodyForm = ({
+ value,
+ onChange,
+ isWorking,
+ onSubmit,
+ onCancel,
+}) => {
+ const $textareaRef = useRef();
+ return (
+ <>
+
+
+ {
+ if ($textareaRef.current.value.trim()) {
+ onSubmit();
+ }
+ }}
+ >
+ Save
+
+
+ Cancel
+
+
+ >
+ );
+};
+
+ProjectBoardIssueDetailsCommentsBodyForm.propTypes = propTypes;
+
+export default ProjectBoardIssueDetailsCommentsBodyForm;
diff --git a/client/src/components/Project/Board/IssueDetails/Comments/Comment/Styles.js b/client/src/components/Project/Board/IssueDetails/Comments/Comment/Styles.js
new file mode 100644
index 0000000..bd8fa2a
--- /dev/null
+++ b/client/src/components/Project/Board/IssueDetails/Comments/Comment/Styles.js
@@ -0,0 +1,66 @@
+import styled, { css } from 'styled-components';
+
+import { color, font, mixin } from 'shared/utils/styles';
+import { Avatar } from 'shared/components';
+
+export const Comment = styled.div`
+ position: relative;
+ margin-top: 25px;
+ ${font.size(15)}
+`;
+
+export const UserAvatar = styled(Avatar)`
+ position: absolute;
+ top: 0;
+ left: 0;
+`;
+
+export const Content = styled.div`
+ padding-left: 44px;
+`;
+
+export const Username = styled.div`
+ display: inline-block;
+ padding-right: 12px;
+ padding-bottom: 10px;
+ color: ${color.textDark};
+ ${font.medium}
+`;
+
+export const CreatedAt = styled.div`
+ display: inline-block;
+ padding-bottom: 10px;
+ color: ${color.textDark};
+ ${font.size(14.5)}
+`;
+
+export const Body = styled.p`
+ padding-bottom: 10px;
+ white-space: pre-wrap;
+`;
+
+const actionLinkStyles = css`
+ display: inline-block;
+ padding: 2px 0;
+ color: ${color.textMedium};
+ ${font.size(14.5)}
+ ${mixin.clickable}
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+export const EditLink = styled.div`
+ margin-right: 12px;
+ ${actionLinkStyles}
+`;
+
+export const DeleteLink = styled.div`
+ ${actionLinkStyles}
+ &:before {
+ position: relative;
+ right: 6px;
+ content: 'ยท';
+ display: inline-block;
+ }
+`;
diff --git a/client/src/components/Project/Board/IssueDetails/Comments/Comment/index.jsx b/client/src/components/Project/Board/IssueDetails/Comments/Comment/index.jsx
new file mode 100644
index 0000000..803253e
--- /dev/null
+++ b/client/src/components/Project/Board/IssueDetails/Comments/Comment/index.jsx
@@ -0,0 +1,83 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import api from 'shared/utils/api';
+import toast from 'shared/utils/toast';
+import { formatDateTimeConversational } from 'shared/utils/dateTime';
+import { ConfirmModal } from 'shared/components';
+import BodyForm from '../BodyForm';
+import {
+ Comment,
+ UserAvatar,
+ Content,
+ Username,
+ CreatedAt,
+ Body,
+ EditLink,
+ DeleteLink,
+} from './Styles';
+
+const propTypes = {
+ comment: PropTypes.object.isRequired,
+ fetchIssue: PropTypes.func.isRequired,
+};
+
+const ProjectBoardIssueDetailsComment = ({ comment, fetchIssue }) => {
+ const [isFormOpen, setFormOpen] = useState(false);
+ const [isUpdating, setUpdating] = useState(false);
+ const [body, setBody] = useState(comment.body);
+
+ const handleCommentDelete = async () => {
+ try {
+ await api.delete(`/comments/${comment.id}`);
+ await fetchIssue();
+ } catch (error) {
+ toast.error(error);
+ }
+ };
+ const handleCommentUpdate = async () => {
+ try {
+ setUpdating(true);
+ await api.put(`/comments/${comment.id}`, { body });
+ await fetchIssue();
+ setUpdating(false);
+ setFormOpen(false);
+ } catch (error) {
+ toast.error(error);
+ }
+ };
+ return (
+
+
+
+ {comment.user.name}
+ {formatDateTimeConversational(comment.createdAt)}
+ {isFormOpen ? (
+ setFormOpen(false)}
+ />
+ ) : (
+ <>
+ {comment.body}
+ setFormOpen(true)}>Edit
+ Delete}
+ />
+ >
+ )}
+
+
+ );
+};
+
+ProjectBoardIssueDetailsComment.propTypes = propTypes;
+
+export default ProjectBoardIssueDetailsComment;
diff --git a/client/src/components/Project/Board/IssueDetails/Comments/Create/Style.js b/client/src/components/Project/Board/IssueDetails/Comments/Create/Style.js
new file mode 100644
index 0000000..b66a231
--- /dev/null
+++ b/client/src/components/Project/Board/IssueDetails/Comments/Create/Style.js
@@ -0,0 +1,55 @@
+import styled from 'styled-components';
+
+import { color, font, mixin } from 'shared/utils/styles';
+import { Avatar } from 'shared/components';
+
+export const Create = styled.div`
+ position: relative;
+ margin-top: 25px;
+ ${font.size(15)}
+`;
+
+export const UserAvatar = styled(Avatar)`
+ position: absolute;
+ top: 0;
+ left: 0;
+`;
+
+export const Right = styled.div`
+ padding-left: 44px;
+`;
+
+export const FakeTextarea = styled.div`
+ padding: 12px 20px;
+ border-radius: 4px;
+ border: 1px solid ${color.borderLightest};
+ color: ${color.textLight};
+ ${mixin.clickable}
+ &:hover {
+ border: 1px solid ${color.borderLight};
+ }
+`;
+
+export const Tip = styled.div`
+ display: flex;
+ align-items: center;
+ padding-top: 8px;
+ color: ${color.textMedium};
+ ${font.size(13)}
+ strong {
+ padding-right: 4px;
+ }
+`;
+
+export const TipLetter = styled.span`
+ position: relative;
+ top: 1px;
+ display: inline-block;
+ margin: 0 4px;
+ padding: 0 4px;
+ border-radius: 2px;
+ color: ${color.textDarkest};
+ background: ${color.backgroundMedium};
+ ${font.bold}
+ ${font.size(12)}
+`;
diff --git a/client/src/components/Project/Board/IssueDetails/Comments/Create/index.jsx b/client/src/components/Project/Board/IssueDetails/Comments/Create/index.jsx
new file mode 100644
index 0000000..a8baf4d
--- /dev/null
+++ b/client/src/components/Project/Board/IssueDetails/Comments/Create/index.jsx
@@ -0,0 +1,62 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import api from 'shared/utils/api';
+import useApi from 'shared/hooks/api';
+import toast from 'shared/utils/toast';
+import BodyForm from '../BodyForm';
+import { Create, UserAvatar, Right, FakeTextarea, Tip, TipLetter } from './Style';
+
+const propTypes = {
+ issueId: PropTypes.number.isRequired,
+ fetchIssue: PropTypes.func.isRequired,
+};
+
+const ProjectBoardIssueDetailsCommentsCreate = ({ issueId, fetchIssue }) => {
+ const [isFormOpen, setFormOpen] = useState(false);
+ const [isCreating, setCreating] = useState(false);
+ const [body, setBody] = useState('');
+
+ const [{ data: currentUserData }] = useApi.get('/currentUser');
+ const currentUser = currentUserData && currentUserData.currentUser;
+
+ const handleCommentCreate = async () => {
+ try {
+ setCreating(true);
+ await api.post(`/comments`, { body, issueId, userId: currentUser.id });
+ await fetchIssue();
+ setFormOpen(false);
+ setCreating(false);
+ setBody('');
+ } catch (error) {
+ toast.error(error);
+ }
+ };
+ return (
+
+ {currentUser && }
+
+ {isFormOpen ? (
+ setFormOpen(false)}
+ />
+ ) : (
+ <>
+ setFormOpen(true)}>Add a comment...
+
+ Pro tip:pressMto comment
+
+ >
+ )}
+
+
+ );
+};
+
+ProjectBoardIssueDetailsCommentsCreate.propTypes = propTypes;
+
+export default ProjectBoardIssueDetailsCommentsCreate;
diff --git a/client/src/components/Project/Board/IssueDetails/Comments/Styles.js b/client/src/components/Project/Board/IssueDetails/Comments/Styles.js
new file mode 100644
index 0000000..a9f2b95
--- /dev/null
+++ b/client/src/components/Project/Board/IssueDetails/Comments/Styles.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+import { font } from 'shared/utils/styles';
+
+export const Comments = styled.div`
+ padding-top: 40px;
+`;
+
+export const Title = styled.div`
+ ${font.medium}
+ ${font.size(15)}
+`;
diff --git a/client/src/components/Project/Board/IssueDetails/Comments/index.jsx b/client/src/components/Project/Board/IssueDetails/Comments/index.jsx
new file mode 100644
index 0000000..9e89a1f
--- /dev/null
+++ b/client/src/components/Project/Board/IssueDetails/Comments/index.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Create from './Create';
+import Comment from './Comment';
+import { Comments, Title } from './Styles';
+
+const propTypes = {
+ issue: PropTypes.array.isRequired,
+ fetchIssue: PropTypes.func.isRequired,
+};
+
+const ProjectBoardIssueDetailsComments = ({ issue, fetchIssue }) => (
+
+ Comments
+
+ {sortByNewestFirst(issue.comments).map(comment => (
+
+ ))}
+
+);
+
+const sortByNewestFirst = items => items.sort((a, b) => -a.createdAt.localeCompare(b.createdAt));
+
+ProjectBoardIssueDetailsComments.propTypes = propTypes;
+
+export default ProjectBoardIssueDetailsComments;
diff --git a/client/src/components/Project/Board/IssueDetails/Description/Styles.js b/client/src/components/Project/Board/IssueDetails/Description/Styles.js
index 38c4d9c..bf668a3 100644
--- a/client/src/components/Project/Board/IssueDetails/Description/Styles.js
+++ b/client/src/components/Project/Board/IssueDetails/Description/Styles.js
@@ -2,7 +2,7 @@ import styled from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
-export const DescriptionLabel = styled.div`
+export const Title = styled.div`
padding: 20px 0 6px;
${font.size(15)}
${font.medium}
diff --git a/client/src/components/Project/Board/IssueDetails/Description/index.jsx b/client/src/components/Project/Board/IssueDetails/Description/index.jsx
index 5388681..9512a51 100644
--- a/client/src/components/Project/Board/IssueDetails/Description/index.jsx
+++ b/client/src/components/Project/Board/IssueDetails/Description/index.jsx
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { getTextContentsFromHtmlString } from 'shared/utils/html';
import { TextEditor, TextEditedContent, Button } from 'shared/components';
-import { DescriptionLabel, EmptyLabel, Actions } from './Styles';
+import { Title, EmptyLabel, Actions } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
@@ -49,7 +49,7 @@ const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
);
return (
<>
- Description
+
Description
{isPresenting ? renderPresentingMode() : renderEditingMode()}
>
);
diff --git a/client/src/components/Project/Board/IssueDetails/Priority/Styles.js b/client/src/components/Project/Board/IssueDetails/Priority/Styles.js
new file mode 100644
index 0000000..7b814e6
--- /dev/null
+++ b/client/src/components/Project/Board/IssueDetails/Priority/Styles.js
@@ -0,0 +1,22 @@
+import styled from 'styled-components';
+
+import { color, font, mixin } from 'shared/utils/styles';
+
+export const Priority = styled.div`
+ display: flex;
+ align-items: center;
+`;
+
+export const Option = styled.div`
+ padding: 8px 12px;
+ ${mixin.clickable}
+ &.jira-select-option-is-active {
+ background: ${color.backgroundLightPrimary};
+ }
+`;
+
+export const Label = styled.div`
+ text-transform: capitalize;
+ padding: 0 3px 0 8px;
+ ${font.size(14.5)}
+`;
diff --git a/client/src/components/Project/Board/IssueDetails/Priority/index.jsx b/client/src/components/Project/Board/IssueDetails/Priority/index.jsx
new file mode 100644
index 0000000..f39431d
--- /dev/null
+++ b/client/src/components/Project/Board/IssueDetails/Priority/index.jsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { invert } from 'lodash';
+
+import { IssuePriority } from 'shared/constants/issues';
+import { Select, IssuePriorityIcon } from 'shared/components';
+import { Priority, Option, Label } from './Styles';
+import { SectionTitle } from '../Styles';
+
+const IssuePriorityCopy = invert(IssuePriority);
+
+const propTypes = {
+ issue: PropTypes.object.isRequired,
+ updateIssue: PropTypes.func.isRequired,
+};
+
+const ProjectBoardIssueDetailsPriority = ({ issue, updateIssue }) => {
+ const renderPriorityItem = priority => (
+
+
+
+
+ );
+ return (
+ <>
+ Priority
+