Polished existing features, added items to sidebar navigation
This commit is contained in:
55
client/src/Project/Board/Filters/Styles.js
Normal file
55
client/src/Project/Board/Filters/Styles.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { InputDebounced, Avatar, Button } from 'shared/components';
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Filters = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 24px;
|
||||
`;
|
||||
|
||||
export const SearchInput = styled(InputDebounced)`
|
||||
margin-right: 18px;
|
||||
width: 160px;
|
||||
`;
|
||||
|
||||
export const Avatars = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
margin: 0 12px 0 2px;
|
||||
`;
|
||||
|
||||
export const AvatarIsActiveBorder = styled.div`
|
||||
display: inline-flex;
|
||||
margin-left: -2px;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.1s;
|
||||
${mixin.clickable};
|
||||
${props => props.isActive && `box-shadow: 0 0 0 4px ${color.primary}`}
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledAvatar = styled(Avatar)`
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
`;
|
||||
|
||||
export const StyledButton = styled(Button)`
|
||||
margin-left: 6px;
|
||||
`;
|
||||
|
||||
export const ClearAll = styled.div`
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
margin-left: 15px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid ${color.borderLightest};
|
||||
color: ${color.textDark};
|
||||
${font.size(14.5)}
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
color: ${color.textMedium};
|
||||
}
|
||||
`;
|
||||
70
client/src/Project/Board/Filters/index.jsx
Normal file
70
client/src/Project/Board/Filters/index.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { xor } from 'lodash';
|
||||
|
||||
import {
|
||||
Filters,
|
||||
SearchInput,
|
||||
Avatars,
|
||||
AvatarIsActiveBorder,
|
||||
StyledAvatar,
|
||||
StyledButton,
|
||||
ClearAll,
|
||||
} from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
projectUsers: PropTypes.array.isRequired,
|
||||
defaultFilters: PropTypes.object.isRequired,
|
||||
filters: PropTypes.object.isRequired,
|
||||
setFilters: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, setFilters }) => {
|
||||
const { searchQuery, userIds, myOnly, recent } = filters;
|
||||
|
||||
const setFiltersMerge = newFilters => setFilters({ ...filters, ...newFilters });
|
||||
|
||||
const areFiltersCleared = !searchQuery && userIds.length === 0 && !myOnly && !recent;
|
||||
|
||||
return (
|
||||
<Filters>
|
||||
<SearchInput
|
||||
icon="search"
|
||||
value={searchQuery}
|
||||
onChange={value => setFiltersMerge({ searchQuery: value })}
|
||||
/>
|
||||
<Avatars>
|
||||
{projectUsers.map(user => (
|
||||
<AvatarIsActiveBorder key={user.id} isActive={userIds.includes(user.id)}>
|
||||
<StyledAvatar
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
onClick={() => setFiltersMerge({ userIds: xor(userIds, [user.id]) })}
|
||||
/>
|
||||
</AvatarIsActiveBorder>
|
||||
))}
|
||||
</Avatars>
|
||||
<StyledButton
|
||||
color="empty"
|
||||
isActive={myOnly}
|
||||
onClick={() => setFiltersMerge({ myOnly: !myOnly })}
|
||||
>
|
||||
Only My Issues
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
color="empty"
|
||||
isActive={recent}
|
||||
onClick={() => setFiltersMerge({ recent: !recent })}
|
||||
>
|
||||
Recently Updated
|
||||
</StyledButton>
|
||||
{!areFiltersCleared && (
|
||||
<ClearAll onClick={() => setFilters(defaultFilters)}>Clear all</ClearAll>
|
||||
)}
|
||||
</Filters>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardFilters.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardFilters;
|
||||
26
client/src/Project/Board/Header/Styles.js
Normal file
26
client/src/Project/Board/Header/Styles.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
|
||||
export const Breadcrumbs = styled.div`
|
||||
color: ${color.textMedium};
|
||||
${font.size(15)};
|
||||
`;
|
||||
|
||||
export const Divider = styled.span`
|
||||
position: relative;
|
||||
top: 2px;
|
||||
margin: 0 10px;
|
||||
${font.size(18)};
|
||||
`;
|
||||
|
||||
export const Header = styled.div`
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const BoardName = styled.div`
|
||||
${font.size(24)}
|
||||
${font.medium}
|
||||
`;
|
||||
29
client/src/Project/Board/Header/index.jsx
Normal file
29
client/src/Project/Board/Header/index.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { CopyLinkButton } from 'shared/components';
|
||||
import { Breadcrumbs, Divider, Header, BoardName } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
projectName: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardHeader = ({ projectName }) => (
|
||||
<>
|
||||
<Breadcrumbs>
|
||||
Projects
|
||||
<Divider>/</Divider>
|
||||
{projectName}
|
||||
<Divider>/</Divider>
|
||||
Kanban Board
|
||||
</Breadcrumbs>
|
||||
<Header>
|
||||
<BoardName>Kanban board</BoardName>
|
||||
<CopyLinkButton />
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
|
||||
ProjectBoardHeader.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardHeader;
|
||||
@@ -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,27 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
|
||||
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,35 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { KeyCodes } from 'shared/constants/keyCodes';
|
||||
import { isFocusedElementEditable } from 'shared/utils/dom';
|
||||
import { Tip, TipLetter } from './Style';
|
||||
|
||||
const propTypes = {
|
||||
setFormOpen: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsCommentsCreateProTip = ({ setFormOpen }) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
if (!isFocusedElementEditable() && event.keyCode === KeyCodes.M) {
|
||||
event.preventDefault();
|
||||
setFormOpen(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [setFormOpen]);
|
||||
|
||||
return (
|
||||
<Tip>
|
||||
<strong>Pro tip:</strong>press<TipLetter>M</TipLetter>to comment
|
||||
</Tip>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsCommentsCreateProTip.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsCommentsCreateProTip;
|
||||
@@ -0,0 +1,31 @@
|
||||
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 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${color.borderLightest};
|
||||
color: ${color.textLight};
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
border: 1px solid ${color.borderLight};
|
||||
}
|
||||
`;
|
||||
@@ -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 ProTip from './ProTip';
|
||||
import { Create, UserAvatar, Right, FakeTextarea } 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>
|
||||
<ProTip setFormOpen={setFormOpen} />
|
||||
</>
|
||||
)}
|
||||
</Right>
|
||||
</Create>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsCommentsCreate.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsCommentsCreate;
|
||||
12
client/src/Project/Board/IssueDetails/Comments/Styles.js
Normal file
12
client/src/Project/Board/IssueDetails/Comments/Styles.js
Normal 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)}
|
||||
`;
|
||||
27
client/src/Project/Board/IssueDetails/Comments/index.jsx
Normal file
27
client/src/Project/Board/IssueDetails/Comments/index.jsx
Normal 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.object.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;
|
||||
12
client/src/Project/Board/IssueDetails/Dates/Styles.js
Normal file
12
client/src/Project/Board/IssueDetails/Dates/Styles.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
|
||||
export const Dates = styled.div`
|
||||
margin-top: 11px;
|
||||
padding-top: 13px;
|
||||
line-height: 22px;
|
||||
border-top: 1px solid ${color.borderLightest};
|
||||
color: ${color.textMedium};
|
||||
${font.size(13)}
|
||||
`;
|
||||
20
client/src/Project/Board/IssueDetails/Dates/index.jsx
Normal file
20
client/src/Project/Board/IssueDetails/Dates/index.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { formatDateTimeConversational } from 'shared/utils/dateTime';
|
||||
import { Dates } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsDates = ({ issue }) => (
|
||||
<Dates>
|
||||
<div>Created at {formatDateTimeConversational(issue.createdAt)}</div>
|
||||
<div>Updated at {formatDateTimeConversational(issue.updatedAt)}</div>
|
||||
</Dates>
|
||||
);
|
||||
|
||||
ProjectBoardIssueDetailsDates.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsDates;
|
||||
37
client/src/Project/Board/IssueDetails/Delete.jsx
Normal file
37
client/src/Project/Board/IssueDetails/Delete.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import api from 'shared/utils/api';
|
||||
import toast from 'shared/utils/toast';
|
||||
import { Button, ConfirmModal } from 'shared/components';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
fetchProject: PropTypes.func.isRequired,
|
||||
modalClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsDelete = ({ issue, fetchProject, modalClose }) => {
|
||||
const handleIssueDelete = async () => {
|
||||
try {
|
||||
await api.delete(`/issues/${issue.id}`);
|
||||
await fetchProject();
|
||||
modalClose();
|
||||
} catch (error) {
|
||||
toast.error(error);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ConfirmModal
|
||||
title="Are you sure you want to delete this issue?"
|
||||
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} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsDelete.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsDelete;
|
||||
30
client/src/Project/Board/IssueDetails/Description/Styles.js
Normal file
30
client/src/Project/Board/IssueDetails/Description/Styles.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Title = styled.div`
|
||||
padding: 20px 0 6px;
|
||||
${font.size(15)}
|
||||
${font.medium}
|
||||
`;
|
||||
|
||||
export const EmptyLabel = styled.div`
|
||||
margin-left: -7px;
|
||||
padding: 7px;
|
||||
border-radius: 3px;
|
||||
color: ${color.textMedium}
|
||||
transition: background 0.1s;
|
||||
${font.size(15)}
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
background: ${color.backgroundLight};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Actions = styled.div`
|
||||
display: flex;
|
||||
padding-top: 12px;
|
||||
& > button {
|
||||
margin-right: 6px;
|
||||
}
|
||||
`;
|
||||
60
client/src/Project/Board/IssueDetails/Description/index.jsx
Normal file
60
client/src/Project/Board/IssueDetails/Description/index.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getTextContentsFromHtmlString } from 'shared/utils/html';
|
||||
import { TextEditor, TextEditedContent, Button } from 'shared/components';
|
||||
import { Title, EmptyLabel, Actions } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
updateIssue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
|
||||
const $editorRef = useRef();
|
||||
const [isPresenting, setPresenting] = useState(true);
|
||||
|
||||
const renderPresentingMode = () =>
|
||||
isDescriptionEmpty(issue.description) ? (
|
||||
<EmptyLabel onClick={() => setPresenting(false)}>Add a description...</EmptyLabel>
|
||||
) : (
|
||||
<TextEditedContent content={issue.description} onClick={() => setPresenting(false)} />
|
||||
);
|
||||
|
||||
const renderEditingMode = () => (
|
||||
<>
|
||||
<TextEditor
|
||||
placeholder="Describe the issue"
|
||||
defaultValue={issue.description}
|
||||
getEditor={editor => ($editorRef.current = editor)}
|
||||
/>
|
||||
<Actions>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setPresenting(true);
|
||||
updateIssue({ description: $editorRef.current.getHTML() });
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button color="empty" onClick={() => setPresenting(true)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Actions>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Title>Description</Title>
|
||||
{isPresenting ? renderPresentingMode() : renderEditingMode()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const isDescriptionEmpty = description =>
|
||||
getTextContentsFromHtmlString(description).trim().length === 0;
|
||||
|
||||
ProjectBoardIssueDetailsDescription.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsDescription;
|
||||
23
client/src/Project/Board/IssueDetails/Feedback/Styles.js
Normal file
23
client/src/Project/Board/IssueDetails/Feedback/Styles.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { font } from 'shared/utils/styles';
|
||||
|
||||
export const FeedbackDropdown = styled.div`
|
||||
padding: 16px 24px 24px;
|
||||
`;
|
||||
|
||||
export const FeedbackImageCont = styled.div`
|
||||
padding: 24px 56px 20px;
|
||||
`;
|
||||
|
||||
export const FeedbackImage = styled.img`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const FeedbackParagraph = styled.p`
|
||||
margin-bottom: 12px;
|
||||
${font.size(15)}
|
||||
&:last-of-type {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
`;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
44
client/src/Project/Board/IssueDetails/Feedback/index.jsx
Normal file
44
client/src/Project/Board/IssueDetails/Feedback/index.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button, Tooltip } from 'shared/components';
|
||||
import feedbackImage from './assets/feedback.png';
|
||||
import { FeedbackDropdown, FeedbackImageCont, FeedbackImage, FeedbackParagraph } from './Styles';
|
||||
|
||||
const ProjectBoardIssueDetailsFeedback = () => (
|
||||
<Tooltip
|
||||
width={300}
|
||||
offset={{ top: -15 }}
|
||||
renderLink={linkProps => (
|
||||
<Button icon="feedback" color="empty" {...linkProps}>
|
||||
Give feedback
|
||||
</Button>
|
||||
)}
|
||||
renderContent={() => (
|
||||
<FeedbackDropdown>
|
||||
<FeedbackImageCont>
|
||||
<FeedbackImage src={feedbackImage} alt="Give feedback" />
|
||||
</FeedbackImageCont>
|
||||
<FeedbackParagraph>
|
||||
This simplified Jira clone is built with React on the front-end and Node/TypeScript on the
|
||||
back-end.
|
||||
</FeedbackParagraph>
|
||||
<FeedbackParagraph>
|
||||
{'Read more on our website or reach out via '}
|
||||
<a href="mailto:ivor@codetree.co">
|
||||
<strong>ivor@codetree.co</strong>
|
||||
</a>
|
||||
</FeedbackParagraph>
|
||||
<a href="https://codetree.co/" target="_blank" rel="noreferrer noopener">
|
||||
<Button color="primary">Visit Website</Button>
|
||||
</a>
|
||||
<a href="https://github.com/oldboyxx/jira_clone" target="_blank" rel="noreferrer noopener">
|
||||
<Button style={{ marginLeft: 10 }} icon="github">
|
||||
Github Repo
|
||||
</Button>
|
||||
</a>
|
||||
</FeedbackDropdown>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export default ProjectBoardIssueDetailsFeedback;
|
||||
31
client/src/Project/Board/IssueDetails/Loader.jsx
Normal file
31
client/src/Project/Board/IssueDetails/Loader.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import ContentLoader from 'react-content-loader';
|
||||
|
||||
const IssueDetailsLoader = () => (
|
||||
<div style={{ padding: 40 }}>
|
||||
<ContentLoader
|
||||
height={260}
|
||||
width={940}
|
||||
speed={2}
|
||||
primaryColor="#f3f3f3"
|
||||
secondaryColor="#ecebeb"
|
||||
>
|
||||
<rect x="0" y="0" rx="3" ry="3" width="627" height="24" />
|
||||
<rect x="0" y="29" rx="3" ry="3" width="506" height="24" />
|
||||
<rect x="0" y="77" rx="3" ry="3" width="590" height="16" />
|
||||
<rect x="0" y="100" rx="3" ry="3" width="627" height="16" />
|
||||
<rect x="0" y="123" rx="3" ry="3" width="480" height="16" />
|
||||
<rect x="0" y="187" rx="3" ry="3" width="370" height="16" />
|
||||
<circle cx="18" cy="239" r="18" />
|
||||
<rect x="46" y="217" rx="3" ry="3" width="548" height="42" />
|
||||
<rect x="683" y="3" rx="3" ry="3" width="135" height="14" />
|
||||
<rect x="683" y="33" rx="3" ry="3" width="251" height="24" />
|
||||
<rect x="683" y="90" rx="3" ry="3" width="135" height="14" />
|
||||
<rect x="683" y="120" rx="3" ry="3" width="251" height="24" />
|
||||
<rect x="683" y="177" rx="3" ry="3" width="135" height="14" />
|
||||
<rect x="683" y="207" rx="3" ry="3" width="251" height="24" />
|
||||
</ContentLoader>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default IssueDetailsLoader;
|
||||
24
client/src/Project/Board/IssueDetails/Priority/Styles.js
Normal file
24
client/src/Project/Board/IssueDetails/Priority/Styles.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
|
||||
export const Priority = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
${props =>
|
||||
props.isValue &&
|
||||
css`
|
||||
padding: 3px 4px 3px 0px;
|
||||
border-radius: 4px;
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: ${color.backgroundLight};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Label = styled.div`
|
||||
text-transform: capitalize;
|
||||
padding: 0 3px 0 8px;
|
||||
${font.size(14.5)}
|
||||
`;
|
||||
44
client/src/Project/Board/IssueDetails/Priority/index.jsx
Normal file
44
client/src/Project/Board/IssueDetails/Priority/index.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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, 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, isValue) => (
|
||||
<Priority isValue={isValue}>
|
||||
<IssuePriorityIcon priority={priority} />
|
||||
<Label>{IssuePriorityCopy[priority].toLowerCase()}</Label>
|
||||
</Priority>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<SectionTitle>Priority</SectionTitle>
|
||||
<Select
|
||||
dropdownWidth={343}
|
||||
value={issue.priority}
|
||||
options={Object.values(IssuePriority).map(priority => ({
|
||||
value: priority,
|
||||
label: IssuePriorityCopy[priority],
|
||||
}))}
|
||||
onChange={priority => updateIssue({ priority })}
|
||||
renderValue={({ value }) => renderPriorityItem(value, true)}
|
||||
renderOption={({ value }) => renderPriorityItem(value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsPriority.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsPriority;
|
||||
18
client/src/Project/Board/IssueDetails/Status/Styles.js
Normal file
18
client/src/Project/Board/IssueDetails/Status/Styles.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import { issueStatusColors, issueStatusBackgroundColors, mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Status = styled.div`
|
||||
text-transform: uppercase;
|
||||
transition: all 0.1s;
|
||||
${props => mixin.tag(issueStatusBackgroundColors[props.color], issueStatusColors[props.color])}
|
||||
${props =>
|
||||
props.isValue &&
|
||||
css`
|
||||
padding: 0 12px;
|
||||
height: 32px;
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
40
client/src/Project/Board/IssueDetails/Status/index.jsx
Normal file
40
client/src/Project/Board/IssueDetails/Status/index.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { IssueStatus, IssueStatusCopy } from 'shared/constants/issues';
|
||||
import { Select, Icon } from 'shared/components';
|
||||
import { Status } from './Styles';
|
||||
import { SectionTitle } from '../Styles';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
updateIssue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsStatus = ({ issue, updateIssue }) => (
|
||||
<>
|
||||
<SectionTitle>Status</SectionTitle>
|
||||
<Select
|
||||
dropdownWidth={343}
|
||||
value={issue.status}
|
||||
options={Object.values(IssueStatus).map(status => ({
|
||||
value: status,
|
||||
label: IssueStatusCopy[status],
|
||||
}))}
|
||||
onChange={status => updateIssue({ status })}
|
||||
renderValue={({ value: status }) => (
|
||||
<Status isValue color={status}>
|
||||
<div>{IssueStatusCopy[status]}</div>
|
||||
<Icon type="chevron-down" size={18} />
|
||||
</Status>
|
||||
)}
|
||||
renderOption={({ value: status }) => (
|
||||
<Status color={status}>{IssueStatusCopy[status]}</Status>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
ProjectBoardIssueDetailsStatus.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsStatus;
|
||||
40
client/src/Project/Board/IssueDetails/Styles.js
Normal file
40
client/src/Project/Board/IssueDetails/Styles.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
|
||||
export const Content = styled.div`
|
||||
display: flex;
|
||||
padding: 0 30px 60px;
|
||||
`;
|
||||
|
||||
export const Left = styled.div`
|
||||
width: 65%;
|
||||
padding-right: 50px;
|
||||
`;
|
||||
|
||||
export const Right = styled.div`
|
||||
width: 35%;
|
||||
padding-top: 5px;
|
||||
`;
|
||||
|
||||
export const TopActions = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 21px 18px 0;
|
||||
`;
|
||||
|
||||
export const TopActionsRight = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& > * {
|
||||
margin-left: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SectionTitle = styled.div`
|
||||
margin: 24px 0 5px;
|
||||
text-transform: uppercase;
|
||||
color: ${color.textMedium};
|
||||
${font.size(12.5)}
|
||||
${font.bold}
|
||||
`;
|
||||
32
client/src/Project/Board/IssueDetails/Title/Styles.js
Normal file
32
client/src/Project/Board/IssueDetails/Title/Styles.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
import { Textarea } from 'shared/components';
|
||||
|
||||
export const TitleTextarea = styled(Textarea)`
|
||||
margin: 18px 0 0 -8px;
|
||||
height: 44px;
|
||||
width: 100%;
|
||||
textarea {
|
||||
padding: 7px 7px 8px;
|
||||
line-height: 1.28;
|
||||
border: none;
|
||||
resize: none;
|
||||
background: #fff;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 0 0 1px transparent;
|
||||
transition: background 0.1s;
|
||||
${font.size(24)}
|
||||
${font.medium}
|
||||
&:hover:not(:focus) {
|
||||
background: ${color.backgroundLight};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ErrorText = styled.div`
|
||||
padding-top: 4px;
|
||||
color: ${color.danger};
|
||||
${font.size(13)}
|
||||
${font.medium}
|
||||
`;
|
||||
53
client/src/Project/Board/IssueDetails/Title/index.jsx
Normal file
53
client/src/Project/Board/IssueDetails/Title/index.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { KeyCodes } from 'shared/constants/keyCodes';
|
||||
import { is, generateErrors } from 'shared/utils/validation';
|
||||
import { TitleTextarea, ErrorText } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
updateIssue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsTitle = ({ issue, updateIssue }) => {
|
||||
const $titleInputRef = useRef();
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleTitleChange = () => {
|
||||
setError(null);
|
||||
|
||||
const title = $titleInputRef.current.value;
|
||||
if (title === issue.title) return;
|
||||
|
||||
const errors = generateErrors({ title }, { title: [is.required(), is.maxLength(200)] });
|
||||
|
||||
if (errors.title) {
|
||||
setError(errors.title);
|
||||
} else {
|
||||
updateIssue({ title });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitleTextarea
|
||||
minRows={1}
|
||||
placeholder="Short summary"
|
||||
defaultValue={issue.title}
|
||||
ref={$titleInputRef}
|
||||
onBlur={handleTitleChange}
|
||||
onKeyDown={event => {
|
||||
if (event.keyCode === KeyCodes.ENTER) {
|
||||
event.target.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && <ErrorText>{error}</ErrorText>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsTitle.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsTitle;
|
||||
81
client/src/Project/Board/IssueDetails/Tracking/Styles.js
Normal file
81
client/src/Project/Board/IssueDetails/Tracking/Styles.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
import { Icon } from 'shared/components';
|
||||
|
||||
export const TrackingLink = styled.div`
|
||||
padding: 4px 4px 2px 0;
|
||||
border-radius: 4px;
|
||||
transition: background 0.1s;
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
background: ${color.backgroundLight};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Tracking = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
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.backgroundMedium};
|
||||
`;
|
||||
|
||||
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 25px 25px;
|
||||
`;
|
||||
|
||||
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;
|
||||
`;
|
||||
135
client/src/Project/Board/IssueDetails/Tracking/index.jsx
Normal file
135
client/src/Project/Board/IssueDetails/Tracking/index.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { InputDebounced, Modal, Button } from 'shared/components';
|
||||
import {
|
||||
TrackingLink,
|
||||
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() ? Number(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 => <TrackingLink>{renderTrackingPreview(modal.open)}</TrackingLink>}
|
||||
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;
|
||||
38
client/src/Project/Board/IssueDetails/Type/Styles.js
Normal file
38
client/src/Project/Board/IssueDetails/Type/Styles.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
import { Button } from 'shared/components';
|
||||
|
||||
export const TypeButton = styled(Button)`
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: ${color.textMedium};
|
||||
${font.size(13)}
|
||||
`;
|
||||
|
||||
export const TypeDropdown = styled.div`
|
||||
padding-bottom: 6px;
|
||||
`;
|
||||
|
||||
export const TypeTitle = styled.div`
|
||||
padding: 10px 0 7px 12px;
|
||||
text-transform: uppercase;
|
||||
color: ${color.textMedium};
|
||||
${font.size(12)}
|
||||
`;
|
||||
|
||||
export const Type = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 7px 12px;
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
background: ${color.backgroundLight};
|
||||
}
|
||||
`;
|
||||
|
||||
export const TypeLabel = styled.div`
|
||||
padding: 0 5px 0 7px;
|
||||
text-transform: capitalize;
|
||||
${font.size(15)}
|
||||
`;
|
||||
38
client/src/Project/Board/IssueDetails/Type/index.jsx
Normal file
38
client/src/Project/Board/IssueDetails/Type/index.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { IssueType } from 'shared/constants/issues';
|
||||
import { IssueTypeIcon, Tooltip } from 'shared/components';
|
||||
import { TypeButton, TypeDropdown, TypeTitle, Type, TypeLabel } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
updateIssue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsType = ({ issue, updateIssue }) => (
|
||||
<Tooltip
|
||||
width={150}
|
||||
offset={{ top: -15 }}
|
||||
renderLink={linkProps => (
|
||||
<TypeButton {...linkProps} color="empty" icon={<IssueTypeIcon type={issue.type} />}>
|
||||
{`${issue.type}-${issue.id}`}
|
||||
</TypeButton>
|
||||
)}
|
||||
renderContent={() => (
|
||||
<TypeDropdown>
|
||||
<TypeTitle>Change issue type</TypeTitle>
|
||||
{Object.values(IssueType).map(type => (
|
||||
<Type key={type} onClick={() => updateIssue({ type })}>
|
||||
<IssueTypeIcon type={type} top={1} />
|
||||
<TypeLabel>{type}</TypeLabel>
|
||||
</Type>
|
||||
))}
|
||||
</TypeDropdown>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
ProjectBoardIssueDetailsType.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsType;
|
||||
26
client/src/Project/Board/IssueDetails/Users/Styles.js
Normal file
26
client/src/Project/Board/IssueDetails/Users/Styles.js
Normal file
@@ -0,0 +1,26 @@
|
||||
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};
|
||||
transition: background 0.1s;
|
||||
&:hover {
|
||||
background: ${color.backgroundMedium};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Username = styled.div`
|
||||
padding: 0 3px 0 8px;
|
||||
${font.size(14.5)}
|
||||
`;
|
||||
83
client/src/Project/Board/IssueDetails/Users/index.jsx
Normal file
83
client/src/Project/Board/IssueDetails/Users/index.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Avatar, Select, Icon } from 'shared/components';
|
||||
import { User, Username } 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 === 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
|
||||
dropdownWidth={343}
|
||||
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 }) => renderUserOption(getUserById(value))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderReporter = () => (
|
||||
<>
|
||||
<SectionTitle>Reporter</SectionTitle>
|
||||
<Select
|
||||
dropdownWidth={343}
|
||||
value={issue.reporterId}
|
||||
options={userOptions}
|
||||
onChange={userId => updateIssue({ reporterId: userId })}
|
||||
renderValue={({ value }) => renderUserValue(getUserById(value), false)}
|
||||
renderOption={({ value }) => renderUserOption(getUserById(value))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderAssignees()}
|
||||
{renderReporter()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsUsers.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsUsers;
|
||||
89
client/src/Project/Board/IssueDetails/index.jsx
Normal file
89
client/src/Project/Board/IssueDetails/index.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import api from 'shared/utils/api';
|
||||
import useApi from 'shared/hooks/api';
|
||||
import { PageError, CopyLinkButton, Button } from 'shared/components';
|
||||
import Loader from './Loader';
|
||||
import Type from './Type';
|
||||
import Feedback from './Feedback';
|
||||
import Delete from './Delete';
|
||||
import Title from './Title';
|
||||
import Description from './Description';
|
||||
import Comments from './Comments';
|
||||
import Status from './Status';
|
||||
import Users from './Users';
|
||||
import Priority from './Priority';
|
||||
import Tracking from './Tracking';
|
||||
import Dates from './Dates';
|
||||
import { TopActions, TopActionsRight, Content, Left, Right } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issueId: PropTypes.string.isRequired,
|
||||
projectUsers: PropTypes.array.isRequired,
|
||||
fetchProject: PropTypes.func.isRequired,
|
||||
updateLocalIssuesArray: PropTypes.func.isRequired,
|
||||
modalClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetails = ({
|
||||
issueId,
|
||||
projectUsers,
|
||||
fetchProject,
|
||||
updateLocalIssuesArray,
|
||||
modalClose,
|
||||
}) => {
|
||||
const [{ data, error, setLocalData }, fetchIssue] = useApi.get(`/issues/${issueId}`);
|
||||
|
||||
if (!data) return <Loader />;
|
||||
if (error) return <PageError />;
|
||||
|
||||
const { issue } = data;
|
||||
|
||||
const updateLocalIssue = fields =>
|
||||
setLocalData(currentData => ({ issue: { ...currentData.issue, ...fields } }));
|
||||
|
||||
const updateIssue = updatedFields => {
|
||||
api.optimisticUpdate({
|
||||
url: `/issues/${issueId}`,
|
||||
updatedFields,
|
||||
currentFields: issue,
|
||||
setLocalData: fields => {
|
||||
updateLocalIssue(fields);
|
||||
updateLocalIssuesArray(issue.id, fields);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopActions>
|
||||
<Type issue={issue} updateIssue={updateIssue} />
|
||||
<TopActionsRight>
|
||||
<Feedback />
|
||||
<CopyLinkButton color="empty" />
|
||||
<Delete issue={issue} fetchProject={fetchProject} modalClose={modalClose} />
|
||||
<Button icon="close" iconSize={24} color="empty" onClick={modalClose} />
|
||||
</TopActionsRight>
|
||||
</TopActions>
|
||||
<Content>
|
||||
<Left>
|
||||
<Title issue={issue} updateIssue={updateIssue} />
|
||||
<Description issue={issue} updateIssue={updateIssue} />
|
||||
<Comments issue={issue} fetchIssue={fetchIssue} />
|
||||
</Left>
|
||||
<Right>
|
||||
<Status issue={issue} updateIssue={updateIssue} />
|
||||
<Users issue={issue} updateIssue={updateIssue} projectUsers={projectUsers} />
|
||||
<Priority issue={issue} updateIssue={updateIssue} />
|
||||
<Tracking issue={issue} updateIssue={updateIssue} />
|
||||
<Dates issue={issue} />
|
||||
</Right>
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetails.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetails;
|
||||
50
client/src/Project/Board/Lists/Issue/Styles.js
Normal file
50
client/src/Project/Board/Lists/Issue/Styles.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from 'shared/components';
|
||||
import { color, font, mixin } from 'shared/utils/styles';
|
||||
|
||||
export const IssueLink = styled(Link)`
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
`;
|
||||
|
||||
export const Issue = styled.div`
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
box-shadow: 0px 1px 2px 0px rgba(9, 30, 66, 0.25);
|
||||
transition: background 0.1s;
|
||||
${mixin.clickable}
|
||||
&:hover {
|
||||
background: ${color.backgroundLight};
|
||||
}
|
||||
${props =>
|
||||
props.isBeingDragged &&
|
||||
css`
|
||||
transform: rotate(3deg);
|
||||
box-shadow: 5px 10px 30px 0px rgba(9, 30, 66, 0.15);
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Title = styled.p`
|
||||
padding-bottom: 11px;
|
||||
${font.size(15)}
|
||||
`;
|
||||
|
||||
export const Bottom = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const Assignees = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
margin-left: 2px;
|
||||
`;
|
||||
|
||||
export const AssigneeAvatar = styled(Avatar)`
|
||||
margin-left: -2px;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
`;
|
||||
57
client/src/Project/Board/Lists/Issue/index.jsx
Normal file
57
client/src/Project/Board/Lists/Issue/index.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
|
||||
import { IssueTypeIcon, IssuePriorityIcon } from 'shared/components';
|
||||
import { IssueLink, Issue, Title, Bottom, Assignees, AssigneeAvatar } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
projectUsers: PropTypes.array.isRequired,
|
||||
issue: PropTypes.object.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardListsIssue = ({ projectUsers, issue, index }) => {
|
||||
const match = useRouteMatch();
|
||||
|
||||
const getUserById = userId => projectUsers.find(user => user.id === userId);
|
||||
const assignees = issue.userIds.map(getUserById);
|
||||
|
||||
return (
|
||||
<Draggable draggableId={issue.id.toString()} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<IssueLink
|
||||
to={`${match.url}/${issue.id}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<Issue isBeingDragged={snapshot.isDragging && !snapshot.isDropAnimating}>
|
||||
<Title>{issue.title}</Title>
|
||||
<Bottom>
|
||||
<div>
|
||||
<IssueTypeIcon type={issue.type} />
|
||||
<IssuePriorityIcon priority={issue.priority} top={-1} left={4} />
|
||||
</div>
|
||||
<Assignees>
|
||||
{assignees.map(user => (
|
||||
<AssigneeAvatar
|
||||
key={user.id}
|
||||
size={24}
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
))}
|
||||
</Assignees>
|
||||
</Bottom>
|
||||
</Issue>
|
||||
</IssueLink>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardListsIssue.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardListsIssue;
|
||||
35
client/src/Project/Board/Lists/Styles.js
Normal file
35
client/src/Project/Board/Lists/Styles.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
|
||||
export const Lists = styled.div`
|
||||
display: flex;
|
||||
margin: 26px -5px 0;
|
||||
`;
|
||||
|
||||
export const List = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 5px;
|
||||
min-height: 400px;
|
||||
width: 25%;
|
||||
border-radius: 3px;
|
||||
background: ${color.backgroundLightest};
|
||||
`;
|
||||
|
||||
export const Title = styled.div`
|
||||
padding: 13px 10px 17px;
|
||||
text-transform: uppercase;
|
||||
color: ${color.textMedium};
|
||||
${font.size(12.5)};
|
||||
`;
|
||||
|
||||
export const IssuesCount = styled.span`
|
||||
text-transform: lowercase;
|
||||
${font.size(13)};
|
||||
`;
|
||||
|
||||
export const Issues = styled.div`
|
||||
height: 100%;
|
||||
padding: 0 5px;
|
||||
`;
|
||||
137
client/src/Project/Board/Lists/index.jsx
Normal file
137
client/src/Project/Board/Lists/index.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||
import { get, intersection } from 'lodash';
|
||||
|
||||
import api from 'shared/utils/api';
|
||||
import useApi from 'shared/hooks/api';
|
||||
import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/javascript';
|
||||
import { IssueStatus, IssueStatusCopy } from 'shared/constants/issues';
|
||||
import Issue from './Issue';
|
||||
import { Lists, List, Title, IssuesCount, Issues } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
project: PropTypes.object.isRequired,
|
||||
filters: PropTypes.object.isRequired,
|
||||
updateLocalIssuesArray: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardLists = ({ project, filters, updateLocalIssuesArray }) => {
|
||||
const [{ data: currentUserData }] = useApi.get('/currentUser');
|
||||
const currentUserId = get(currentUserData, 'currentUser.id');
|
||||
|
||||
const filteredIssues = filterIssues(project.issues, filters, currentUserId);
|
||||
|
||||
const handleIssueDrop = async ({ draggableId, destination, source }) => {
|
||||
if (!destination) return;
|
||||
const isSameList = destination.droppableId === source.droppableId;
|
||||
const isSamePosition = destination.index === source.index;
|
||||
if (isSameList && isSamePosition) return;
|
||||
|
||||
const issueId = Number(draggableId);
|
||||
|
||||
api.optimisticUpdate({
|
||||
url: `/issues/${issueId}`,
|
||||
updatedFields: {
|
||||
status: destination.droppableId,
|
||||
listPosition: calculateListPosition(project.issues, destination, isSameList, issueId),
|
||||
},
|
||||
currentFields: project.issues.find(({ id }) => id === issueId),
|
||||
setLocalData: fields => updateLocalIssuesArray(issueId, fields),
|
||||
});
|
||||
};
|
||||
|
||||
const renderList = status => {
|
||||
const filteredListIssues = getSortedListIssues(filteredIssues, status);
|
||||
const allListIssues = getSortedListIssues(project.issues, status);
|
||||
|
||||
const issuesCount =
|
||||
allListIssues.length !== filteredListIssues.length
|
||||
? `${filteredListIssues.length} of ${allListIssues.length}`
|
||||
: allListIssues.length;
|
||||
|
||||
return (
|
||||
<Droppable key={status} droppableId={status}>
|
||||
{provided => (
|
||||
<List>
|
||||
<Title>
|
||||
{`${IssueStatusCopy[status]} `}
|
||||
<IssuesCount>{issuesCount}</IssuesCount>
|
||||
</Title>
|
||||
<Issues {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{filteredListIssues.map((issue, index) => (
|
||||
<Issue key={issue.id} projectUsers={project.users} issue={issue} index={index} />
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Issues>
|
||||
</List>
|
||||
)}
|
||||
</Droppable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DragDropContext onDragEnd={handleIssueDrop}>
|
||||
<Lists>{Object.values(IssueStatus).map(renderList)}</Lists>
|
||||
</DragDropContext>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const filterIssues = (projectIssues, filters, currentUserId) => {
|
||||
let issues = projectIssues;
|
||||
const { searchQuery, userIds, myOnly, recent } = filters;
|
||||
|
||||
if (searchQuery) {
|
||||
issues = issues.filter(issue => issue.title.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}
|
||||
if (userIds.length > 0) {
|
||||
issues = issues.filter(issue => intersection(issue.userIds, userIds).length > 0);
|
||||
}
|
||||
if (myOnly && currentUserId) {
|
||||
issues = issues.filter(issue => issue.userIds.includes(currentUserId));
|
||||
}
|
||||
if (recent) {
|
||||
issues = issues.filter(issue => moment(issue.updatedAt).isAfter(moment().subtract(3, 'days')));
|
||||
}
|
||||
return issues;
|
||||
};
|
||||
|
||||
const getSortedListIssues = (issues, status) =>
|
||||
issues.filter(issue => issue.status === status).sort((a, b) => a.listPosition - b.listPosition);
|
||||
|
||||
const calculateListPosition = (...args) => {
|
||||
const { prevIssue, nextIssue } = getAfterDropPrevNextIssue(...args);
|
||||
let position;
|
||||
|
||||
if (!prevIssue && !nextIssue) {
|
||||
position = 1;
|
||||
} else if (!prevIssue) {
|
||||
position = nextIssue.listPosition - 1;
|
||||
} else if (!nextIssue) {
|
||||
position = prevIssue.listPosition + 1;
|
||||
} else {
|
||||
position = prevIssue.listPosition + (nextIssue.listPosition - prevIssue.listPosition) / 2;
|
||||
}
|
||||
return position;
|
||||
};
|
||||
|
||||
const getAfterDropPrevNextIssue = (allIssues, destination, isSameList, droppedIssueId) => {
|
||||
const destinationIssues = getSortedListIssues(allIssues, destination.droppableId);
|
||||
const droppedIssue = allIssues.find(issue => issue.id === droppedIssueId);
|
||||
|
||||
const afterDropDestinationIssues = isSameList
|
||||
? moveItemWithinArray(destinationIssues, droppedIssue, destination.index)
|
||||
: insertItemIntoArray(destinationIssues, droppedIssue, destination.index);
|
||||
|
||||
return {
|
||||
prevIssue: afterDropDestinationIssues[destination.index - 1],
|
||||
nextIssue: afterDropDestinationIssues[destination.index + 1],
|
||||
};
|
||||
};
|
||||
|
||||
ProjectBoardLists.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardLists;
|
||||
65
client/src/Project/Board/index.jsx
Normal file
65
client/src/Project/Board/index.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Route, useRouteMatch, useHistory } from 'react-router-dom';
|
||||
|
||||
import { Modal } from 'shared/components';
|
||||
import Header from './Header';
|
||||
import Filters from './Filters';
|
||||
import Lists from './Lists';
|
||||
import IssueDetails from './IssueDetails';
|
||||
|
||||
const propTypes = {
|
||||
project: PropTypes.object.isRequired,
|
||||
fetchProject: PropTypes.func.isRequired,
|
||||
updateLocalIssuesArray: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const defaultFilters = {
|
||||
searchQuery: '',
|
||||
userIds: [],
|
||||
myOnly: false,
|
||||
recent: false,
|
||||
};
|
||||
|
||||
const ProjectBoard = ({ project, fetchProject, updateLocalIssuesArray }) => {
|
||||
const match = useRouteMatch();
|
||||
const history = useHistory();
|
||||
const [filters, setFilters] = useState(defaultFilters);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header projectName={project.name} />
|
||||
<Filters
|
||||
projectUsers={project.users}
|
||||
defaultFilters={defaultFilters}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
/>
|
||||
<Lists project={project} filters={filters} updateLocalIssuesArray={updateLocalIssuesArray} />
|
||||
<Route
|
||||
path={`${match.path}/:issueId`}
|
||||
render={({ match: { params } }) => (
|
||||
<Modal
|
||||
isOpen
|
||||
width={1040}
|
||||
withCloseIcon={false}
|
||||
onClose={() => history.push(match.url)}
|
||||
renderContent={modal => (
|
||||
<IssueDetails
|
||||
issueId={params.issueId}
|
||||
projectUsers={project.users}
|
||||
fetchProject={fetchProject}
|
||||
updateLocalIssuesArray={updateLocalIssuesArray}
|
||||
modalClose={modal.close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoard.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoard;
|
||||
93
client/src/Project/Sidebar/Styles.js
Normal file
93
client/src/Project/Sidebar/Styles.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { color, sizes, font, mixin, zIndexValues } from 'shared/utils/styles';
|
||||
|
||||
export const Sidebar = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${zIndexValues.navLeft - 1};
|
||||
top: 0;
|
||||
left: ${sizes.appNavBarLeftWidth}px;
|
||||
height: 100vh;
|
||||
width: 240px;
|
||||
padding: 0 16px;
|
||||
background: ${color.backgroundLightest};
|
||||
border-right: 1px solid ${color.borderLightest};
|
||||
`;
|
||||
|
||||
export const ProjectInfo = styled.div`
|
||||
display: flex;
|
||||
padding: 24px 4px;
|
||||
`;
|
||||
|
||||
export const ProjectTexts = styled.div`
|
||||
padding: 3px 0 0 10px;
|
||||
`;
|
||||
|
||||
export const ProjectName = styled.div`
|
||||
color: ${color.textDark};
|
||||
${font.size(15)};
|
||||
${font.medium};
|
||||
`;
|
||||
|
||||
export const ProjectCategory = styled.div`
|
||||
color: ${color.textMedium};
|
||||
${font.size(13)};
|
||||
`;
|
||||
|
||||
export const Divider = styled.div`
|
||||
margin-top: 17px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid ${color.borderLight};
|
||||
`;
|
||||
|
||||
export const LinkItem = styled(NavLink)`
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
border-radius: 3px;
|
||||
color: ${color.textDark};
|
||||
${mixin.clickable}
|
||||
${props =>
|
||||
!props.implemented
|
||||
? `cursor: not-allowed;`
|
||||
: css`
|
||||
&:hover {
|
||||
background: ${color.backgroundLight};
|
||||
}
|
||||
`}
|
||||
i {
|
||||
margin-right: 15px;
|
||||
font-size: 20px;
|
||||
color: ${color.textDarkest};
|
||||
}
|
||||
&.active {
|
||||
color: ${color.primary};
|
||||
background: ${color.backgroundLight};
|
||||
i {
|
||||
color: ${color.primary};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LinkText = styled.div`
|
||||
padding-top: 2px;
|
||||
${font.size(14.7)};
|
||||
`;
|
||||
|
||||
export const NotImplemented = styled.div`
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: 104%;
|
||||
width: 120px;
|
||||
padding: 3px 0 3px 8px;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
${font.size(12.5)};
|
||||
${font.medium}
|
||||
${LinkItem}:hover & {
|
||||
display: inline-block;
|
||||
}
|
||||
`;
|
||||
53
client/src/Project/Sidebar/index.jsx
Normal file
53
client/src/Project/Sidebar/index.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Icon, ProjectAvatar } from 'shared/components';
|
||||
import {
|
||||
Sidebar,
|
||||
ProjectInfo,
|
||||
ProjectTexts,
|
||||
ProjectName,
|
||||
ProjectCategory,
|
||||
Divider,
|
||||
LinkItem,
|
||||
LinkText,
|
||||
NotImplemented,
|
||||
} from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
projectName: PropTypes.string.isRequired,
|
||||
matchPath: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const ProjectSidebar = ({ projectName, matchPath }) => {
|
||||
const renderLinkItem = (text, iconType, path = '') => (
|
||||
<LinkItem exact to={`${matchPath}${path}`} implemented={path}>
|
||||
<Icon type={iconType} />
|
||||
<LinkText>{text}</LinkText>
|
||||
{!path && <NotImplemented>Not implemented</NotImplemented>}
|
||||
</LinkItem>
|
||||
);
|
||||
return (
|
||||
<Sidebar>
|
||||
<ProjectInfo>
|
||||
<ProjectAvatar />
|
||||
<ProjectTexts>
|
||||
<ProjectName>{projectName}</ProjectName>
|
||||
<ProjectCategory>Software project</ProjectCategory>
|
||||
</ProjectTexts>
|
||||
</ProjectInfo>
|
||||
{renderLinkItem('Kanban Board', 'board', '/board')}
|
||||
{renderLinkItem('Reports', 'reports')}
|
||||
<Divider />
|
||||
{renderLinkItem('Releases', 'shipping')}
|
||||
{renderLinkItem('Issues and filters', 'issues')}
|
||||
{renderLinkItem('Pages', 'page')}
|
||||
{renderLinkItem('Components', 'component')}
|
||||
{renderLinkItem('Project settings', 'settings')}
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectSidebar.propTypes = propTypes;
|
||||
|
||||
export default ProjectSidebar;
|
||||
7
client/src/Project/Styles.js
Normal file
7
client/src/Project/Styles.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { sizes } from 'shared/utils/styles';
|
||||
|
||||
export const ProjectPage = styled.div`
|
||||
padding: 25px 32px 0 ${sizes.secondarySideBarWidth + 40}px;
|
||||
`;
|
||||
50
client/src/Project/index.jsx
Normal file
50
client/src/Project/index.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Route, Redirect, useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import useApi from 'shared/hooks/api';
|
||||
import { updateArrayItemById } from 'shared/utils/javascript';
|
||||
import { PageLoader, PageError } from 'shared/components';
|
||||
import Sidebar from './Sidebar';
|
||||
import Board from './Board';
|
||||
import { ProjectPage } from './Styles';
|
||||
|
||||
const Project = () => {
|
||||
const match = useRouteMatch();
|
||||
const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project');
|
||||
|
||||
const updateLocalIssuesArray = (issueId, updatedFields) => {
|
||||
setLocalData(currentData => ({
|
||||
project: {
|
||||
...currentData.project,
|
||||
issues: updateArrayItemById(data.project.issues, issueId, updatedFields),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
if (!data) return <PageLoader />;
|
||||
if (error) return <PageError />;
|
||||
|
||||
const { project } = data;
|
||||
|
||||
const renderBoard = () => (
|
||||
<Board
|
||||
project={project}
|
||||
fetchProject={fetchProject}
|
||||
updateLocalIssuesArray={updateLocalIssuesArray}
|
||||
/>
|
||||
);
|
||||
const renderSettings = () => <h1>SETTINGS</h1>;
|
||||
const renderIssues = () => <h1>ISSUES</h1>;
|
||||
|
||||
return (
|
||||
<ProjectPage>
|
||||
<Sidebar projectName={project.name} matchPath={match.path} />
|
||||
<Route path={`${match.path}/board`} render={renderBoard} />
|
||||
<Route path={`${match.path}/settings`} render={renderSettings} />
|
||||
<Route path={`${match.path}/issues`} render={renderIssues} />
|
||||
{match.isExact && <Redirect to={`${match.url}/board`} />}
|
||||
</ProjectPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Project;
|
||||
Reference in New Issue
Block a user