Refactored code structure
This commit is contained in:
@@ -12,25 +12,12 @@ const propTypes = {
|
||||
projectUsers: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsUsers = ({ issue, updateIssue, projectUsers }) => {
|
||||
const ProjectBoardIssueDetailsAssigneesReporter = ({ issue, updateIssue, projectUsers }) => {
|
||||
const getUserById = userId => projectUsers.find(user => user.id === userId);
|
||||
|
||||
const userOptions = projectUsers.map(user => ({ value: user.id, label: user.name }));
|
||||
|
||||
const renderUser = (user, isSelectValue, removeOptionValue) => (
|
||||
<User
|
||||
key={user.id}
|
||||
isSelectValue={isSelectValue}
|
||||
withBottomMargin={!!removeOptionValue}
|
||||
onClick={() => removeOptionValue && removeOptionValue()}
|
||||
>
|
||||
<Avatar avatarUrl={user.avatarUrl} name={user.name} size={24} />
|
||||
<Username>{user.name}</Username>
|
||||
{removeOptionValue && <Icon type="close" top={1} />}
|
||||
</User>
|
||||
);
|
||||
|
||||
const renderAssignees = () => (
|
||||
return (
|
||||
<>
|
||||
<SectionTitle>Assignees</SectionTitle>
|
||||
<Select
|
||||
@@ -43,16 +30,12 @@ const ProjectBoardIssueDetailsUsers = ({ issue, updateIssue, projectUsers }) =>
|
||||
onChange={userIds => {
|
||||
updateIssue({ userIds, users: userIds.map(getUserById) });
|
||||
}}
|
||||
renderValue={({ value, removeOptionValue }) =>
|
||||
renderUser(getUserById(value), true, removeOptionValue)
|
||||
renderValue={({ value: userId, removeOptionValue }) =>
|
||||
renderUser(getUserById(userId), true, removeOptionValue)
|
||||
}
|
||||
renderOption={({ value }) => renderUser(getUserById(value), false)}
|
||||
renderOption={({ value: userId }) => renderUser(getUserById(userId), false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderReporter = () => (
|
||||
<>
|
||||
<SectionTitle>Reporter</SectionTitle>
|
||||
<Select
|
||||
variant="empty"
|
||||
@@ -61,20 +44,26 @@ const ProjectBoardIssueDetailsUsers = ({ issue, updateIssue, projectUsers }) =>
|
||||
value={issue.reporterId}
|
||||
options={userOptions}
|
||||
onChange={userId => updateIssue({ reporterId: userId })}
|
||||
renderValue={({ value }) => renderUser(getUserById(value), true)}
|
||||
renderOption={({ value }) => renderUser(getUserById(value))}
|
||||
renderValue={({ value: userId }) => renderUser(getUserById(userId), true)}
|
||||
renderOption={({ value: userId }) => renderUser(getUserById(userId))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderAssignees()}
|
||||
{renderReporter()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsUsers.propTypes = propTypes;
|
||||
const renderUser = (user, isSelectValue, removeOptionValue) => (
|
||||
<User
|
||||
key={user.id}
|
||||
isSelectValue={isSelectValue}
|
||||
withBottomMargin={!!removeOptionValue}
|
||||
onClick={() => removeOptionValue && removeOptionValue()}
|
||||
>
|
||||
<Avatar avatarUrl={user.avatarUrl} name={user.name} size={24} />
|
||||
<Username>{user.name}</Username>
|
||||
{removeOptionValue && <Icon type="close" top={1} />}
|
||||
</User>
|
||||
);
|
||||
|
||||
export default ProjectBoardIssueDetailsUsers;
|
||||
ProjectBoardIssueDetailsAssigneesReporter.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsAssigneesReporter;
|
||||
@@ -22,6 +22,12 @@ const ProjectBoardIssueDetailsCommentsBodyForm = ({
|
||||
}) => {
|
||||
const $textareaRef = useRef();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if ($textareaRef.current.value.trim()) {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Textarea
|
||||
@@ -32,15 +38,7 @@ const ProjectBoardIssueDetailsCommentsBodyForm = ({
|
||||
ref={$textareaRef}
|
||||
/>
|
||||
<Actions>
|
||||
<FormButton
|
||||
variant="primary"
|
||||
isWorking={isWorking}
|
||||
onClick={() => {
|
||||
if ($textareaRef.current.value.trim()) {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormButton variant="primary" isWorking={isWorking} onClick={handleSubmit}>
|
||||
Save
|
||||
</FormButton>
|
||||
<FormButton variant="empty" onClick={onCancel}>
|
||||
@@ -15,42 +15,41 @@ const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
|
||||
const [description, setDescription] = useState(issue.description);
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
|
||||
const isDescriptionEmpty = getTextContentsFromHtmlString(description).trim().length === 0;
|
||||
|
||||
const handleUpdate = () => {
|
||||
setEditing(false);
|
||||
updateIssue({ description });
|
||||
};
|
||||
|
||||
const renderPresentingMode = () =>
|
||||
isDescriptionEmpty ? (
|
||||
<EmptyLabel onClick={() => setEditing(true)}>Add a description...</EmptyLabel>
|
||||
) : (
|
||||
<TextEditedContent content={description} onClick={() => setEditing(true)} />
|
||||
);
|
||||
|
||||
const renderEditingMode = () => (
|
||||
<>
|
||||
<TextEditor
|
||||
placeholder="Describe the issue"
|
||||
defaultValue={description}
|
||||
onChange={setDescription}
|
||||
/>
|
||||
<Actions>
|
||||
<Button variant="primary" onClick={handleUpdate}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="empty" onClick={() => setEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Actions>
|
||||
</>
|
||||
);
|
||||
const isDescriptionEmpty = getTextContentsFromHtmlString(description).trim().length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Description</Title>
|
||||
{isEditing ? renderEditingMode() : renderPresentingMode()}
|
||||
{isEditing ? (
|
||||
<>
|
||||
<TextEditor
|
||||
placeholder="Describe the issue"
|
||||
defaultValue={description}
|
||||
onChange={setDescription}
|
||||
/>
|
||||
<Actions>
|
||||
<Button variant="primary" onClick={handleUpdate}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="empty" onClick={() => setEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Actions>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isDescriptionEmpty ? (
|
||||
<EmptyLabel onClick={() => setEditing(true)}>Add a description...</EmptyLabel>
|
||||
) : (
|
||||
<TextEditedContent content={description} onClick={() => setEditing(true)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
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;
|
||||
@@ -13,41 +12,6 @@ export const TrackingLink = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
`;
|
||||
@@ -0,0 +1,39 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
import { Icon } from 'shared/components';
|
||||
|
||||
export const TrackingWidget = 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)};
|
||||
`;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { TrackingWidget, WatchIcon, Right, BarCont, Bar, Values } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsTrackingWidget = ({ issue }) => (
|
||||
<TrackingWidget>
|
||||
<WatchIcon type="stopwatch" size={26} top={-1} />
|
||||
<Right>
|
||||
<BarCont>
|
||||
<Bar width={calculateTrackingBarWidth(issue)} />
|
||||
</BarCont>
|
||||
<Values>
|
||||
<div>{issue.timeSpent ? `${issue.timeSpent}h logged` : 'No time logged'}</div>
|
||||
{renderRemainingOrEstimate(issue)}
|
||||
</Values>
|
||||
</Right>
|
||||
</TrackingWidget>
|
||||
);
|
||||
|
||||
const calculateTrackingBarWidth = ({ timeSpent, timeRemaining, estimate }) => {
|
||||
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 = ({ timeRemaining, estimate }) => {
|
||||
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>;
|
||||
}
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsTrackingWidget.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsTrackingWidget;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { InputDebounced, Modal, Button } from 'shared/components';
|
||||
|
||||
import TrackingWidget from './TrackingWidget';
|
||||
import { SectionTitle } from '../Styles';
|
||||
import {
|
||||
TrackingLink,
|
||||
ModalContents,
|
||||
ModalTitle,
|
||||
Inputs,
|
||||
InputCont,
|
||||
InputLabel,
|
||||
Actions,
|
||||
} from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
updateIssue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsEstimateTracking = ({ issue, updateIssue }) => (
|
||||
<>
|
||||
<SectionTitle>Original Estimate (hours)</SectionTitle>
|
||||
{renderHourInput('estimate', issue, updateIssue)}
|
||||
|
||||
<SectionTitle>Time Tracking</SectionTitle>
|
||||
<Modal
|
||||
width={400}
|
||||
renderLink={modal => (
|
||||
<TrackingLink onClick={modal.open}>
|
||||
<TrackingWidget issue={issue} />
|
||||
</TrackingLink>
|
||||
)}
|
||||
renderContent={modal => (
|
||||
<ModalContents>
|
||||
<ModalTitle>Time tracking</ModalTitle>
|
||||
<TrackingWidget issue={issue} />
|
||||
<Inputs>
|
||||
<InputCont>
|
||||
<InputLabel>Time spent (hours)</InputLabel>
|
||||
{renderHourInput('timeSpent', issue, updateIssue)}
|
||||
</InputCont>
|
||||
<InputCont>
|
||||
<InputLabel>Time remaining (hours)</InputLabel>
|
||||
{renderHourInput('timeRemaining', issue, updateIssue)}
|
||||
</InputCont>
|
||||
</Inputs>
|
||||
<Actions>
|
||||
<Button variant="primary" onClick={modal.close}>
|
||||
Done
|
||||
</Button>
|
||||
</Actions>
|
||||
</ModalContents>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderHourInput = (fieldName, issue, updateIssue) => (
|
||||
<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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
ProjectBoardIssueDetailsEstimateTracking.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsEstimateTracking;
|
||||
43
client/src/Project/Board/IssueDetails/Priority/index.jsx
Normal file
43
client/src/Project/Board/IssueDetails/Priority/index.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { IssuePriority, IssuePriorityCopy } from 'shared/constants/issues';
|
||||
import { Select, IssuePriorityIcon } from 'shared/components';
|
||||
|
||||
import { SectionTitle } from '../Styles';
|
||||
import { Priority, Label } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
issue: PropTypes.object.isRequired,
|
||||
updateIssue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardIssueDetailsPriority = ({ issue, updateIssue }) => (
|
||||
<>
|
||||
<SectionTitle>Priority</SectionTitle>
|
||||
<Select
|
||||
variant="empty"
|
||||
withClearValue={false}
|
||||
dropdownWidth={343}
|
||||
value={issue.priority}
|
||||
options={Object.values(IssuePriority).map(priority => ({
|
||||
value: priority,
|
||||
label: IssuePriorityCopy[priority],
|
||||
}))}
|
||||
onChange={priority => updateIssue({ priority })}
|
||||
renderValue={({ value: priority }) => renderPriorityItem(priority, true)}
|
||||
renderOption={({ value: priority }) => renderPriorityItem(priority)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderPriorityItem = (priority, isValue) => (
|
||||
<Priority isValue={isValue}>
|
||||
<IssuePriorityIcon priority={priority} />
|
||||
<Label>{IssuePriorityCopy[priority]}</Label>
|
||||
</Priority>
|
||||
);
|
||||
|
||||
ProjectBoardIssueDetailsPriority.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsPriority;
|
||||
@@ -12,9 +12,9 @@ import Title from './Title';
|
||||
import Description from './Description';
|
||||
import Comments from './Comments';
|
||||
import Status from './Status';
|
||||
import Users from './Users';
|
||||
import AssigneesReporter from './AssigneesReporter';
|
||||
import Priority from './Priority';
|
||||
import Tracking from './Tracking';
|
||||
import EstimateTracking from './EstimateTracking';
|
||||
import Dates from './Dates';
|
||||
import { TopActions, TopActionsRight, Content, Left, Right } from './Styles';
|
||||
|
||||
@@ -79,9 +79,9 @@ const ProjectBoardIssueDetails = ({
|
||||
</Left>
|
||||
<Right>
|
||||
<Status issue={issue} updateIssue={updateIssue} />
|
||||
<Users issue={issue} updateIssue={updateIssue} projectUsers={projectUsers} />
|
||||
<AssigneesReporter issue={issue} updateIssue={updateIssue} projectUsers={projectUsers} />
|
||||
<Priority issue={issue} updateIssue={updateIssue} />
|
||||
<Tracking issue={issue} updateIssue={updateIssue} />
|
||||
<EstimateTracking issue={issue} updateIssue={updateIssue} />
|
||||
<Dates issue={issue} />
|
||||
</Right>
|
||||
</Content>
|
||||
@@ -13,11 +13,10 @@ const propTypes = {
|
||||
index: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardListsIssue = ({ projectUsers, issue, index }) => {
|
||||
const ProjectBoardListIssue = ({ projectUsers, issue, index }) => {
|
||||
const match = useRouteMatch();
|
||||
|
||||
const getUserById = userId => projectUsers.find(user => user.id === userId);
|
||||
const assignees = issue.userIds.map(getUserById);
|
||||
const assignees = issue.userIds.map(userId => projectUsers.find(user => user.id === userId));
|
||||
|
||||
return (
|
||||
<Draggable draggableId={issue.id.toString()} index={index}>
|
||||
@@ -53,6 +52,6 @@ const ProjectBoardListsIssue = ({ projectUsers, issue, index }) => {
|
||||
);
|
||||
};
|
||||
|
||||
ProjectBoardListsIssue.propTypes = propTypes;
|
||||
ProjectBoardListIssue.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardListsIssue;
|
||||
export default ProjectBoardListIssue;
|
||||
30
client/src/Project/Board/Lists/List/Styles.js
Normal file
30
client/src/Project/Board/Lists/List/Styles.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { color, font } from 'shared/utils/styles';
|
||||
|
||||
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;
|
||||
`;
|
||||
77
client/src/Project/Board/Lists/List/index.jsx
Normal file
77
client/src/Project/Board/Lists/List/index.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import { Droppable } from 'react-beautiful-dnd';
|
||||
import { intersection } from 'lodash';
|
||||
|
||||
import useCurrentUser from 'shared/hooks/currentUser';
|
||||
import { IssueStatusCopy } from 'shared/constants/issues';
|
||||
|
||||
import Issue from './Issue';
|
||||
import { List, Title, IssuesCount, Issues } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
status: PropTypes.string.isRequired,
|
||||
project: PropTypes.object.isRequired,
|
||||
filters: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardList = ({ status, project, filters }) => {
|
||||
const { currentUserId } = useCurrentUser();
|
||||
|
||||
const filteredIssues = filterIssues(project.issues, filters, currentUserId);
|
||||
const filteredListIssues = getSortedListIssues(filteredIssues, status);
|
||||
const allListIssues = getSortedListIssues(project.issues, status);
|
||||
|
||||
return (
|
||||
<Droppable key={status} droppableId={status}>
|
||||
{provided => (
|
||||
<List>
|
||||
<Title>
|
||||
{`${IssueStatusCopy[status]} `}
|
||||
<IssuesCount>{formatIssuesCount(allListIssues, filteredListIssues)}</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>
|
||||
);
|
||||
};
|
||||
|
||||
const filterIssues = (projectIssues, filters, currentUserId) => {
|
||||
const { searchTerm, userIds, myOnly, recent } = filters;
|
||||
let issues = projectIssues;
|
||||
|
||||
if (searchTerm) {
|
||||
issues = issues.filter(issue => issue.title.toLowerCase().includes(searchTerm.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 formatIssuesCount = (allListIssues, filteredListIssues) => {
|
||||
if (allListIssues.length !== filteredListIssues.length) {
|
||||
return `${filteredListIssues.length} of ${allListIssues.length}`;
|
||||
}
|
||||
return allListIssues.length;
|
||||
};
|
||||
|
||||
ProjectBoardList.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardList;
|
||||
@@ -1,35 +1,6 @@
|
||||
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;
|
||||
`;
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||
import { intersection } from 'lodash';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
|
||||
import api from 'shared/utils/api';
|
||||
import useCurrentUser from 'shared/hooks/currentUser';
|
||||
import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/javascript';
|
||||
import { IssueStatus, IssueStatusCopy } from 'shared/constants/issues';
|
||||
import { IssueStatus } from 'shared/constants/issues';
|
||||
|
||||
import Issue from './Issue';
|
||||
import { Lists, List, Title, IssuesCount, Issues } from './Styles';
|
||||
import List from './List';
|
||||
import { Lists } from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
project: PropTypes.object.isRequired,
|
||||
@@ -19,9 +16,7 @@ const propTypes = {
|
||||
};
|
||||
|
||||
const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => {
|
||||
const { currentUserId } = useCurrentUser();
|
||||
|
||||
const handleIssueDrop = async ({ draggableId, destination, source }) => {
|
||||
const handleIssueDrop = ({ draggableId, destination, source }) => {
|
||||
if (!isPositionChanged(source, destination)) return;
|
||||
|
||||
const issueId = Number(draggableId);
|
||||
@@ -29,76 +24,24 @@ const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => {
|
||||
api.optimisticUpdate(`/issues/${issueId}`, {
|
||||
updatedFields: {
|
||||
status: destination.droppableId,
|
||||
listPosition: calculateListPosition(project.issues, destination, source, issueId),
|
||||
listPosition: calculateIssueListPosition(project.issues, destination, source, issueId),
|
||||
},
|
||||
currentFields: project.issues.find(({ id }) => id === issueId),
|
||||
setLocalData: fields => updateLocalProjectIssues(issueId, fields),
|
||||
});
|
||||
};
|
||||
|
||||
const renderList = status => {
|
||||
const filteredIssues = filterIssues(project.issues, filters, currentUserId);
|
||||
const filteredListIssues = getSortedListIssues(filteredIssues, status);
|
||||
const allListIssues = getSortedListIssues(project.issues, status);
|
||||
|
||||
return (
|
||||
<Droppable key={status} droppableId={status}>
|
||||
{provided => (
|
||||
<List>
|
||||
<Title>
|
||||
{`${IssueStatusCopy[status]} `}
|
||||
<IssuesCount>{formatIssuesCount(allListIssues, filteredListIssues)}</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>
|
||||
</>
|
||||
<DragDropContext onDragEnd={handleIssueDrop}>
|
||||
<Lists>
|
||||
{Object.values(IssueStatus).map(status => (
|
||||
<List key={status} status={status} project={project} filters={filters} />
|
||||
))}
|
||||
</Lists>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
const filterIssues = (projectIssues, filters, currentUserId) => {
|
||||
const { searchTerm, userIds, myOnly, recent } = filters;
|
||||
let issues = projectIssues;
|
||||
|
||||
if (searchTerm) {
|
||||
issues = issues.filter(issue => issue.title.toLowerCase().includes(searchTerm.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 formatIssuesCount = (allListIssues, filteredListIssues) => {
|
||||
if (allListIssues.length !== filteredListIssues.length) {
|
||||
return `${filteredListIssues.length} of ${allListIssues.length}`;
|
||||
}
|
||||
return allListIssues.length;
|
||||
};
|
||||
|
||||
const isPositionChanged = (destination, source) => {
|
||||
if (!destination) return false;
|
||||
const isSameList = destination.droppableId === source.droppableId;
|
||||
@@ -106,7 +49,7 @@ const isPositionChanged = (destination, source) => {
|
||||
return !isSameList || !isSamePosition;
|
||||
};
|
||||
|
||||
const calculateListPosition = (...args) => {
|
||||
const calculateIssueListPosition = (...args) => {
|
||||
const { prevIssue, nextIssue } = getAfterDropPrevNextIssue(...args);
|
||||
let position;
|
||||
|
||||
@@ -123,13 +66,13 @@ const calculateListPosition = (...args) => {
|
||||
};
|
||||
|
||||
const getAfterDropPrevNextIssue = (allIssues, destination, source, droppedIssueId) => {
|
||||
const destinationIssues = getSortedListIssues(allIssues, destination.droppableId);
|
||||
const beforeDropDestinationIssues = getSortedListIssues(allIssues, destination.droppableId);
|
||||
const droppedIssue = allIssues.find(issue => issue.id === droppedIssueId);
|
||||
const isSameList = destination.droppableId === source.droppableId;
|
||||
|
||||
const afterDropDestinationIssues = isSameList
|
||||
? moveItemWithinArray(destinationIssues, droppedIssue, destination.index)
|
||||
: insertItemIntoArray(destinationIssues, droppedIssue, destination.index);
|
||||
? moveItemWithinArray(beforeDropDestinationIssues, droppedIssue, destination.index)
|
||||
: insertItemIntoArray(beforeDropDestinationIssues, droppedIssue, destination.index);
|
||||
|
||||
return {
|
||||
prevIssue: afterDropDestinationIssues[destination.index - 1],
|
||||
@@ -137,6 +80,9 @@ const getAfterDropPrevNextIssue = (allIssues, destination, source, droppedIssueI
|
||||
};
|
||||
};
|
||||
|
||||
const getSortedListIssues = (issues, status) =>
|
||||
issues.filter(issue => issue.status === status).sort((a, b) => a.listPosition - b.listPosition);
|
||||
|
||||
ProjectBoardLists.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardLists;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Route, useRouteMatch, useHistory } from 'react-router-dom';
|
||||
|
||||
import useMergeState from 'shared/hooks/mergeState';
|
||||
import { Breadcrumbs } from 'shared/components';
|
||||
import { Breadcrumbs, 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,
|
||||
updateLocalProjectIssues: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -20,7 +23,10 @@ const defaultFilters = {
|
||||
recent: false,
|
||||
};
|
||||
|
||||
const ProjectBoard = ({ project, updateLocalProjectIssues }) => {
|
||||
const ProjectBoard = ({ project, fetchProject, updateLocalProjectIssues }) => {
|
||||
const match = useRouteMatch();
|
||||
const history = useHistory();
|
||||
|
||||
const [filters, mergeFilters] = useMergeState(defaultFilters);
|
||||
|
||||
return (
|
||||
@@ -38,6 +44,26 @@ const ProjectBoard = ({ project, updateLocalProjectIssues }) => {
|
||||
filters={filters}
|
||||
updateLocalProjectIssues={updateLocalProjectIssues}
|
||||
/>
|
||||
<Route
|
||||
path={`${match.path}/issues/:issueId`}
|
||||
render={routeProps => (
|
||||
<Modal
|
||||
isOpen
|
||||
width={1040}
|
||||
withCloseIcon={false}
|
||||
onClose={() => history.push(match.url)}
|
||||
renderContent={modal => (
|
||||
<IssueDetails
|
||||
issueId={routeProps.match.params.issueId}
|
||||
projectUsers={project.users}
|
||||
fetchProject={fetchProject}
|
||||
updateLocalProjectIssues={updateLocalProjectIssues}
|
||||
modalClose={modal.close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,52 +35,10 @@ const ProjectIssueCreateForm = ({ project, fetchProject, onCreate, modalClose })
|
||||
|
||||
const { currentUserId } = useCurrentUser();
|
||||
|
||||
const typeOptions = Object.values(IssueType).map(type => ({
|
||||
value: type,
|
||||
label: IssueTypeCopy[type],
|
||||
}));
|
||||
|
||||
const priorityOptions = Object.values(IssuePriority).map(priority => ({
|
||||
value: priority,
|
||||
label: IssuePriorityCopy[priority],
|
||||
}));
|
||||
|
||||
const userOptions = project.users.map(user => ({ value: user.id, label: user.name }));
|
||||
|
||||
const renderType = ({ value: type }) => (
|
||||
<SelectItem>
|
||||
<IssueTypeIcon type={type} top={1} />
|
||||
<SelectItemLabel>{IssueTypeCopy[type]}</SelectItemLabel>
|
||||
</SelectItem>
|
||||
);
|
||||
|
||||
const renderPriority = ({ value: priority }) => (
|
||||
<SelectItem>
|
||||
<IssuePriorityIcon priority={priority} top={1} />
|
||||
<SelectItemLabel>{IssuePriorityCopy[priority]}</SelectItemLabel>
|
||||
</SelectItem>
|
||||
);
|
||||
|
||||
const renderUser = ({ value: userId, removeOptionValue }) => {
|
||||
const user = project.users.find(({ id }) => id === userId);
|
||||
return (
|
||||
<SelectItem
|
||||
key={user.id}
|
||||
withBottomMargin={!!removeOptionValue}
|
||||
onClick={() => removeOptionValue && removeOptionValue()}
|
||||
>
|
||||
<Avatar size={20} avatarUrl={user.avatarUrl} name={user.name} />
|
||||
<SelectItemLabel>{user.name}</SelectItemLabel>
|
||||
{removeOptionValue && <Icon type="close" top={2} />}
|
||||
</SelectItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
enableReinitialize
|
||||
initialValues={{
|
||||
status: IssueStatus.BACKLOG,
|
||||
type: IssueType.TASK,
|
||||
title: '',
|
||||
description: '',
|
||||
@@ -98,6 +56,7 @@ const ProjectIssueCreateForm = ({ project, fetchProject, onCreate, modalClose })
|
||||
try {
|
||||
await createIssue({
|
||||
...values,
|
||||
status: IssueStatus.BACKLOG,
|
||||
projectId: project.id,
|
||||
users: values.userIds.map(id => ({ id })),
|
||||
});
|
||||
@@ -133,18 +92,18 @@ const ProjectIssueCreateForm = ({ project, fetchProject, onCreate, modalClose })
|
||||
<Form.Field.Select
|
||||
name="reporterId"
|
||||
label="Reporter"
|
||||
options={userOptions}
|
||||
renderOption={renderUser}
|
||||
renderValue={renderUser}
|
||||
options={userOptions(project)}
|
||||
renderOption={renderUser(project)}
|
||||
renderValue={renderUser(project)}
|
||||
/>
|
||||
<Form.Field.Select
|
||||
isMulti
|
||||
name="userIds"
|
||||
label="Assignees"
|
||||
tio="People who are responsible for dealing with this issue."
|
||||
options={userOptions}
|
||||
renderOption={renderUser}
|
||||
renderValue={renderUser}
|
||||
options={userOptions(project)}
|
||||
renderOption={renderUser(project)}
|
||||
renderValue={renderUser(project)}
|
||||
/>
|
||||
<Form.Field.Select
|
||||
name="priority"
|
||||
@@ -167,6 +126,48 @@ const ProjectIssueCreateForm = ({ project, fetchProject, onCreate, modalClose })
|
||||
);
|
||||
};
|
||||
|
||||
const typeOptions = Object.values(IssueType).map(type => ({
|
||||
value: type,
|
||||
label: IssueTypeCopy[type],
|
||||
}));
|
||||
|
||||
const priorityOptions = Object.values(IssuePriority).map(priority => ({
|
||||
value: priority,
|
||||
label: IssuePriorityCopy[priority],
|
||||
}));
|
||||
|
||||
const userOptions = project => project.users.map(user => ({ value: user.id, label: user.name }));
|
||||
|
||||
const renderType = ({ value: type }) => (
|
||||
<SelectItem>
|
||||
<IssueTypeIcon type={type} top={1} />
|
||||
<SelectItemLabel>{IssueTypeCopy[type]}</SelectItemLabel>
|
||||
</SelectItem>
|
||||
);
|
||||
|
||||
const renderPriority = ({ value: priority }) => (
|
||||
<SelectItem>
|
||||
<IssuePriorityIcon priority={priority} top={1} />
|
||||
<SelectItemLabel>{IssuePriorityCopy[priority]}</SelectItemLabel>
|
||||
</SelectItem>
|
||||
);
|
||||
|
||||
const renderUser = project => ({ value: userId, removeOptionValue }) => {
|
||||
const user = project.users.find(({ id }) => id === userId);
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={user.id}
|
||||
withBottomMargin={!!removeOptionValue}
|
||||
onClick={() => removeOptionValue && removeOptionValue()}
|
||||
>
|
||||
<Avatar size={20} avatarUrl={user.avatarUrl} name={user.name} />
|
||||
<SelectItemLabel>{user.name}</SelectItemLabel>
|
||||
{removeOptionValue && <Icon type="close" top={2} />}
|
||||
</SelectItem>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectIssueCreateForm.propTypes = propTypes;
|
||||
|
||||
export default ProjectIssueCreateForm;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { IssuePriority, IssuePriorityCopy } from 'shared/constants/issues';
|
||||
import { Select, IssuePriorityIcon } from 'shared/components';
|
||||
|
||||
import { SectionTitle } from '../Styles';
|
||||
import { Priority, Label } from './Styles';
|
||||
|
||||
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]}</Label>
|
||||
</Priority>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionTitle>Priority</SectionTitle>
|
||||
<Select
|
||||
variant="empty"
|
||||
withClearValue={false}
|
||||
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;
|
||||
@@ -1,134 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { InputDebounced, Modal, Button } from 'shared/components';
|
||||
|
||||
import { SectionTitle } from '../Styles';
|
||||
import {
|
||||
TrackingLink,
|
||||
Tracking,
|
||||
WatchIcon,
|
||||
Right,
|
||||
BarCont,
|
||||
Bar,
|
||||
Values,
|
||||
ModalContents,
|
||||
ModalTitle,
|
||||
Inputs,
|
||||
InputCont,
|
||||
InputLabel,
|
||||
Actions,
|
||||
} 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 renderEstimate = () => (
|
||||
<>
|
||||
<SectionTitle>Original Estimate (hours)</SectionTitle>
|
||||
{renderHourInput('estimate')}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderTracking = () => (
|
||||
<>
|
||||
<SectionTitle>Time Tracking</SectionTitle>
|
||||
<Modal
|
||||
width={400}
|
||||
renderLink={modal => (
|
||||
<TrackingLink onClick={modal.open}>{renderTrackingWidget()}</TrackingLink>
|
||||
)}
|
||||
renderContent={modal => (
|
||||
<ModalContents>
|
||||
<ModalTitle>Time tracking</ModalTitle>
|
||||
{renderTrackingWidget()}
|
||||
<Inputs>
|
||||
<InputCont>
|
||||
<InputLabel>Time spent (hours)</InputLabel>
|
||||
{renderHourInput('timeSpent')}
|
||||
</InputCont>
|
||||
<InputCont>
|
||||
<InputLabel>Time remaining (hours)</InputLabel>
|
||||
{renderHourInput('timeRemaining')}
|
||||
</InputCont>
|
||||
</Inputs>
|
||||
<Actions>
|
||||
<Button variant="primary" onClick={modal.close}>
|
||||
Done
|
||||
</Button>
|
||||
</Actions>
|
||||
</ModalContents>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderTrackingWidget = () => (
|
||||
<Tracking>
|
||||
<WatchIcon type="stopwatch" size={26} top={-1} />
|
||||
<Right>
|
||||
<BarCont>
|
||||
<Bar width={calculateTrackingBarWidth(issue)} />
|
||||
</BarCont>
|
||||
<Values>
|
||||
<div>{issue.timeSpent ? `${issue.timeSpent}h logged` : 'No time logged'}</div>
|
||||
{renderRemainingOrEstimate(issue)}
|
||||
</Values>
|
||||
</Right>
|
||||
</Tracking>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderEstimate()}
|
||||
{renderTracking()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const calculateTrackingBarWidth = ({ timeSpent, timeRemaining, estimate }) => {
|
||||
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 = ({ timeRemaining, estimate }) => {
|
||||
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>;
|
||||
}
|
||||
};
|
||||
|
||||
ProjectBoardIssueDetailsTracking.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsTracking;
|
||||
@@ -47,18 +47,6 @@ const ProjectIssueSearch = ({ project }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderIssue = issue => (
|
||||
<Link key={issue.id} to={`/project/board/issues/${issue.id}`}>
|
||||
<Issue>
|
||||
<IssueTypeIcon type={issue.type} size={25} />
|
||||
<IssueData>
|
||||
<IssueTitle>{issue.title}</IssueTitle>
|
||||
<IssueTypeId>{`${issue.type}-${issue.id}`}</IssueTypeId>
|
||||
</IssueData>
|
||||
</Issue>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<IssueSearch>
|
||||
<SearchInputCont>
|
||||
@@ -96,6 +84,18 @@ const ProjectIssueSearch = ({ project }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderIssue = issue => (
|
||||
<Link key={issue.id} to={`/project/board/issues/${issue.id}`}>
|
||||
<Issue>
|
||||
<IssueTypeIcon type={issue.type} size={25} />
|
||||
<IssueData>
|
||||
<IssueTitle>{issue.title}</IssueTitle>
|
||||
<IssueTypeId>{`${issue.type}-${issue.id}`}</IssueTypeId>
|
||||
</IssueData>
|
||||
</Issue>
|
||||
</Link>
|
||||
);
|
||||
|
||||
ProjectIssueSearch.propTypes = propTypes;
|
||||
|
||||
export default ProjectIssueSearch;
|
||||
|
||||
@@ -15,14 +15,17 @@ const ProjectNavbarLeft = ({ issueSearchModalOpen, issueCreateModalOpen }) => (
|
||||
<LogoLink to="/">
|
||||
<StyledLogo color="#fff" />
|
||||
</LogoLink>
|
||||
|
||||
<Item onClick={issueSearchModalOpen}>
|
||||
<Icon type="search" size={22} top={1} left={3} />
|
||||
<ItemText>Search issues</ItemText>
|
||||
</Item>
|
||||
|
||||
<Item onClick={issueCreateModalOpen}>
|
||||
<Icon type="plus" size={27} />
|
||||
<ItemText>Create Issue</ItemText>
|
||||
</Item>
|
||||
|
||||
<Bottom>
|
||||
<AboutTooltip
|
||||
placement="right"
|
||||
|
||||
@@ -16,11 +16,6 @@ const propTypes = {
|
||||
const ProjectSettings = ({ project, fetchProject }) => {
|
||||
const [{ isUpdating }, updateProject] = useApi.put('/project');
|
||||
|
||||
const categoryOptions = Object.values(ProjectCategory).map(category => ({
|
||||
value: category,
|
||||
label: ProjectCategoryCopy[category],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Form
|
||||
initialValues={Form.initialValues(project, get => ({
|
||||
@@ -38,7 +33,7 @@ const ProjectSettings = ({ project, fetchProject }) => {
|
||||
try {
|
||||
await updateProject(values);
|
||||
await fetchProject();
|
||||
toast.success('Changes have been successfully saved.');
|
||||
toast.success('Changes have been saved successfully.');
|
||||
} catch (error) {
|
||||
Form.handleAPIError(error, form);
|
||||
}
|
||||
@@ -48,6 +43,7 @@ const ProjectSettings = ({ project, fetchProject }) => {
|
||||
<FormElement>
|
||||
<Breadcrumbs items={['Projects', project.name, 'Project Details']} />
|
||||
<FormHeading>Project Details</FormHeading>
|
||||
|
||||
<Form.Field.Input name="name" label="Name" />
|
||||
<Form.Field.Input name="url" label="URL" />
|
||||
<Form.Field.TextEditor
|
||||
@@ -56,6 +52,7 @@ const ProjectSettings = ({ project, fetchProject }) => {
|
||||
tip="Describe the project in as much detail as you'd like."
|
||||
/>
|
||||
<Form.Field.Select name="category" label="Project Category" options={categoryOptions} />
|
||||
|
||||
<ActionButton type="submit" variant="primary" isWorking={isUpdating}>
|
||||
Save changes
|
||||
</ActionButton>
|
||||
@@ -65,6 +62,11 @@ const ProjectSettings = ({ project, fetchProject }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const categoryOptions = Object.values(ProjectCategory).map(category => ({
|
||||
value: category,
|
||||
label: ProjectCategoryCopy[category],
|
||||
}));
|
||||
|
||||
ProjectSettings.propTypes = propTypes;
|
||||
|
||||
export default ProjectSettings;
|
||||
|
||||
@@ -24,22 +24,6 @@ const propTypes = {
|
||||
const ProjectSidebar = ({ project }) => {
|
||||
const match = useRouteMatch();
|
||||
|
||||
const renderLinkItem = (text, iconType, path) => {
|
||||
const isImplemented = !!path;
|
||||
|
||||
const linkItemProps = isImplemented
|
||||
? { as: NavLink, exact: true, to: `${match.path}${path}` }
|
||||
: { as: 'div' };
|
||||
|
||||
return (
|
||||
<LinkItem {...linkItemProps}>
|
||||
<Icon type={iconType} />
|
||||
<LinkText>{text}</LinkText>
|
||||
{!isImplemented && <NotImplemented>Not implemented</NotImplemented>}
|
||||
</LinkItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<ProjectInfo>
|
||||
@@ -50,18 +34,34 @@ const ProjectSidebar = ({ project }) => {
|
||||
</ProjectTexts>
|
||||
</ProjectInfo>
|
||||
|
||||
{renderLinkItem('Kanban Board', 'board', '/board')}
|
||||
{renderLinkItem('Project settings', 'settings', '/settings')}
|
||||
{renderLinkItem(match, 'Kanban Board', 'board', '/board')}
|
||||
{renderLinkItem(match, 'Project settings', 'settings', '/settings')}
|
||||
<Divider />
|
||||
{renderLinkItem('Releases', 'shipping')}
|
||||
{renderLinkItem('Issues and filters', 'issues')}
|
||||
{renderLinkItem('Pages', 'page')}
|
||||
{renderLinkItem('Reports', 'reports')}
|
||||
{renderLinkItem('Components', 'component')}
|
||||
{renderLinkItem(match, 'Releases', 'shipping')}
|
||||
{renderLinkItem(match, 'Issues and filters', 'issues')}
|
||||
{renderLinkItem(match, 'Pages', 'page')}
|
||||
{renderLinkItem(match, 'Reports', 'reports')}
|
||||
{renderLinkItem(match, 'Components', 'component')}
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLinkItem = (match, text, iconType, path) => {
|
||||
const isImplemented = !!path;
|
||||
|
||||
const linkItemProps = isImplemented
|
||||
? { as: NavLink, exact: true, to: `${match.path}${path}` }
|
||||
: { as: 'div' };
|
||||
|
||||
return (
|
||||
<LinkItem {...linkItemProps}>
|
||||
<Icon type={iconType} />
|
||||
<LinkText>{text}</LinkText>
|
||||
{!isImplemented && <NotImplemented>Not implemented</NotImplemented>}
|
||||
</LinkItem>
|
||||
);
|
||||
};
|
||||
|
||||
ProjectSidebar.propTypes = propTypes;
|
||||
|
||||
export default ProjectSidebar;
|
||||
|
||||
@@ -11,7 +11,6 @@ import Sidebar from './Sidebar';
|
||||
import Board from './Board';
|
||||
import IssueSearch from './IssueSearch';
|
||||
import IssueCreateForm from './IssueCreateForm';
|
||||
import IssueDetails from './IssueDetails';
|
||||
import ProjectSettings from './ProjectSettings';
|
||||
import { ProjectPage } from './Styles';
|
||||
|
||||
@@ -38,77 +37,58 @@ const Project = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const renderIssueSearchModal = () => (
|
||||
<Modal
|
||||
isOpen
|
||||
variant="aside"
|
||||
width={600}
|
||||
onClose={issueSearchModalHelpers.close}
|
||||
renderContent={() => <IssueSearch project={project} />}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderIssueCreateModal = () => (
|
||||
<Modal
|
||||
isOpen
|
||||
width={800}
|
||||
withCloseIcon={false}
|
||||
onClose={issueCreateModalHelpers.close}
|
||||
renderContent={modal => (
|
||||
<IssueCreateForm
|
||||
project={project}
|
||||
fetchProject={fetchProject}
|
||||
onCreate={() => history.push(`${match.url}/board`)}
|
||||
modalClose={modal.close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderBoard = () => (
|
||||
<Board
|
||||
project={project}
|
||||
fetchProject={fetchProject}
|
||||
updateLocalProjectIssues={updateLocalProjectIssues}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderIssueDetailsModal = routeProps => (
|
||||
<Modal
|
||||
isOpen
|
||||
width={1040}
|
||||
withCloseIcon={false}
|
||||
onClose={() => history.push(`${match.url}/board`)}
|
||||
renderContent={modal => (
|
||||
<IssueDetails
|
||||
issueId={routeProps.match.params.issueId}
|
||||
projectUsers={project.users}
|
||||
fetchProject={fetchProject}
|
||||
updateLocalProjectIssues={updateLocalProjectIssues}
|
||||
modalClose={modal.close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderProjectSettings = () => (
|
||||
<ProjectSettings project={project} fetchProject={fetchProject} />
|
||||
);
|
||||
|
||||
return (
|
||||
<ProjectPage>
|
||||
<NavbarLeft
|
||||
issueSearchModalOpen={issueSearchModalHelpers.open}
|
||||
issueCreateModalOpen={issueCreateModalHelpers.open}
|
||||
/>
|
||||
|
||||
<Sidebar project={project} />
|
||||
|
||||
{issueSearchModalHelpers.isOpen() && renderIssueSearchModal()}
|
||||
{issueCreateModalHelpers.isOpen() && renderIssueCreateModal()}
|
||||
{issueSearchModalHelpers.isOpen() && (
|
||||
<Modal
|
||||
isOpen
|
||||
variant="aside"
|
||||
width={600}
|
||||
onClose={issueSearchModalHelpers.close}
|
||||
renderContent={() => <IssueSearch project={project} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{issueCreateModalHelpers.isOpen() && (
|
||||
<Modal
|
||||
isOpen
|
||||
width={800}
|
||||
withCloseIcon={false}
|
||||
onClose={issueCreateModalHelpers.close}
|
||||
renderContent={modal => (
|
||||
<IssueCreateForm
|
||||
project={project}
|
||||
fetchProject={fetchProject}
|
||||
onCreate={() => history.push(`${match.url}/board`)}
|
||||
modalClose={modal.close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={`${match.path}/board`}
|
||||
render={() => (
|
||||
<Board
|
||||
project={project}
|
||||
fetchProject={fetchProject}
|
||||
updateLocalProjectIssues={updateLocalProjectIssues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={`${match.path}/settings`}
|
||||
render={() => <ProjectSettings project={project} fetchProject={fetchProject} />}
|
||||
/>
|
||||
|
||||
<Route path={`${match.path}/board`} render={renderBoard} />
|
||||
<Route path={`${match.path}/board/issues/:issueId`} render={renderIssueDetailsModal} />
|
||||
<Route path={`${match.path}/settings`} render={renderProjectSettings} />
|
||||
{match.isExact && <Redirect to={`${match.url}/board`} />}
|
||||
</ProjectPage>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ const Avatar = ({ className, avatarUrl, name, size, ...otherProps }) => {
|
||||
if (avatarUrl) {
|
||||
return <Image className={className} size={size} avatarUrl={avatarUrl} {...otherProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Letter className={className} size={size} color={getColorFromName(name)} {...otherProps}>
|
||||
<span>{name.charAt(0)}</span>
|
||||
|
||||
@@ -27,6 +27,7 @@ const defaultProps = {
|
||||
const generateField = FormComponent => {
|
||||
const FieldComponent = ({ className, label, tip, error, ...otherProps }) => {
|
||||
const fieldId = uniqueId('form-field-');
|
||||
|
||||
return (
|
||||
<StyledField className={className} hasLabel={!!label}>
|
||||
{label && <FieldLabel htmlFor={fieldId}>{label}</FieldLabel>}
|
||||
|
||||
@@ -57,21 +57,22 @@ const Modal = ({
|
||||
useOnEscapeKeyDown(isOpen, closeModal);
|
||||
useEffect(setBodyScrollLock, [isOpen]);
|
||||
|
||||
const renderModal = () => (
|
||||
<ScrollOverlay data-jira-modal="true">
|
||||
<ClickableOverlay variant={variant} ref={$clickableOverlayRef}>
|
||||
<StyledModal className={className} variant={variant} width={width} ref={$modalRef}>
|
||||
{withCloseIcon && <CloseIcon type="close" variant={variant} onClick={closeModal} />}
|
||||
{renderContent({ close: closeModal })}
|
||||
</StyledModal>
|
||||
</ClickableOverlay>
|
||||
</ScrollOverlay>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isControlled && renderLink({ open: () => setStateOpen(true) })}
|
||||
{isOpen && ReactDOM.createPortal(renderModal(), $root)}
|
||||
|
||||
{isOpen &&
|
||||
ReactDOM.createPortal(
|
||||
<ScrollOverlay data-jira-modal="true">
|
||||
<ClickableOverlay variant={variant} ref={$clickableOverlayRef}>
|
||||
<StyledModal className={className} variant={variant} width={width} ref={$modalRef}>
|
||||
{withCloseIcon && <CloseIcon type="close" variant={variant} onClick={closeModal} />}
|
||||
{renderContent({ close: closeModal })}
|
||||
</StyledModal>
|
||||
</ClickableOverlay>
|
||||
</ScrollOverlay>,
|
||||
$root,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ const defaultProps = {
|
||||
size: 40,
|
||||
};
|
||||
|
||||
const Logo = ({ className, size }) => (
|
||||
const ProjectAvatar = ({ className, size }) => (
|
||||
<span className={className}>
|
||||
<svg
|
||||
width={size}
|
||||
@@ -114,7 +114,7 @@ const Logo = ({ className, size }) => (
|
||||
</span>
|
||||
);
|
||||
|
||||
Logo.propTypes = propTypes;
|
||||
Logo.defaultProps = defaultProps;
|
||||
ProjectAvatar.propTypes = propTypes;
|
||||
ProjectAvatar.defaultProps = defaultProps;
|
||||
|
||||
export default Logo;
|
||||
export default ProjectAvatar;
|
||||
|
||||
@@ -174,27 +174,6 @@ const SelectDropdown = ({
|
||||
const isSearchValueInOptions = options.map(option => option.label).includes(searchValue);
|
||||
const isOptionCreatable = onCreate && searchValue && !isSearchValueInOptions;
|
||||
|
||||
const renderSelectableOption = option => (
|
||||
<Option
|
||||
key={option.value}
|
||||
data-select-option-value={option.value}
|
||||
onMouseEnter={handleOptionMouseEnter}
|
||||
onClick={() => selectOptionValue(option.value)}
|
||||
>
|
||||
{propsRenderOption ? propsRenderOption(option) : option.label}
|
||||
</Option>
|
||||
);
|
||||
|
||||
const renderCreatableOption = () => (
|
||||
<Option
|
||||
data-create-option-label={searchValue}
|
||||
onMouseEnter={handleOptionMouseEnter}
|
||||
onClick={() => createOption(searchValue)}
|
||||
>
|
||||
{isCreatingOption ? `Creating "${searchValue}"...` : `Create "${searchValue}"`}
|
||||
</Option>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown width={dropdownWidth}>
|
||||
<DropdownInput
|
||||
@@ -205,12 +184,33 @@ const SelectDropdown = ({
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onChange={event => setSearchValue(event.target.value)}
|
||||
/>
|
||||
|
||||
{!isValueEmpty && withClearValue && <ClearIcon type="close" onClick={clearOptionValues} />}
|
||||
|
||||
<Options ref={$optionsRef}>
|
||||
{filteredOptions.map(renderSelectableOption)}
|
||||
{isOptionCreatable && renderCreatableOption()}
|
||||
{filteredOptions.length === 0 && <OptionsNoResults>No results</OptionsNoResults>}
|
||||
{filteredOptions.map(option => (
|
||||
<Option
|
||||
key={option.value}
|
||||
data-select-option-value={option.value}
|
||||
onMouseEnter={handleOptionMouseEnter}
|
||||
onClick={() => selectOptionValue(option.value)}
|
||||
>
|
||||
{propsRenderOption ? propsRenderOption(option) : option.label}
|
||||
</Option>
|
||||
))}
|
||||
|
||||
{isOptionCreatable && (
|
||||
<Option
|
||||
data-create-option-label={searchValue}
|
||||
onMouseEnter={handleOptionMouseEnter}
|
||||
onClick={() => createOption(searchValue)}
|
||||
>
|
||||
{isCreatingOption ? `Creating "${searchValue}"...` : `Create "${searchValue}"`}
|
||||
</Option>
|
||||
)}
|
||||
</Options>
|
||||
|
||||
{filteredOptions.length === 0 && <OptionsNoResults>No results</OptionsNoResults>}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -131,31 +131,6 @@ const Select = ({
|
||||
|
||||
const isValueEmpty = isMulti ? !value.length : !getOption(value);
|
||||
|
||||
const renderSingleValue = () =>
|
||||
propsRenderValue ? propsRenderValue({ value }) : getOptionLabel(value);
|
||||
|
||||
const renderMultiValue = () => (
|
||||
<ValueMulti variant={variant}>
|
||||
{value.map(optionValue =>
|
||||
propsRenderValue ? (
|
||||
propsRenderValue({
|
||||
value: optionValue,
|
||||
removeOptionValue: () => removeOptionValue(optionValue),
|
||||
})
|
||||
) : (
|
||||
<ValueMultiItem key={optionValue} onClick={() => removeOptionValue(optionValue)}>
|
||||
{getOptionLabel(optionValue)}
|
||||
<Icon type="close" size={14} />
|
||||
</ValueMultiItem>
|
||||
),
|
||||
)}
|
||||
<AddMore>
|
||||
<Icon type="plus" />
|
||||
Add more
|
||||
</AddMore>
|
||||
</ValueMulti>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledSelect
|
||||
className={className}
|
||||
@@ -167,12 +142,38 @@ const Select = ({
|
||||
>
|
||||
<ValueContainer variant={variant} onClick={activateDropdown}>
|
||||
{isValueEmpty && <Placeholder>{placeholder}</Placeholder>}
|
||||
{!isValueEmpty && !isMulti && renderSingleValue()}
|
||||
{!isValueEmpty && isMulti && renderMultiValue()}
|
||||
|
||||
{!isValueEmpty && !isMulti && propsRenderValue
|
||||
? propsRenderValue({ value })
|
||||
: getOptionLabel(value)}
|
||||
|
||||
{!isValueEmpty && isMulti && (
|
||||
<ValueMulti variant={variant}>
|
||||
{value.map(optionValue =>
|
||||
propsRenderValue ? (
|
||||
propsRenderValue({
|
||||
value: optionValue,
|
||||
removeOptionValue: () => removeOptionValue(optionValue),
|
||||
})
|
||||
) : (
|
||||
<ValueMultiItem key={optionValue} onClick={() => removeOptionValue(optionValue)}>
|
||||
{getOptionLabel(optionValue)}
|
||||
<Icon type="close" size={14} />
|
||||
</ValueMultiItem>
|
||||
),
|
||||
)}
|
||||
<AddMore>
|
||||
<Icon type="plus" />
|
||||
Add more
|
||||
</AddMore>
|
||||
</ValueMulti>
|
||||
)}
|
||||
|
||||
{(!isMulti || isValueEmpty) && variant !== 'empty' && (
|
||||
<ChevronIcon type="chevron-down" top={1} />
|
||||
)}
|
||||
</ValueContainer>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<Dropdown
|
||||
dropdownWidth={dropdownWidth}
|
||||
|
||||
@@ -44,27 +44,30 @@ const Tooltip = ({ className, placement, offset, width, renderLink, renderConten
|
||||
$tooltipRef.current.style.top = `${top}px`;
|
||||
$tooltipRef.current.style.left = `${left}px`;
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
setTooltipPosition();
|
||||
window.addEventListener('resize', setTooltipPosition);
|
||||
window.addEventListener('scroll', setTooltipPosition);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', setTooltipPosition);
|
||||
window.removeEventListener('scroll', setTooltipPosition);
|
||||
};
|
||||
}, [isOpen, offset, placement]);
|
||||
|
||||
const renderTooltip = () => (
|
||||
<StyledTooltip className={className} ref={$tooltipRef} width={width}>
|
||||
{renderContent({ close: closeTooltip })}
|
||||
</StyledTooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderLink({ ref: $linkRef, onClick: isOpen ? closeTooltip : openTooltip })}
|
||||
{isOpen && ReactDOM.createPortal(renderTooltip(), $root)}
|
||||
|
||||
{isOpen &&
|
||||
ReactDOM.createPortal(
|
||||
<StyledTooltip className={className} ref={$tooltipRef} width={width}>
|
||||
{renderContent({ close: closeTooltip })}
|
||||
</StyledTooltip>,
|
||||
$root,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user