Refactored code structure
This commit is contained in:
@@ -12,25 +12,12 @@ const propTypes = {
|
|||||||
projectUsers: PropTypes.array.isRequired,
|
projectUsers: PropTypes.array.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectBoardIssueDetailsUsers = ({ issue, updateIssue, projectUsers }) => {
|
const ProjectBoardIssueDetailsAssigneesReporter = ({ issue, updateIssue, projectUsers }) => {
|
||||||
const getUserById = userId => projectUsers.find(user => user.id === userId);
|
const getUserById = userId => projectUsers.find(user => user.id === userId);
|
||||||
|
|
||||||
const userOptions = projectUsers.map(user => ({ value: user.id, label: user.name }));
|
const userOptions = projectUsers.map(user => ({ value: user.id, label: user.name }));
|
||||||
|
|
||||||
const renderUser = (user, isSelectValue, removeOptionValue) => (
|
return (
|
||||||
<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 = () => (
|
|
||||||
<>
|
<>
|
||||||
<SectionTitle>Assignees</SectionTitle>
|
<SectionTitle>Assignees</SectionTitle>
|
||||||
<Select
|
<Select
|
||||||
@@ -43,16 +30,12 @@ const ProjectBoardIssueDetailsUsers = ({ issue, updateIssue, projectUsers }) =>
|
|||||||
onChange={userIds => {
|
onChange={userIds => {
|
||||||
updateIssue({ userIds, users: userIds.map(getUserById) });
|
updateIssue({ userIds, users: userIds.map(getUserById) });
|
||||||
}}
|
}}
|
||||||
renderValue={({ value, removeOptionValue }) =>
|
renderValue={({ value: userId, removeOptionValue }) =>
|
||||||
renderUser(getUserById(value), true, removeOptionValue)
|
renderUser(getUserById(userId), true, removeOptionValue)
|
||||||
}
|
}
|
||||||
renderOption={({ value }) => renderUser(getUserById(value), false)}
|
renderOption={({ value: userId }) => renderUser(getUserById(userId), false)}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderReporter = () => (
|
|
||||||
<>
|
|
||||||
<SectionTitle>Reporter</SectionTitle>
|
<SectionTitle>Reporter</SectionTitle>
|
||||||
<Select
|
<Select
|
||||||
variant="empty"
|
variant="empty"
|
||||||
@@ -61,20 +44,26 @@ const ProjectBoardIssueDetailsUsers = ({ issue, updateIssue, projectUsers }) =>
|
|||||||
value={issue.reporterId}
|
value={issue.reporterId}
|
||||||
options={userOptions}
|
options={userOptions}
|
||||||
onChange={userId => updateIssue({ reporterId: userId })}
|
onChange={userId => updateIssue({ reporterId: userId })}
|
||||||
renderValue={({ value }) => renderUser(getUserById(value), true)}
|
renderValue={({ value: userId }) => renderUser(getUserById(userId), true)}
|
||||||
renderOption={({ value }) => renderUser(getUserById(value))}
|
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 $textareaRef = useRef();
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if ($textareaRef.current.value.trim()) {
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -32,15 +38,7 @@ const ProjectBoardIssueDetailsCommentsBodyForm = ({
|
|||||||
ref={$textareaRef}
|
ref={$textareaRef}
|
||||||
/>
|
/>
|
||||||
<Actions>
|
<Actions>
|
||||||
<FormButton
|
<FormButton variant="primary" isWorking={isWorking} onClick={handleSubmit}>
|
||||||
variant="primary"
|
|
||||||
isWorking={isWorking}
|
|
||||||
onClick={() => {
|
|
||||||
if ($textareaRef.current.value.trim()) {
|
|
||||||
onSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
Save
|
||||||
</FormButton>
|
</FormButton>
|
||||||
<FormButton variant="empty" onClick={onCancel}>
|
<FormButton variant="empty" onClick={onCancel}>
|
||||||
@@ -15,42 +15,41 @@ const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
|
|||||||
const [description, setDescription] = useState(issue.description);
|
const [description, setDescription] = useState(issue.description);
|
||||||
const [isEditing, setEditing] = useState(false);
|
const [isEditing, setEditing] = useState(false);
|
||||||
|
|
||||||
const isDescriptionEmpty = getTextContentsFromHtmlString(description).trim().length === 0;
|
|
||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
updateIssue({ description });
|
updateIssue({ description });
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPresentingMode = () =>
|
const isDescriptionEmpty = getTextContentsFromHtmlString(description).trim().length === 0;
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>Description</Title>
|
<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 styled from 'styled-components';
|
||||||
|
|
||||||
import { color, font, mixin } from 'shared/utils/styles';
|
import { color, font, mixin } from 'shared/utils/styles';
|
||||||
import { Icon } from 'shared/components';
|
|
||||||
|
|
||||||
export const TrackingLink = styled.div`
|
export const TrackingLink = styled.div`
|
||||||
padding: 4px 4px 2px 0;
|
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`
|
export const ModalContents = styled.div`
|
||||||
padding: 20px 25px 25px;
|
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 Description from './Description';
|
||||||
import Comments from './Comments';
|
import Comments from './Comments';
|
||||||
import Status from './Status';
|
import Status from './Status';
|
||||||
import Users from './Users';
|
import AssigneesReporter from './AssigneesReporter';
|
||||||
import Priority from './Priority';
|
import Priority from './Priority';
|
||||||
import Tracking from './Tracking';
|
import EstimateTracking from './EstimateTracking';
|
||||||
import Dates from './Dates';
|
import Dates from './Dates';
|
||||||
import { TopActions, TopActionsRight, Content, Left, Right } from './Styles';
|
import { TopActions, TopActionsRight, Content, Left, Right } from './Styles';
|
||||||
|
|
||||||
@@ -79,9 +79,9 @@ const ProjectBoardIssueDetails = ({
|
|||||||
</Left>
|
</Left>
|
||||||
<Right>
|
<Right>
|
||||||
<Status issue={issue} updateIssue={updateIssue} />
|
<Status issue={issue} updateIssue={updateIssue} />
|
||||||
<Users issue={issue} updateIssue={updateIssue} projectUsers={projectUsers} />
|
<AssigneesReporter issue={issue} updateIssue={updateIssue} projectUsers={projectUsers} />
|
||||||
<Priority issue={issue} updateIssue={updateIssue} />
|
<Priority issue={issue} updateIssue={updateIssue} />
|
||||||
<Tracking issue={issue} updateIssue={updateIssue} />
|
<EstimateTracking issue={issue} updateIssue={updateIssue} />
|
||||||
<Dates issue={issue} />
|
<Dates issue={issue} />
|
||||||
</Right>
|
</Right>
|
||||||
</Content>
|
</Content>
|
||||||
@@ -13,11 +13,10 @@ const propTypes = {
|
|||||||
index: PropTypes.number.isRequired,
|
index: PropTypes.number.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectBoardListsIssue = ({ projectUsers, issue, index }) => {
|
const ProjectBoardListIssue = ({ projectUsers, issue, index }) => {
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
|
|
||||||
const getUserById = userId => projectUsers.find(user => user.id === userId);
|
const assignees = issue.userIds.map(userId => projectUsers.find(user => user.id === userId));
|
||||||
const assignees = issue.userIds.map(getUserById);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Draggable draggableId={issue.id.toString()} index={index}>
|
<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 styled from 'styled-components';
|
||||||
|
|
||||||
import { color, font } from 'shared/utils/styles';
|
|
||||||
|
|
||||||
export const Lists = styled.div`
|
export const Lists = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 26px -5px 0;
|
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 React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import moment from 'moment';
|
import { DragDropContext } from 'react-beautiful-dnd';
|
||||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
|
||||||
import { intersection } from 'lodash';
|
|
||||||
|
|
||||||
import api from 'shared/utils/api';
|
import api from 'shared/utils/api';
|
||||||
import useCurrentUser from 'shared/hooks/currentUser';
|
|
||||||
import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/javascript';
|
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 List from './List';
|
||||||
import { Lists, List, Title, IssuesCount, Issues } from './Styles';
|
import { Lists } from './Styles';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
project: PropTypes.object.isRequired,
|
project: PropTypes.object.isRequired,
|
||||||
@@ -19,9 +16,7 @@ const propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => {
|
const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => {
|
||||||
const { currentUserId } = useCurrentUser();
|
const handleIssueDrop = ({ draggableId, destination, source }) => {
|
||||||
|
|
||||||
const handleIssueDrop = async ({ draggableId, destination, source }) => {
|
|
||||||
if (!isPositionChanged(source, destination)) return;
|
if (!isPositionChanged(source, destination)) return;
|
||||||
|
|
||||||
const issueId = Number(draggableId);
|
const issueId = Number(draggableId);
|
||||||
@@ -29,76 +24,24 @@ const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => {
|
|||||||
api.optimisticUpdate(`/issues/${issueId}`, {
|
api.optimisticUpdate(`/issues/${issueId}`, {
|
||||||
updatedFields: {
|
updatedFields: {
|
||||||
status: destination.droppableId,
|
status: destination.droppableId,
|
||||||
listPosition: calculateListPosition(project.issues, destination, source, issueId),
|
listPosition: calculateIssueListPosition(project.issues, destination, source, issueId),
|
||||||
},
|
},
|
||||||
currentFields: project.issues.find(({ id }) => id === issueId),
|
currentFields: project.issues.find(({ id }) => id === issueId),
|
||||||
setLocalData: fields => updateLocalProjectIssues(issueId, fields),
|
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 (
|
return (
|
||||||
<>
|
<DragDropContext onDragEnd={handleIssueDrop}>
|
||||||
<DragDropContext onDragEnd={handleIssueDrop}>
|
<Lists>
|
||||||
<Lists>{Object.values(IssueStatus).map(renderList)}</Lists>
|
{Object.values(IssueStatus).map(status => (
|
||||||
</DragDropContext>
|
<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) => {
|
const isPositionChanged = (destination, source) => {
|
||||||
if (!destination) return false;
|
if (!destination) return false;
|
||||||
const isSameList = destination.droppableId === source.droppableId;
|
const isSameList = destination.droppableId === source.droppableId;
|
||||||
@@ -106,7 +49,7 @@ const isPositionChanged = (destination, source) => {
|
|||||||
return !isSameList || !isSamePosition;
|
return !isSameList || !isSamePosition;
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateListPosition = (...args) => {
|
const calculateIssueListPosition = (...args) => {
|
||||||
const { prevIssue, nextIssue } = getAfterDropPrevNextIssue(...args);
|
const { prevIssue, nextIssue } = getAfterDropPrevNextIssue(...args);
|
||||||
let position;
|
let position;
|
||||||
|
|
||||||
@@ -123,13 +66,13 @@ const calculateListPosition = (...args) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getAfterDropPrevNextIssue = (allIssues, destination, source, droppedIssueId) => {
|
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 droppedIssue = allIssues.find(issue => issue.id === droppedIssueId);
|
||||||
const isSameList = destination.droppableId === source.droppableId;
|
const isSameList = destination.droppableId === source.droppableId;
|
||||||
|
|
||||||
const afterDropDestinationIssues = isSameList
|
const afterDropDestinationIssues = isSameList
|
||||||
? moveItemWithinArray(destinationIssues, droppedIssue, destination.index)
|
? moveItemWithinArray(beforeDropDestinationIssues, droppedIssue, destination.index)
|
||||||
: insertItemIntoArray(destinationIssues, droppedIssue, destination.index);
|
: insertItemIntoArray(beforeDropDestinationIssues, droppedIssue, destination.index);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prevIssue: afterDropDestinationIssues[destination.index - 1],
|
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;
|
ProjectBoardLists.propTypes = propTypes;
|
||||||
|
|
||||||
export default ProjectBoardLists;
|
export default ProjectBoardLists;
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { Route, useRouteMatch, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import useMergeState from 'shared/hooks/mergeState';
|
import useMergeState from 'shared/hooks/mergeState';
|
||||||
import { Breadcrumbs } from 'shared/components';
|
import { Breadcrumbs, Modal } from 'shared/components';
|
||||||
|
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import Filters from './Filters';
|
import Filters from './Filters';
|
||||||
import Lists from './Lists';
|
import Lists from './Lists';
|
||||||
|
import IssueDetails from './IssueDetails';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
project: PropTypes.object.isRequired,
|
project: PropTypes.object.isRequired,
|
||||||
|
fetchProject: PropTypes.func.isRequired,
|
||||||
updateLocalProjectIssues: PropTypes.func.isRequired,
|
updateLocalProjectIssues: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,7 +23,10 @@ const defaultFilters = {
|
|||||||
recent: false,
|
recent: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectBoard = ({ project, updateLocalProjectIssues }) => {
|
const ProjectBoard = ({ project, fetchProject, updateLocalProjectIssues }) => {
|
||||||
|
const match = useRouteMatch();
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
const [filters, mergeFilters] = useMergeState(defaultFilters);
|
const [filters, mergeFilters] = useMergeState(defaultFilters);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -38,6 +44,26 @@ const ProjectBoard = ({ project, updateLocalProjectIssues }) => {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
updateLocalProjectIssues={updateLocalProjectIssues}
|
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 { 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 (
|
return (
|
||||||
<Form
|
<Form
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
initialValues={{
|
initialValues={{
|
||||||
status: IssueStatus.BACKLOG,
|
|
||||||
type: IssueType.TASK,
|
type: IssueType.TASK,
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -98,6 +56,7 @@ const ProjectIssueCreateForm = ({ project, fetchProject, onCreate, modalClose })
|
|||||||
try {
|
try {
|
||||||
await createIssue({
|
await createIssue({
|
||||||
...values,
|
...values,
|
||||||
|
status: IssueStatus.BACKLOG,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
users: values.userIds.map(id => ({ id })),
|
users: values.userIds.map(id => ({ id })),
|
||||||
});
|
});
|
||||||
@@ -133,18 +92,18 @@ const ProjectIssueCreateForm = ({ project, fetchProject, onCreate, modalClose })
|
|||||||
<Form.Field.Select
|
<Form.Field.Select
|
||||||
name="reporterId"
|
name="reporterId"
|
||||||
label="Reporter"
|
label="Reporter"
|
||||||
options={userOptions}
|
options={userOptions(project)}
|
||||||
renderOption={renderUser}
|
renderOption={renderUser(project)}
|
||||||
renderValue={renderUser}
|
renderValue={renderUser(project)}
|
||||||
/>
|
/>
|
||||||
<Form.Field.Select
|
<Form.Field.Select
|
||||||
isMulti
|
isMulti
|
||||||
name="userIds"
|
name="userIds"
|
||||||
label="Assignees"
|
label="Assignees"
|
||||||
tio="People who are responsible for dealing with this issue."
|
tio="People who are responsible for dealing with this issue."
|
||||||
options={userOptions}
|
options={userOptions(project)}
|
||||||
renderOption={renderUser}
|
renderOption={renderUser(project)}
|
||||||
renderValue={renderUser}
|
renderValue={renderUser(project)}
|
||||||
/>
|
/>
|
||||||
<Form.Field.Select
|
<Form.Field.Select
|
||||||
name="priority"
|
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;
|
ProjectIssueCreateForm.propTypes = propTypes;
|
||||||
|
|
||||||
export default ProjectIssueCreateForm;
|
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 (
|
return (
|
||||||
<IssueSearch>
|
<IssueSearch>
|
||||||
<SearchInputCont>
|
<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;
|
ProjectIssueSearch.propTypes = propTypes;
|
||||||
|
|
||||||
export default ProjectIssueSearch;
|
export default ProjectIssueSearch;
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ const ProjectNavbarLeft = ({ issueSearchModalOpen, issueCreateModalOpen }) => (
|
|||||||
<LogoLink to="/">
|
<LogoLink to="/">
|
||||||
<StyledLogo color="#fff" />
|
<StyledLogo color="#fff" />
|
||||||
</LogoLink>
|
</LogoLink>
|
||||||
|
|
||||||
<Item onClick={issueSearchModalOpen}>
|
<Item onClick={issueSearchModalOpen}>
|
||||||
<Icon type="search" size={22} top={1} left={3} />
|
<Icon type="search" size={22} top={1} left={3} />
|
||||||
<ItemText>Search issues</ItemText>
|
<ItemText>Search issues</ItemText>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
<Item onClick={issueCreateModalOpen}>
|
<Item onClick={issueCreateModalOpen}>
|
||||||
<Icon type="plus" size={27} />
|
<Icon type="plus" size={27} />
|
||||||
<ItemText>Create Issue</ItemText>
|
<ItemText>Create Issue</ItemText>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
<Bottom>
|
<Bottom>
|
||||||
<AboutTooltip
|
<AboutTooltip
|
||||||
placement="right"
|
placement="right"
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ const propTypes = {
|
|||||||
const ProjectSettings = ({ project, fetchProject }) => {
|
const ProjectSettings = ({ project, fetchProject }) => {
|
||||||
const [{ isUpdating }, updateProject] = useApi.put('/project');
|
const [{ isUpdating }, updateProject] = useApi.put('/project');
|
||||||
|
|
||||||
const categoryOptions = Object.values(ProjectCategory).map(category => ({
|
|
||||||
value: category,
|
|
||||||
label: ProjectCategoryCopy[category],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
initialValues={Form.initialValues(project, get => ({
|
initialValues={Form.initialValues(project, get => ({
|
||||||
@@ -38,7 +33,7 @@ const ProjectSettings = ({ project, fetchProject }) => {
|
|||||||
try {
|
try {
|
||||||
await updateProject(values);
|
await updateProject(values);
|
||||||
await fetchProject();
|
await fetchProject();
|
||||||
toast.success('Changes have been successfully saved.');
|
toast.success('Changes have been saved successfully.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Form.handleAPIError(error, form);
|
Form.handleAPIError(error, form);
|
||||||
}
|
}
|
||||||
@@ -48,6 +43,7 @@ const ProjectSettings = ({ project, fetchProject }) => {
|
|||||||
<FormElement>
|
<FormElement>
|
||||||
<Breadcrumbs items={['Projects', project.name, 'Project Details']} />
|
<Breadcrumbs items={['Projects', project.name, 'Project Details']} />
|
||||||
<FormHeading>Project Details</FormHeading>
|
<FormHeading>Project Details</FormHeading>
|
||||||
|
|
||||||
<Form.Field.Input name="name" label="Name" />
|
<Form.Field.Input name="name" label="Name" />
|
||||||
<Form.Field.Input name="url" label="URL" />
|
<Form.Field.Input name="url" label="URL" />
|
||||||
<Form.Field.TextEditor
|
<Form.Field.TextEditor
|
||||||
@@ -56,6 +52,7 @@ const ProjectSettings = ({ project, fetchProject }) => {
|
|||||||
tip="Describe the project in as much detail as you'd like."
|
tip="Describe the project in as much detail as you'd like."
|
||||||
/>
|
/>
|
||||||
<Form.Field.Select name="category" label="Project Category" options={categoryOptions} />
|
<Form.Field.Select name="category" label="Project Category" options={categoryOptions} />
|
||||||
|
|
||||||
<ActionButton type="submit" variant="primary" isWorking={isUpdating}>
|
<ActionButton type="submit" variant="primary" isWorking={isUpdating}>
|
||||||
Save changes
|
Save changes
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
@@ -65,6 +62,11 @@ const ProjectSettings = ({ project, fetchProject }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const categoryOptions = Object.values(ProjectCategory).map(category => ({
|
||||||
|
value: category,
|
||||||
|
label: ProjectCategoryCopy[category],
|
||||||
|
}));
|
||||||
|
|
||||||
ProjectSettings.propTypes = propTypes;
|
ProjectSettings.propTypes = propTypes;
|
||||||
|
|
||||||
export default ProjectSettings;
|
export default ProjectSettings;
|
||||||
|
|||||||
@@ -24,22 +24,6 @@ const propTypes = {
|
|||||||
const ProjectSidebar = ({ project }) => {
|
const ProjectSidebar = ({ project }) => {
|
||||||
const match = useRouteMatch();
|
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 (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<ProjectInfo>
|
<ProjectInfo>
|
||||||
@@ -50,18 +34,34 @@ const ProjectSidebar = ({ project }) => {
|
|||||||
</ProjectTexts>
|
</ProjectTexts>
|
||||||
</ProjectInfo>
|
</ProjectInfo>
|
||||||
|
|
||||||
{renderLinkItem('Kanban Board', 'board', '/board')}
|
{renderLinkItem(match, 'Kanban Board', 'board', '/board')}
|
||||||
{renderLinkItem('Project settings', 'settings', '/settings')}
|
{renderLinkItem(match, 'Project settings', 'settings', '/settings')}
|
||||||
<Divider />
|
<Divider />
|
||||||
{renderLinkItem('Releases', 'shipping')}
|
{renderLinkItem(match, 'Releases', 'shipping')}
|
||||||
{renderLinkItem('Issues and filters', 'issues')}
|
{renderLinkItem(match, 'Issues and filters', 'issues')}
|
||||||
{renderLinkItem('Pages', 'page')}
|
{renderLinkItem(match, 'Pages', 'page')}
|
||||||
{renderLinkItem('Reports', 'reports')}
|
{renderLinkItem(match, 'Reports', 'reports')}
|
||||||
{renderLinkItem('Components', 'component')}
|
{renderLinkItem(match, 'Components', 'component')}
|
||||||
</Sidebar>
|
</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;
|
ProjectSidebar.propTypes = propTypes;
|
||||||
|
|
||||||
export default ProjectSidebar;
|
export default ProjectSidebar;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import Sidebar from './Sidebar';
|
|||||||
import Board from './Board';
|
import Board from './Board';
|
||||||
import IssueSearch from './IssueSearch';
|
import IssueSearch from './IssueSearch';
|
||||||
import IssueCreateForm from './IssueCreateForm';
|
import IssueCreateForm from './IssueCreateForm';
|
||||||
import IssueDetails from './IssueDetails';
|
|
||||||
import ProjectSettings from './ProjectSettings';
|
import ProjectSettings from './ProjectSettings';
|
||||||
import { ProjectPage } from './Styles';
|
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 (
|
return (
|
||||||
<ProjectPage>
|
<ProjectPage>
|
||||||
<NavbarLeft
|
<NavbarLeft
|
||||||
issueSearchModalOpen={issueSearchModalHelpers.open}
|
issueSearchModalOpen={issueSearchModalHelpers.open}
|
||||||
issueCreateModalOpen={issueCreateModalHelpers.open}
|
issueCreateModalOpen={issueCreateModalHelpers.open}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Sidebar project={project} />
|
<Sidebar project={project} />
|
||||||
|
|
||||||
{issueSearchModalHelpers.isOpen() && renderIssueSearchModal()}
|
{issueSearchModalHelpers.isOpen() && (
|
||||||
{issueCreateModalHelpers.isOpen() && renderIssueCreateModal()}
|
<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`} />}
|
{match.isExact && <Redirect to={`${match.url}/board`} />}
|
||||||
</ProjectPage>
|
</ProjectPage>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const Avatar = ({ className, avatarUrl, name, size, ...otherProps }) => {
|
|||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
return <Image className={className} size={size} avatarUrl={avatarUrl} {...otherProps} />;
|
return <Image className={className} size={size} avatarUrl={avatarUrl} {...otherProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Letter className={className} size={size} color={getColorFromName(name)} {...otherProps}>
|
<Letter className={className} size={size} color={getColorFromName(name)} {...otherProps}>
|
||||||
<span>{name.charAt(0)}</span>
|
<span>{name.charAt(0)}</span>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const defaultProps = {
|
|||||||
const generateField = FormComponent => {
|
const generateField = FormComponent => {
|
||||||
const FieldComponent = ({ className, label, tip, error, ...otherProps }) => {
|
const FieldComponent = ({ className, label, tip, error, ...otherProps }) => {
|
||||||
const fieldId = uniqueId('form-field-');
|
const fieldId = uniqueId('form-field-');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledField className={className} hasLabel={!!label}>
|
<StyledField className={className} hasLabel={!!label}>
|
||||||
{label && <FieldLabel htmlFor={fieldId}>{label}</FieldLabel>}
|
{label && <FieldLabel htmlFor={fieldId}>{label}</FieldLabel>}
|
||||||
|
|||||||
@@ -57,21 +57,22 @@ const Modal = ({
|
|||||||
useOnEscapeKeyDown(isOpen, closeModal);
|
useOnEscapeKeyDown(isOpen, closeModal);
|
||||||
useEffect(setBodyScrollLock, [isOpen]);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isControlled && renderLink({ open: () => setStateOpen(true) })}
|
{!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,
|
size: 40,
|
||||||
};
|
};
|
||||||
|
|
||||||
const Logo = ({ className, size }) => (
|
const ProjectAvatar = ({ className, size }) => (
|
||||||
<span className={className}>
|
<span className={className}>
|
||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
@@ -114,7 +114,7 @@ const Logo = ({ className, size }) => (
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
Logo.propTypes = propTypes;
|
ProjectAvatar.propTypes = propTypes;
|
||||||
Logo.defaultProps = defaultProps;
|
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 isSearchValueInOptions = options.map(option => option.label).includes(searchValue);
|
||||||
const isOptionCreatable = onCreate && searchValue && !isSearchValueInOptions;
|
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 (
|
return (
|
||||||
<Dropdown width={dropdownWidth}>
|
<Dropdown width={dropdownWidth}>
|
||||||
<DropdownInput
|
<DropdownInput
|
||||||
@@ -205,12 +184,33 @@ const SelectDropdown = ({
|
|||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
onChange={event => setSearchValue(event.target.value)}
|
onChange={event => setSearchValue(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isValueEmpty && withClearValue && <ClearIcon type="close" onClick={clearOptionValues} />}
|
{!isValueEmpty && withClearValue && <ClearIcon type="close" onClick={clearOptionValues} />}
|
||||||
|
|
||||||
<Options ref={$optionsRef}>
|
<Options ref={$optionsRef}>
|
||||||
{filteredOptions.map(renderSelectableOption)}
|
{filteredOptions.map(option => (
|
||||||
{isOptionCreatable && renderCreatableOption()}
|
<Option
|
||||||
{filteredOptions.length === 0 && <OptionsNoResults>No results</OptionsNoResults>}
|
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>
|
</Options>
|
||||||
|
|
||||||
|
{filteredOptions.length === 0 && <OptionsNoResults>No results</OptionsNoResults>}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -131,31 +131,6 @@ const Select = ({
|
|||||||
|
|
||||||
const isValueEmpty = isMulti ? !value.length : !getOption(value);
|
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 (
|
return (
|
||||||
<StyledSelect
|
<StyledSelect
|
||||||
className={className}
|
className={className}
|
||||||
@@ -167,12 +142,38 @@ const Select = ({
|
|||||||
>
|
>
|
||||||
<ValueContainer variant={variant} onClick={activateDropdown}>
|
<ValueContainer variant={variant} onClick={activateDropdown}>
|
||||||
{isValueEmpty && <Placeholder>{placeholder}</Placeholder>}
|
{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' && (
|
{(!isMulti || isValueEmpty) && variant !== 'empty' && (
|
||||||
<ChevronIcon type="chevron-down" top={1} />
|
<ChevronIcon type="chevron-down" top={1} />
|
||||||
)}
|
)}
|
||||||
</ValueContainer>
|
</ValueContainer>
|
||||||
|
|
||||||
{isDropdownOpen && (
|
{isDropdownOpen && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
dropdownWidth={dropdownWidth}
|
dropdownWidth={dropdownWidth}
|
||||||
|
|||||||
@@ -44,27 +44,30 @@ const Tooltip = ({ className, placement, offset, width, renderLink, renderConten
|
|||||||
$tooltipRef.current.style.top = `${top}px`;
|
$tooltipRef.current.style.top = `${top}px`;
|
||||||
$tooltipRef.current.style.left = `${left}px`;
|
$tooltipRef.current.style.left = `${left}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setTooltipPosition();
|
setTooltipPosition();
|
||||||
window.addEventListener('resize', setTooltipPosition);
|
window.addEventListener('resize', setTooltipPosition);
|
||||||
window.addEventListener('scroll', setTooltipPosition);
|
window.addEventListener('scroll', setTooltipPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', setTooltipPosition);
|
window.removeEventListener('resize', setTooltipPosition);
|
||||||
window.removeEventListener('scroll', setTooltipPosition);
|
window.removeEventListener('scroll', setTooltipPosition);
|
||||||
};
|
};
|
||||||
}, [isOpen, offset, placement]);
|
}, [isOpen, offset, placement]);
|
||||||
|
|
||||||
const renderTooltip = () => (
|
|
||||||
<StyledTooltip className={className} ref={$tooltipRef} width={width}>
|
|
||||||
{renderContent({ close: closeTooltip })}
|
|
||||||
</StyledTooltip>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderLink({ ref: $linkRef, onClick: isOpen ? closeTooltip : openTooltip })}
|
{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