Implemented issue comments

This commit is contained in:
ireic
2019-12-18 20:44:05 +01:00
parent 386694d28f
commit 32170e90d2
34 changed files with 902 additions and 439 deletions

View 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;

View File

@@ -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 });
}),

View File

@@ -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;

View File

@@ -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)));

View File

@@ -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;
`;

View File

@@ -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;

View File

@@ -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;
}
`;

View File

@@ -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;

View File

@@ -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)}
`;

View File

@@ -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;

View File

@@ -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)}
`;

View File

@@ -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;

View File

@@ -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}

View File

@@ -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 (
<>
<DescriptionLabel>Description</DescriptionLabel>
<Title>Description</Title>
{isPresenting ? renderPresentingMode() : renderEditingMode()}
</>
);

View File

@@ -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)}
`;

View File

@@ -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;

View File

@@ -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;
`;

View File

@@ -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;

View File

@@ -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};
}
`;

View File

@@ -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;

View File

@@ -1,8 +1,10 @@
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const Content = styled.div`
display: flex;
padding: 0 30px 30px;
padding: 0 30px 60px;
`;
export const Left = styled.div`
@@ -14,3 +16,11 @@ export const Right = styled.div`
width: 35%;
padding-top: 5px;
`;
export const SectionTitle = styled.div`
margin: 24px 0 5px;
text-transform: uppercase;
color: ${color.textMedium};
${font.size(12.5)}
${font.bold}
`;

View File

@@ -8,10 +8,11 @@ export const TitleTextarea = styled(Textarea)`
height: 44px;
width: 100%;
textarea {
background: #fff;
padding: 7px 7px 8px;
line-height: 1.28;
border: none;
resize: none;
line-height: 1.28;
background: #fff;
border: 1px solid transparent;
box-shadow: 0 0 0 1px transparent;
transition: background 0.1s;

View File

@@ -93,7 +93,7 @@ const ProjectBoardIssueDetailsTopActions = ({ issue, updateIssue, fetchProject,
const renderDeleteIcon = () => (
<ConfirmModal
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"
onConfirm={handleIssueDelete}
renderLink={modal => <Button icon="trash" iconSize={19} color="empty" onClick={modal.open} />}

View File

@@ -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;
`;

View File

@@ -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;

View File

@@ -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};
}
`;

View File

@@ -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;

View File

@@ -8,7 +8,11 @@ import Loader from './Loader';
import TopActions from './TopActions';
import Title from './Title';
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';
const propTypes = {
@@ -26,9 +30,9 @@ const ProjectBoardIssueDetails = ({
updateLocalIssuesArray,
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 />;
const { issue } = data;
@@ -60,9 +64,13 @@ const ProjectBoardIssueDetails = ({
<Left>
<Title issue={issue} updateIssue={updateIssue} />
<Description issue={issue} updateIssue={updateIssue} />
<Comments issue={issue} fetchIssue={fetchIssue} />
</Left>
<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>
</Content>
</>

View File

@@ -38,5 +38,5 @@ export const Actions = styled.div`
`;
export const StyledButton = styled(Button)`
margin: 5px 20px 0 0;
margin: 6px 20px 0 0;
`;

View File

@@ -78,9 +78,6 @@ const ConfirmModal = ({
</>
)}
<Actions>
<StyledButton hollow onClick={modal.close}>
{cancelText}
</StyledButton>
<StyledButton
color={type}
disabled={confirmInput && !isConfirmEnabled}
@@ -89,6 +86,9 @@ const ConfirmModal = ({
>
{confirmText}
</StyledButton>
<StyledButton hollow onClick={modal.close}>
{cancelText}
</StyledButton>
</Actions>
</>
)}

View File

@@ -8,7 +8,7 @@ export default styled.div`
textarea {
overflow-y: hidden;
width: 100%;
padding: 6px 7px 7px;
padding: 8px 12px 9px;
border-radius: 3px;
border: 1px solid ${color.borderLightest};
background: ${color.backgroundLightest};

View File

@@ -25,7 +25,7 @@ const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps },
<TextareaAutoSize
{...textareaProps}
onChange={event => onChange(event.target.value, event)}
inputRef={ref}
inputRef={ref || undefined}
/>
</StyledTextarea>
));

View File

@@ -1,7 +1,7 @@
import { useRef } from 'react';
import { isEqual } from 'lodash';
function useDeepCompareMemoize(value) {
const useDeepCompareMemoize = value => {
const valueRef = useRef();
if (!isEqual(value, valueRef.current)) {
@@ -9,6 +9,6 @@ function useDeepCompareMemoize(value) {
}
return valueRef.current;
}
};
export default useDeepCompareMemoize;

View File

@@ -1,8 +1,10 @@
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 =>
date
@@ -10,3 +12,5 @@ export const formatDateTimeForAPI = date =>
.utc()
.format()
: date;
export const formatDateTimeConversational = date => (date ? moment(date).fromNow() : date);