Implemented issue comments
This commit is contained in:
33
api/src/controllers/comments.ts
Normal file
33
api/src/controllers/comments.ts
Normal file
@@ -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;
|
||||||
@@ -10,7 +10,7 @@ router.get(
|
|||||||
'/issues/:issueId',
|
'/issues/:issueId',
|
||||||
catchErrors(async (req, res) => {
|
catchErrors(async (req, res) => {
|
||||||
const issue = await findEntityOrThrow(Issue, req.params.issueId, {
|
const issue = await findEntityOrThrow(Issue, req.params.issueId, {
|
||||||
relations: ['users', 'comments'],
|
relations: ['users', 'comments', 'comments.user'],
|
||||||
});
|
});
|
||||||
res.respond({ issue });
|
res.respond({ issue });
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
RelationId,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
import is from 'utils/validation';
|
import is from 'utils/validation';
|
||||||
@@ -36,7 +35,7 @@ class Comment extends BaseEntity {
|
|||||||
)
|
)
|
||||||
user: User;
|
user: User;
|
||||||
|
|
||||||
@RelationId((comment: Comment) => comment.user)
|
@Column('integer')
|
||||||
userId: number;
|
userId: number;
|
||||||
|
|
||||||
@ManyToOne(
|
@ManyToOne(
|
||||||
@@ -45,6 +44,9 @@ class Comment extends BaseEntity {
|
|||||||
{ onDelete: 'CASCADE' },
|
{ onDelete: 'CASCADE' },
|
||||||
)
|
)
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
issueId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Comment;
|
export default Comment;
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import cors from 'cors';
|
|||||||
import createDatabaseConnection from 'database/connection';
|
import createDatabaseConnection from 'database/connection';
|
||||||
import { authenticateUser } from 'middleware/authentication';
|
import { authenticateUser } from 'middleware/authentication';
|
||||||
import authenticationRoutes from 'controllers/authentication';
|
import authenticationRoutes from 'controllers/authentication';
|
||||||
import projectsRoutes from 'controllers/projects';
|
import commentsRoutes from 'controllers/comments';
|
||||||
import issuesRoutes from 'controllers/issues';
|
import issuesRoutes from 'controllers/issues';
|
||||||
|
import projectsRoutes from 'controllers/projects';
|
||||||
import usersRoutes from 'controllers/users';
|
import usersRoutes from 'controllers/users';
|
||||||
import { RouteNotFoundError } from 'errors';
|
import { RouteNotFoundError } from 'errors';
|
||||||
import { errorHandler } from 'errors/errorHandler';
|
import { errorHandler } from 'errors/errorHandler';
|
||||||
@@ -40,8 +41,9 @@ const initializeExpress = (): void => {
|
|||||||
|
|
||||||
app.use('/', authenticateUser);
|
app.use('/', authenticateUser);
|
||||||
|
|
||||||
app.use('/', projectsRoutes);
|
app.use('/', commentsRoutes);
|
||||||
app.use('/', issuesRoutes);
|
app.use('/', issuesRoutes);
|
||||||
|
app.use('/', projectsRoutes);
|
||||||
app.use('/', usersRoutes);
|
app.use('/', usersRoutes);
|
||||||
|
|
||||||
app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl)));
|
app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl)));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
`;
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Textarea
|
||||||
|
autoFocus
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
ref={$textareaRef}
|
||||||
|
/>
|
||||||
|
<Actions>
|
||||||
|
<FormButton
|
||||||
|
color="primary"
|
||||||
|
working={isWorking}
|
||||||
|
onClick={() => {
|
||||||
|
if ($textareaRef.current.value.trim()) {
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</FormButton>
|
||||||
|
<FormButton color="empty" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</FormButton>
|
||||||
|
</Actions>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProjectBoardIssueDetailsCommentsBodyForm.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default ProjectBoardIssueDetailsCommentsBodyForm;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -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>
|
||||||
|
<UserAvatar name={comment.user.name} avatarUrl={comment.user.avatarUrl} />
|
||||||
|
<Content>
|
||||||
|
<Username>{comment.user.name}</Username>
|
||||||
|
<CreatedAt>{formatDateTimeConversational(comment.createdAt)}</CreatedAt>
|
||||||
|
{isFormOpen ? (
|
||||||
|
<BodyForm
|
||||||
|
value={body}
|
||||||
|
onChange={setBody}
|
||||||
|
isWorking={isUpdating}
|
||||||
|
onSubmit={handleCommentUpdate}
|
||||||
|
onCancel={() => setFormOpen(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Body>{comment.body}</Body>
|
||||||
|
<EditLink onClick={() => setFormOpen(true)}>Edit</EditLink>
|
||||||
|
<ConfirmModal
|
||||||
|
title="Are you sure you want to delete this comment?"
|
||||||
|
message="Once you delete, it's gone for good."
|
||||||
|
confirmText="Delete Comment"
|
||||||
|
onConfirm={handleCommentDelete}
|
||||||
|
renderLink={modal => <DeleteLink onClick={modal.open}>Delete</DeleteLink>}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Content>
|
||||||
|
</Comment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProjectBoardIssueDetailsComment.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default ProjectBoardIssueDetailsComment;
|
||||||
@@ -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)}
|
||||||
|
`;
|
||||||
@@ -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 (
|
||||||
|
<Create>
|
||||||
|
{currentUser && <UserAvatar name={currentUser.name} avatarUrl={currentUser.avatarUrl} />}
|
||||||
|
<Right>
|
||||||
|
{isFormOpen ? (
|
||||||
|
<BodyForm
|
||||||
|
value={body}
|
||||||
|
onChange={setBody}
|
||||||
|
isWorking={isCreating}
|
||||||
|
onSubmit={handleCommentCreate}
|
||||||
|
onCancel={() => setFormOpen(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FakeTextarea onClick={() => setFormOpen(true)}>Add a comment...</FakeTextarea>
|
||||||
|
<Tip>
|
||||||
|
<strong>Pro tip:</strong>press<TipLetter>M</TipLetter>to comment
|
||||||
|
</Tip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Right>
|
||||||
|
</Create>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProjectBoardIssueDetailsCommentsCreate.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default ProjectBoardIssueDetailsCommentsCreate;
|
||||||
@@ -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)}
|
||||||
|
`;
|
||||||
@@ -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>
|
||||||
|
<Title>Comments</Title>
|
||||||
|
<Create issueId={issue.id} fetchIssue={fetchIssue} />
|
||||||
|
{sortByNewestFirst(issue.comments).map(comment => (
|
||||||
|
<Comment key={comment.id} comment={comment} fetchIssue={fetchIssue} />
|
||||||
|
))}
|
||||||
|
</Comments>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortByNewestFirst = items => items.sort((a, b) => -a.createdAt.localeCompare(b.createdAt));
|
||||||
|
|
||||||
|
ProjectBoardIssueDetailsComments.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default ProjectBoardIssueDetailsComments;
|
||||||
@@ -2,7 +2,7 @@ import styled from 'styled-components';
|
|||||||
|
|
||||||
import { color, font, mixin } from 'shared/utils/styles';
|
import { color, font, mixin } from 'shared/utils/styles';
|
||||||
|
|
||||||
export const DescriptionLabel = styled.div`
|
export const Title = styled.div`
|
||||||
padding: 20px 0 6px;
|
padding: 20px 0 6px;
|
||||||
${font.size(15)}
|
${font.size(15)}
|
||||||
${font.medium}
|
${font.medium}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import { getTextContentsFromHtmlString } from 'shared/utils/html';
|
import { getTextContentsFromHtmlString } from 'shared/utils/html';
|
||||||
import { TextEditor, TextEditedContent, Button } from 'shared/components';
|
import { TextEditor, TextEditedContent, Button } from 'shared/components';
|
||||||
import { DescriptionLabel, EmptyLabel, Actions } from './Styles';
|
import { Title, EmptyLabel, Actions } from './Styles';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
issue: PropTypes.object.isRequired,
|
issue: PropTypes.object.isRequired,
|
||||||
@@ -49,7 +49,7 @@ const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DescriptionLabel>Description</DescriptionLabel>
|
<Title>Description</Title>
|
||||||
{isPresenting ? renderPresentingMode() : renderEditingMode()}
|
{isPresenting ? renderPresentingMode() : renderEditingMode()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
`;
|
||||||
@@ -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 => (
|
||||||
|
<Priority color={priority}>
|
||||||
|
<IssuePriorityIcon priority={priority} />
|
||||||
|
<Label>{IssuePriorityCopy[priority].toLowerCase()}</Label>
|
||||||
|
</Priority>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionTitle>Priority</SectionTitle>
|
||||||
|
<Select
|
||||||
|
value={issue.priority}
|
||||||
|
options={Object.values(IssuePriority).map(priority => ({
|
||||||
|
value: priority,
|
||||||
|
label: IssuePriorityCopy[priority],
|
||||||
|
}))}
|
||||||
|
onChange={priority => updateIssue({ priority })}
|
||||||
|
renderValue={({ value }) => renderPriorityItem(value)}
|
||||||
|
renderOption={({ value, ...optionProps }) => (
|
||||||
|
<Option {...optionProps}>{renderPriorityItem(value)}</Option>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProjectBoardIssueDetailsPriority.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default ProjectBoardIssueDetailsPriority;
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import styled, { css } from 'styled-components';
|
|
||||||
|
|
||||||
import {
|
|
||||||
color,
|
|
||||||
issueStatusColors,
|
|
||||||
issueStatusBackgroundColors,
|
|
||||||
font,
|
|
||||||
mixin,
|
|
||||||
} from 'shared/utils/styles';
|
|
||||||
import { Icon } from 'shared/components';
|
|
||||||
|
|
||||||
export const SectionTitle = styled.div`
|
|
||||||
margin: 24px 0 5px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: ${color.textMedium};
|
|
||||||
${font.size(12.5)}
|
|
||||||
${font.bold}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const User = styled.div`
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
${mixin.clickable}
|
|
||||||
${props =>
|
|
||||||
props.isSelectValue &&
|
|
||||||
css`
|
|
||||||
margin: 0 10px ${props.withBottomMargin ? 5 : 0}px 0;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: ${color.backgroundLight};
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UserName = styled.div`
|
|
||||||
padding: 0 3px 0 8px;
|
|
||||||
${font.size(14.5)}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const StatusOption = styled.div`
|
|
||||||
padding: 8px 16px;
|
|
||||||
&.jira-select-option-is-active {
|
|
||||||
background: ${color.backgroundLightPrimary};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Status = styled.div`
|
|
||||||
text-transform: uppercase;
|
|
||||||
${props => mixin.tag(issueStatusBackgroundColors[props.color], issueStatusColors[props.color])}
|
|
||||||
${props => props.isLarge && `padding: 9px 14px 8px;`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const UserOptionCont = styled.div`
|
|
||||||
padding: 8px 12px 5px;
|
|
||||||
${mixin.clickable}
|
|
||||||
&.jira-select-option-is-active {
|
|
||||||
background: ${color.backgroundLightPrimary};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Priority = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const PriorityOption = styled.div`
|
|
||||||
padding: 8px 12px;
|
|
||||||
${mixin.clickable}
|
|
||||||
&.jira-select-option-is-active {
|
|
||||||
background: ${color.backgroundLightPrimary};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const PriorityLabel = styled.div`
|
|
||||||
text-transform: capitalize;
|
|
||||||
padding: 0 3px 0 8px;
|
|
||||||
${font.size(14.5)}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Tracking = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: 2px;
|
|
||||||
${mixin.clickable};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const TrackingIcon = styled(Icon)`
|
|
||||||
color: ${color.textMedium};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const TrackingRight = styled.div`
|
|
||||||
width: 90%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const TrackingBarCont = styled.div`
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: ${color.backgroundLight};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const TrackingBar = styled.div`
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: ${color.primary};
|
|
||||||
transition: all 0.1s;
|
|
||||||
width: ${props => props.width}%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const TrackingValues = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-top: 3px;
|
|
||||||
${font.size(14.5)};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const TrackingModalContents = styled.div`
|
|
||||||
padding: 20px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const TrackingModalTitle = styled.div`
|
|
||||||
padding-bottom: 14px;
|
|
||||||
${font.medium}
|
|
||||||
${font.size(20)}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Inputs = styled.div`
|
|
||||||
display: flex;
|
|
||||||
margin: 20px -5px 30px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const InputCont = styled.div`
|
|
||||||
margin: 0 5px;
|
|
||||||
width: 50%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const InputLabel = styled.div`
|
|
||||||
padding-bottom: 5px;
|
|
||||||
color: ${color.textMedium};
|
|
||||||
${font.medium};
|
|
||||||
${font.size(13)};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Actions = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
`;
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { invert, isNil } from 'lodash';
|
|
||||||
|
|
||||||
import { IssueStatus, IssueStatusCopy, IssuePriority } from 'shared/constants/issues';
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
Select,
|
|
||||||
Icon,
|
|
||||||
InputDebounced,
|
|
||||||
IssuePriorityIcon,
|
|
||||||
Modal,
|
|
||||||
Button,
|
|
||||||
} from 'shared/components';
|
|
||||||
import {
|
|
||||||
SectionTitle,
|
|
||||||
User,
|
|
||||||
UserName,
|
|
||||||
Status,
|
|
||||||
StatusOption,
|
|
||||||
UserOptionCont,
|
|
||||||
Priority,
|
|
||||||
PriorityOption,
|
|
||||||
PriorityLabel,
|
|
||||||
Tracking,
|
|
||||||
TrackingIcon,
|
|
||||||
TrackingRight,
|
|
||||||
TrackingBarCont,
|
|
||||||
TrackingBar,
|
|
||||||
TrackingValues,
|
|
||||||
TrackingModalContents,
|
|
||||||
TrackingModalTitle,
|
|
||||||
Inputs,
|
|
||||||
InputCont,
|
|
||||||
InputLabel,
|
|
||||||
Actions,
|
|
||||||
} from './Styles';
|
|
||||||
|
|
||||||
const IssuePriorityCopy = invert(IssuePriority);
|
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
issue: PropTypes.object.isRequired,
|
|
||||||
updateIssue: PropTypes.func.isRequired,
|
|
||||||
projectUsers: PropTypes.array.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProjectBoardIssueDetailsRightActions = ({ issue, updateIssue, projectUsers }) => {
|
|
||||||
const getUserById = userId => projectUsers.find(user => user.id === parseInt(userId));
|
|
||||||
|
|
||||||
const userOptions = projectUsers.map(user => ({ value: user.id, label: user.name }));
|
|
||||||
|
|
||||||
const renderHourInput = fieldName => (
|
|
||||||
<InputDebounced
|
|
||||||
placeholder="Number"
|
|
||||||
filter={/^\d{0,6}$/}
|
|
||||||
value={isNil(issue[fieldName]) ? '' : issue[fieldName]}
|
|
||||||
onChange={stringValue => {
|
|
||||||
const value = stringValue.trim() ? parseInt(stringValue) : null;
|
|
||||||
updateIssue({ [fieldName]: value });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderStatus = () => (
|
|
||||||
<>
|
|
||||||
<SectionTitle>Status</SectionTitle>
|
|
||||||
<Select
|
|
||||||
value={issue.status}
|
|
||||||
options={Object.values(IssueStatus).map(status => ({
|
|
||||||
value: status,
|
|
||||||
label: IssueStatusCopy[status],
|
|
||||||
}))}
|
|
||||||
onChange={status => updateIssue({ status })}
|
|
||||||
renderValue={({ value: status }) => (
|
|
||||||
<Status isLarge color={status}>
|
|
||||||
{IssueStatusCopy[status]}
|
|
||||||
</Status>
|
|
||||||
)}
|
|
||||||
renderOption={({ value: status, ...optionProps }) => (
|
|
||||||
<StatusOption {...optionProps}>
|
|
||||||
<Status color={status}>{IssueStatusCopy[status]}</Status>
|
|
||||||
</StatusOption>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderUserValue = (user, withBottomMargin, removeOptionValue) => (
|
|
||||||
<User
|
|
||||||
key={user.id}
|
|
||||||
isSelectValue
|
|
||||||
withBottomMargin={withBottomMargin}
|
|
||||||
onClick={() => removeOptionValue && removeOptionValue(user.id)}
|
|
||||||
>
|
|
||||||
<Avatar avatarUrl={user.avatarUrl} name={user.name} size={24} />
|
|
||||||
<UserName>{user.name}</UserName>
|
|
||||||
{removeOptionValue && <Icon type="close" top={1} />}
|
|
||||||
</User>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderUserOption = user => (
|
|
||||||
<User key={user.id}>
|
|
||||||
<Avatar avatarUrl={user.avatarUrl} name={user.name} size={32} />
|
|
||||||
<UserName>{user.name}</UserName>
|
|
||||||
</User>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderAssignees = () => (
|
|
||||||
<>
|
|
||||||
<SectionTitle>Assignees</SectionTitle>
|
|
||||||
<Select
|
|
||||||
isMulti
|
|
||||||
placeholder="Unassigned"
|
|
||||||
value={issue.userIds}
|
|
||||||
options={userOptions}
|
|
||||||
onChange={userIds => {
|
|
||||||
updateIssue({ userIds, users: userIds.map(getUserById) });
|
|
||||||
}}
|
|
||||||
renderValue={({ value, removeOptionValue }) =>
|
|
||||||
renderUserValue(getUserById(value), true, removeOptionValue)
|
|
||||||
}
|
|
||||||
renderOption={({ value, ...optionProps }) => (
|
|
||||||
<UserOptionCont {...optionProps}>{renderUserOption(getUserById(value))}</UserOptionCont>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderReporter = () => (
|
|
||||||
<>
|
|
||||||
<SectionTitle>Reporter</SectionTitle>
|
|
||||||
<Select
|
|
||||||
value={issue.reporterId}
|
|
||||||
options={userOptions}
|
|
||||||
onChange={userId => updateIssue({ reporterId: userId })}
|
|
||||||
renderValue={({ value }) => renderUserValue(getUserById(value), false)}
|
|
||||||
renderOption={({ value, ...optionProps }) => (
|
|
||||||
<UserOptionCont {...optionProps}>{renderUserOption(getUserById(value))}</UserOptionCont>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderEstimate = () => (
|
|
||||||
<>
|
|
||||||
<SectionTitle>Original Estimate (hours)</SectionTitle>
|
|
||||||
{renderHourInput('estimate')}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderPriorityItem = priority => (
|
|
||||||
<Priority color={priority}>
|
|
||||||
<IssuePriorityIcon priority={priority} />
|
|
||||||
<PriorityLabel>{IssuePriorityCopy[priority].toLowerCase()}</PriorityLabel>
|
|
||||||
</Priority>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderPriority = () => (
|
|
||||||
<>
|
|
||||||
<SectionTitle>Priority</SectionTitle>
|
|
||||||
<Select
|
|
||||||
value={issue.priority}
|
|
||||||
options={Object.values(IssuePriority).map(priority => ({
|
|
||||||
value: priority,
|
|
||||||
label: IssuePriorityCopy[priority],
|
|
||||||
}))}
|
|
||||||
onChange={priority => updateIssue({ priority })}
|
|
||||||
renderValue={({ value }) => renderPriorityItem(value)}
|
|
||||||
renderOption={({ value, ...optionProps }) => (
|
|
||||||
<PriorityOption {...optionProps}>{renderPriorityItem(value)}</PriorityOption>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const calculateTrackingBarWidth = () => {
|
|
||||||
const { timeSpent, timeRemaining, estimate } = issue;
|
|
||||||
|
|
||||||
if (!timeSpent) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (isNil(timeRemaining) && isNil(estimate)) {
|
|
||||||
return 100;
|
|
||||||
}
|
|
||||||
if (!isNil(timeRemaining)) {
|
|
||||||
return (timeSpent / (timeSpent + timeRemaining)) * 100;
|
|
||||||
}
|
|
||||||
if (!isNil(estimate)) {
|
|
||||||
return Math.min((timeSpent / estimate) * 100, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderRemainingOrEstimate = () => {
|
|
||||||
const { timeRemaining, estimate } = issue;
|
|
||||||
|
|
||||||
if (isNil(timeRemaining) && isNil(estimate)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!isNil(timeRemaining)) {
|
|
||||||
return <div>{`${timeRemaining}h remaining`}</div>;
|
|
||||||
}
|
|
||||||
if (!isNil(estimate)) {
|
|
||||||
return <div>{`${estimate}h estimated`}</div>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTrackingPreview = (onClick = () => {}) => (
|
|
||||||
<Tracking onClick={onClick}>
|
|
||||||
<TrackingIcon type="stopwatch" size={26} top={-1} />
|
|
||||||
<TrackingRight>
|
|
||||||
<TrackingBarCont>
|
|
||||||
<TrackingBar width={calculateTrackingBarWidth()} />
|
|
||||||
</TrackingBarCont>
|
|
||||||
<TrackingValues>
|
|
||||||
<div>{issue.timeSpent ? `${issue.timeSpent}h logged` : 'No time logged'}</div>
|
|
||||||
{renderRemainingOrEstimate()}
|
|
||||||
</TrackingValues>
|
|
||||||
</TrackingRight>
|
|
||||||
</Tracking>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTracking = () => (
|
|
||||||
<>
|
|
||||||
<SectionTitle>Time Tracking</SectionTitle>
|
|
||||||
<Modal
|
|
||||||
width={400}
|
|
||||||
renderLink={modal => renderTrackingPreview(modal.open)}
|
|
||||||
renderContent={modal => (
|
|
||||||
<TrackingModalContents>
|
|
||||||
<TrackingModalTitle>Time tracking</TrackingModalTitle>
|
|
||||||
{renderTrackingPreview()}
|
|
||||||
<Inputs>
|
|
||||||
<InputCont>
|
|
||||||
<InputLabel>Time spent (hours)</InputLabel>
|
|
||||||
{renderHourInput('timeSpent')}
|
|
||||||
</InputCont>
|
|
||||||
<InputCont>
|
|
||||||
<InputLabel>Time remaining (hours)</InputLabel>
|
|
||||||
{renderHourInput('timeRemaining')}
|
|
||||||
</InputCont>
|
|
||||||
</Inputs>
|
|
||||||
<Actions>
|
|
||||||
<Button color="primary" onClick={modal.close}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</Actions>
|
|
||||||
</TrackingModalContents>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{renderStatus()}
|
|
||||||
{renderAssignees()}
|
|
||||||
{renderReporter()}
|
|
||||||
{renderEstimate()}
|
|
||||||
{renderPriority()}
|
|
||||||
{renderTracking()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ProjectBoardIssueDetailsRightActions.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default ProjectBoardIssueDetailsRightActions;
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import { color, issueStatusColors, issueStatusBackgroundColors, mixin } from 'shared/utils/styles';
|
||||||
|
|
||||||
|
export const Status = styled.div`
|
||||||
|
text-transform: uppercase;
|
||||||
|
${props => mixin.tag(issueStatusBackgroundColors[props.color], issueStatusColors[props.color])}
|
||||||
|
${props => props.isLarge && `padding: 9px 14px 8px;`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Option = styled.div`
|
||||||
|
padding: 8px 16px;
|
||||||
|
&.jira-select-option-is-active {
|
||||||
|
background: ${color.backgroundLightPrimary};
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { IssueStatus, IssueStatusCopy } from 'shared/constants/issues';
|
||||||
|
import { Select } from 'shared/components';
|
||||||
|
import { Status, Option } from './Styles';
|
||||||
|
import { SectionTitle } from '../Styles';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
issue: PropTypes.object.isRequired,
|
||||||
|
updateIssue: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectBoardIssueDetailsStatus = ({ issue, updateIssue }) => (
|
||||||
|
<>
|
||||||
|
<SectionTitle>Status</SectionTitle>
|
||||||
|
<Select
|
||||||
|
value={issue.status}
|
||||||
|
options={Object.values(IssueStatus).map(status => ({
|
||||||
|
value: status,
|
||||||
|
label: IssueStatusCopy[status],
|
||||||
|
}))}
|
||||||
|
onChange={status => updateIssue({ status })}
|
||||||
|
renderValue={({ value: status }) => (
|
||||||
|
<Status isLarge color={status}>
|
||||||
|
{IssueStatusCopy[status]}
|
||||||
|
</Status>
|
||||||
|
)}
|
||||||
|
renderOption={({ value: status, ...optionProps }) => (
|
||||||
|
<Option {...optionProps}>
|
||||||
|
<Status color={status}>{IssueStatusCopy[status]}</Status>
|
||||||
|
</Option>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
ProjectBoardIssueDetailsStatus.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default ProjectBoardIssueDetailsStatus;
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import { color, font } from 'shared/utils/styles';
|
||||||
|
|
||||||
export const Content = styled.div`
|
export const Content = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0 30px 30px;
|
padding: 0 30px 60px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Left = styled.div`
|
export const Left = styled.div`
|
||||||
@@ -14,3 +16,11 @@ export const Right = styled.div`
|
|||||||
width: 35%;
|
width: 35%;
|
||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const SectionTitle = styled.div`
|
||||||
|
margin: 24px 0 5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: ${color.textMedium};
|
||||||
|
${font.size(12.5)}
|
||||||
|
${font.bold}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ export const TitleTextarea = styled(Textarea)`
|
|||||||
height: 44px;
|
height: 44px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
textarea {
|
textarea {
|
||||||
background: #fff;
|
padding: 7px 7px 8px;
|
||||||
|
line-height: 1.28;
|
||||||
border: none;
|
border: none;
|
||||||
resize: none;
|
resize: none;
|
||||||
line-height: 1.28;
|
background: #fff;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
box-shadow: 0 0 0 1px transparent;
|
box-shadow: 0 0 0 1px transparent;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const ProjectBoardIssueDetailsTopActions = ({ issue, updateIssue, fetchProject,
|
|||||||
const renderDeleteIcon = () => (
|
const renderDeleteIcon = () => (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title="Are you sure you want to delete this issue?"
|
title="Are you sure you want to delete this issue?"
|
||||||
message="This action is permanent and can not be reversed."
|
message="Once you delete, it's gone for good."
|
||||||
confirmText="Delete issue"
|
confirmText="Delete issue"
|
||||||
onConfirm={handleIssueDelete}
|
onConfirm={handleIssueDelete}
|
||||||
renderLink={modal => <Button icon="trash" iconSize={19} color="empty" onClick={modal.open} />}
|
renderLink={modal => <Button icon="trash" iconSize={19} color="empty" onClick={modal.open} />}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import { color, font, mixin } from 'shared/utils/styles';
|
||||||
|
import { Icon } from 'shared/components';
|
||||||
|
|
||||||
|
export const Tracking = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 2px;
|
||||||
|
${mixin.clickable};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const WatchIcon = styled(Icon)`
|
||||||
|
color: ${color.textMedium};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Right = styled.div`
|
||||||
|
width: 90%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BarCont = styled.div`
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: ${color.backgroundLight};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Bar = styled.div`
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: ${color.primary};
|
||||||
|
transition: all 0.1s;
|
||||||
|
width: ${props => props.width}%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Values = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 3px;
|
||||||
|
${font.size(14.5)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ModalContents = styled.div`
|
||||||
|
padding: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ModalTitle = styled.div`
|
||||||
|
padding-bottom: 14px;
|
||||||
|
${font.medium}
|
||||||
|
${font.size(20)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Inputs = styled.div`
|
||||||
|
display: flex;
|
||||||
|
margin: 20px -5px 30px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const InputCont = styled.div`
|
||||||
|
margin: 0 5px;
|
||||||
|
width: 50%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const InputLabel = styled.div`
|
||||||
|
padding-bottom: 5px;
|
||||||
|
color: ${color.textMedium};
|
||||||
|
${font.medium};
|
||||||
|
${font.size(13)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Actions = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
`;
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { isNil } from 'lodash';
|
||||||
|
|
||||||
|
import { InputDebounced, Modal, Button } from 'shared/components';
|
||||||
|
import {
|
||||||
|
Tracking,
|
||||||
|
WatchIcon,
|
||||||
|
Right,
|
||||||
|
BarCont,
|
||||||
|
Bar,
|
||||||
|
Values,
|
||||||
|
ModalContents,
|
||||||
|
ModalTitle,
|
||||||
|
Inputs,
|
||||||
|
InputCont,
|
||||||
|
InputLabel,
|
||||||
|
Actions,
|
||||||
|
} from './Styles';
|
||||||
|
import { SectionTitle } from '../Styles';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
issue: PropTypes.object.isRequired,
|
||||||
|
updateIssue: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectBoardIssueDetailsTracking = ({ issue, updateIssue }) => {
|
||||||
|
const renderHourInput = fieldName => (
|
||||||
|
<InputDebounced
|
||||||
|
placeholder="Number"
|
||||||
|
filter={/^\d{0,6}$/}
|
||||||
|
value={isNil(issue[fieldName]) ? '' : issue[fieldName]}
|
||||||
|
onChange={stringValue => {
|
||||||
|
const value = stringValue.trim() ? parseInt(stringValue) : null;
|
||||||
|
updateIssue({ [fieldName]: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const calculateTrackingBarWidth = () => {
|
||||||
|
const { timeSpent, timeRemaining, estimate } = issue;
|
||||||
|
|
||||||
|
if (!timeSpent) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (isNil(timeRemaining) && isNil(estimate)) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
if (!isNil(timeRemaining)) {
|
||||||
|
return (timeSpent / (timeSpent + timeRemaining)) * 100;
|
||||||
|
}
|
||||||
|
if (!isNil(estimate)) {
|
||||||
|
return Math.min((timeSpent / estimate) * 100, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRemainingOrEstimate = () => {
|
||||||
|
const { timeRemaining, estimate } = issue;
|
||||||
|
|
||||||
|
if (isNil(timeRemaining) && isNil(estimate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isNil(timeRemaining)) {
|
||||||
|
return <div>{`${timeRemaining}h remaining`}</div>;
|
||||||
|
}
|
||||||
|
if (!isNil(estimate)) {
|
||||||
|
return <div>{`${estimate}h estimated`}</div>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTrackingPreview = (onClick = () => {}) => (
|
||||||
|
<Tracking onClick={onClick}>
|
||||||
|
<WatchIcon type="stopwatch" size={26} top={-1} />
|
||||||
|
<Right>
|
||||||
|
<BarCont>
|
||||||
|
<Bar width={calculateTrackingBarWidth()} />
|
||||||
|
</BarCont>
|
||||||
|
<Values>
|
||||||
|
<div>{issue.timeSpent ? `${issue.timeSpent}h logged` : 'No time logged'}</div>
|
||||||
|
{renderRemainingOrEstimate()}
|
||||||
|
</Values>
|
||||||
|
</Right>
|
||||||
|
</Tracking>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEstimate = () => (
|
||||||
|
<>
|
||||||
|
<SectionTitle>Original Estimate (hours)</SectionTitle>
|
||||||
|
{renderHourInput('estimate')}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTracking = () => (
|
||||||
|
<>
|
||||||
|
<SectionTitle>Time Tracking</SectionTitle>
|
||||||
|
<Modal
|
||||||
|
width={400}
|
||||||
|
renderLink={modal => renderTrackingPreview(modal.open)}
|
||||||
|
renderContent={modal => (
|
||||||
|
<ModalContents>
|
||||||
|
<ModalTitle>Time tracking</ModalTitle>
|
||||||
|
{renderTrackingPreview()}
|
||||||
|
<Inputs>
|
||||||
|
<InputCont>
|
||||||
|
<InputLabel>Time spent (hours)</InputLabel>
|
||||||
|
{renderHourInput('timeSpent')}
|
||||||
|
</InputCont>
|
||||||
|
<InputCont>
|
||||||
|
<InputLabel>Time remaining (hours)</InputLabel>
|
||||||
|
{renderHourInput('timeRemaining')}
|
||||||
|
</InputCont>
|
||||||
|
</Inputs>
|
||||||
|
<Actions>
|
||||||
|
<Button color="primary" onClick={modal.close}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</Actions>
|
||||||
|
</ModalContents>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderEstimate()}
|
||||||
|
{renderTracking()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProjectBoardIssueDetailsTracking.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default ProjectBoardIssueDetailsTracking;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
|
||||||
|
import { color, font, mixin } from 'shared/utils/styles';
|
||||||
|
|
||||||
|
export const User = styled.div`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
${mixin.clickable}
|
||||||
|
${props =>
|
||||||
|
props.isSelectValue &&
|
||||||
|
css`
|
||||||
|
margin: 0 10px ${props.withBottomMargin ? 5 : 0}px 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: ${color.backgroundLight};
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Username = styled.div`
|
||||||
|
padding: 0 3px 0 8px;
|
||||||
|
${font.size(14.5)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Option = styled.div`
|
||||||
|
padding: 8px 12px 5px;
|
||||||
|
${mixin.clickable}
|
||||||
|
&.jira-select-option-is-active {
|
||||||
|
background: ${color.backgroundLightPrimary};
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { Avatar, Select, Icon } from 'shared/components';
|
||||||
|
import { User, Username, Option } from './Styles';
|
||||||
|
import { SectionTitle } from '../Styles';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
issue: PropTypes.object.isRequired,
|
||||||
|
updateIssue: PropTypes.func.isRequired,
|
||||||
|
projectUsers: PropTypes.array.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectBoardIssueDetailsUsers = ({ issue, updateIssue, projectUsers }) => {
|
||||||
|
const getUserById = userId => projectUsers.find(user => user.id === parseInt(userId));
|
||||||
|
|
||||||
|
const userOptions = projectUsers.map(user => ({ value: user.id, label: user.name }));
|
||||||
|
|
||||||
|
const renderUserValue = (user, withBottomMargin, removeOptionValue) => (
|
||||||
|
<User
|
||||||
|
key={user.id}
|
||||||
|
isSelectValue
|
||||||
|
withBottomMargin={withBottomMargin}
|
||||||
|
onClick={() => removeOptionValue && removeOptionValue(user.id)}
|
||||||
|
>
|
||||||
|
<Avatar avatarUrl={user.avatarUrl} name={user.name} size={24} />
|
||||||
|
<Username>{user.name}</Username>
|
||||||
|
{removeOptionValue && <Icon type="close" top={1} />}
|
||||||
|
</User>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderUserOption = user => (
|
||||||
|
<User key={user.id}>
|
||||||
|
<Avatar avatarUrl={user.avatarUrl} name={user.name} size={32} />
|
||||||
|
<Username>{user.name}</Username>
|
||||||
|
</User>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderAssignees = () => (
|
||||||
|
<>
|
||||||
|
<SectionTitle>Assignees</SectionTitle>
|
||||||
|
<Select
|
||||||
|
isMulti
|
||||||
|
placeholder="Unassigned"
|
||||||
|
value={issue.userIds}
|
||||||
|
options={userOptions}
|
||||||
|
onChange={userIds => {
|
||||||
|
updateIssue({ userIds, users: userIds.map(getUserById) });
|
||||||
|
}}
|
||||||
|
renderValue={({ value, removeOptionValue }) =>
|
||||||
|
renderUserValue(getUserById(value), true, removeOptionValue)
|
||||||
|
}
|
||||||
|
renderOption={({ value, ...optionProps }) => (
|
||||||
|
<Option {...optionProps}>{renderUserOption(getUserById(value))}</Option>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderReporter = () => (
|
||||||
|
<>
|
||||||
|
<SectionTitle>Reporter</SectionTitle>
|
||||||
|
<Select
|
||||||
|
value={issue.reporterId}
|
||||||
|
options={userOptions}
|
||||||
|
onChange={userId => updateIssue({ reporterId: userId })}
|
||||||
|
renderValue={({ value }) => renderUserValue(getUserById(value), false)}
|
||||||
|
renderOption={({ value, ...optionProps }) => (
|
||||||
|
<Option {...optionProps}>{renderUserOption(getUserById(value))}</Option>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderAssignees()}
|
||||||
|
{renderReporter()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProjectBoardIssueDetailsUsers.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default ProjectBoardIssueDetailsUsers;
|
||||||
@@ -8,7 +8,11 @@ import Loader from './Loader';
|
|||||||
import TopActions from './TopActions';
|
import TopActions from './TopActions';
|
||||||
import Title from './Title';
|
import Title from './Title';
|
||||||
import Description from './Description';
|
import Description from './Description';
|
||||||
import RightActions from './RightActions';
|
import Comments from './Comments';
|
||||||
|
import Status from './Status';
|
||||||
|
import Users from './Users';
|
||||||
|
import Priority from './Priority';
|
||||||
|
import Tracking from './Tracking';
|
||||||
import { Content, Left, Right } from './Styles';
|
import { Content, Left, Right } from './Styles';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
@@ -26,9 +30,9 @@ const ProjectBoardIssueDetails = ({
|
|||||||
updateLocalIssuesArray,
|
updateLocalIssuesArray,
|
||||||
modalClose,
|
modalClose,
|
||||||
}) => {
|
}) => {
|
||||||
const [{ data, error, isLoading, setLocalData }] = useApi.get(`/issues/${issueId}`);
|
const [{ data, error, setLocalData }, fetchIssue] = useApi.get(`/issues/${issueId}`);
|
||||||
|
|
||||||
if (isLoading) return <Loader />;
|
if (!data) return <Loader />;
|
||||||
if (error) return <PageError />;
|
if (error) return <PageError />;
|
||||||
|
|
||||||
const { issue } = data;
|
const { issue } = data;
|
||||||
@@ -60,9 +64,13 @@ const ProjectBoardIssueDetails = ({
|
|||||||
<Left>
|
<Left>
|
||||||
<Title issue={issue} updateIssue={updateIssue} />
|
<Title issue={issue} updateIssue={updateIssue} />
|
||||||
<Description issue={issue} updateIssue={updateIssue} />
|
<Description issue={issue} updateIssue={updateIssue} />
|
||||||
|
<Comments issue={issue} fetchIssue={fetchIssue} />
|
||||||
</Left>
|
</Left>
|
||||||
<Right>
|
<Right>
|
||||||
<RightActions issue={issue} updateIssue={updateIssue} projectUsers={projectUsers} />
|
<Status issue={issue} updateIssue={updateIssue} />
|
||||||
|
<Users issue={issue} updateIssue={updateIssue} projectUsers={projectUsers} />
|
||||||
|
<Priority issue={issue} updateIssue={updateIssue} />
|
||||||
|
<Tracking issue={issue} updateIssue={updateIssue} />
|
||||||
</Right>
|
</Right>
|
||||||
</Content>
|
</Content>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -38,5 +38,5 @@ export const Actions = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const StyledButton = styled(Button)`
|
export const StyledButton = styled(Button)`
|
||||||
margin: 5px 20px 0 0;
|
margin: 6px 20px 0 0;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ const ConfirmModal = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Actions>
|
<Actions>
|
||||||
<StyledButton hollow onClick={modal.close}>
|
|
||||||
{cancelText}
|
|
||||||
</StyledButton>
|
|
||||||
<StyledButton
|
<StyledButton
|
||||||
color={type}
|
color={type}
|
||||||
disabled={confirmInput && !isConfirmEnabled}
|
disabled={confirmInput && !isConfirmEnabled}
|
||||||
@@ -89,6 +86,9 @@ const ConfirmModal = ({
|
|||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
|
<StyledButton hollow onClick={modal.close}>
|
||||||
|
{cancelText}
|
||||||
|
</StyledButton>
|
||||||
</Actions>
|
</Actions>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default styled.div`
|
|||||||
textarea {
|
textarea {
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 7px 7px;
|
padding: 8px 12px 9px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid ${color.borderLightest};
|
border: 1px solid ${color.borderLightest};
|
||||||
background: ${color.backgroundLightest};
|
background: ${color.backgroundLightest};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps },
|
|||||||
<TextareaAutoSize
|
<TextareaAutoSize
|
||||||
{...textareaProps}
|
{...textareaProps}
|
||||||
onChange={event => onChange(event.target.value, event)}
|
onChange={event => onChange(event.target.value, event)}
|
||||||
inputRef={ref}
|
inputRef={ref || undefined}
|
||||||
/>
|
/>
|
||||||
</StyledTextarea>
|
</StyledTextarea>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
|
|
||||||
function useDeepCompareMemoize(value) {
|
const useDeepCompareMemoize = value => {
|
||||||
const valueRef = useRef();
|
const valueRef = useRef();
|
||||||
|
|
||||||
if (!isEqual(value, valueRef.current)) {
|
if (!isEqual(value, valueRef.current)) {
|
||||||
@@ -9,6 +9,6 @@ function useDeepCompareMemoize(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return valueRef.current;
|
return valueRef.current;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default useDeepCompareMemoize;
|
export default useDeepCompareMemoize;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
export const formatDate = (date, format = 'll') => (date ? moment(date).format(format) : date);
|
export const formatDate = (date, format = 'MMMM D, YYYY') =>
|
||||||
|
date ? moment(date).format(format) : date;
|
||||||
|
|
||||||
export const formatDateTime = (date, format = 'lll') => (date ? moment(date).format(format) : date);
|
export const formatDateTime = (date, format = 'MMMM D, YYYY, h:mm A') =>
|
||||||
|
date ? moment(date).format(format) : date;
|
||||||
|
|
||||||
export const formatDateTimeForAPI = date =>
|
export const formatDateTimeForAPI = date =>
|
||||||
date
|
date
|
||||||
@@ -10,3 +12,5 @@ export const formatDateTimeForAPI = date =>
|
|||||||
.utc()
|
.utc()
|
||||||
.format()
|
.format()
|
||||||
: date;
|
: date;
|
||||||
|
|
||||||
|
export const formatDateTimeConversational = date => (date ? moment(date).fromNow() : date);
|
||||||
|
|||||||
Reference in New Issue
Block a user