Refactored code structure

This commit is contained in:
ireic
2019-12-29 18:43:11 +01:00
parent bbda9b9d03
commit ad74afb628
54 changed files with 650 additions and 624 deletions

View File

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

View File

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

View File

@@ -15,21 +15,17 @@ 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 isDescriptionEmpty = getTextContentsFromHtmlString(description).trim().length === 0;
const renderEditingMode = () => (
return (
<>
<Title>Description</Title>
{isEditing ? (
<>
<TextEditor
placeholder="Describe the issue"
@@ -45,12 +41,15 @@ const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
</Button>
</Actions>
</>
);
return (
) : (
<>
<Title>Description</Title>
{isEditing ? renderEditingMode() : renderPresentingMode()}
{isDescriptionEmpty ? (
<EmptyLabel onClick={() => setEditing(true)}>Add a description...</EmptyLabel>
) : (
<TextEditedContent content={description} onClick={() => setEditing(true)} />
)}
</>
)}
</>
);
};

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,29 @@ const propTypes = {
const ProjectSidebar = ({ project }) => {
const match = useRouteMatch();
const renderLinkItem = (text, iconType, path) => {
return (
<Sidebar>
<ProjectInfo>
<ProjectAvatar />
<ProjectTexts>
<ProjectName>{project.name}</ProjectName>
<ProjectCategory>{ProjectCategoryCopy[project.category]} project</ProjectCategory>
</ProjectTexts>
</ProjectInfo>
{renderLinkItem(match, 'Kanban Board', 'board', '/board')}
{renderLinkItem(match, 'Project settings', 'settings', '/settings')}
<Divider />
{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
@@ -38,28 +60,6 @@ const ProjectSidebar = ({ project }) => {
{!isImplemented && <NotImplemented>Not implemented</NotImplemented>}
</LinkItem>
);
};
return (
<Sidebar>
<ProjectInfo>
<ProjectAvatar />
<ProjectTexts>
<ProjectName>{project.name}</ProjectName>
<ProjectCategory>{ProjectCategoryCopy[project.category]} project</ProjectCategory>
</ProjectTexts>
</ProjectInfo>
{renderLinkItem('Kanban Board', 'board', '/board')}
{renderLinkItem('Project settings', 'settings', '/settings')}
<Divider />
{renderLinkItem('Releases', 'shipping')}
{renderLinkItem('Issues and filters', 'issues')}
{renderLinkItem('Pages', 'page')}
{renderLinkItem('Reports', 'reports')}
{renderLinkItem('Components', 'component')}
</Sidebar>
);
};
ProjectSidebar.propTypes = propTypes;

View File

@@ -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,7 +37,16 @@ const Project = () => {
}));
};
const renderIssueSearchModal = () => (
return (
<ProjectPage>
<NavbarLeft
issueSearchModalOpen={issueSearchModalHelpers.open}
issueCreateModalOpen={issueCreateModalHelpers.open}
/>
<Sidebar project={project} />
{issueSearchModalHelpers.isOpen() && (
<Modal
isOpen
variant="aside"
@@ -46,9 +54,9 @@ const Project = () => {
onClose={issueSearchModalHelpers.close}
renderContent={() => <IssueSearch project={project} />}
/>
);
)}
const renderIssueCreateModal = () => (
{issueCreateModalHelpers.isOpen() && (
<Modal
isOpen
width={800}
@@ -63,52 +71,24 @@ const Project = () => {
/>
)}
/>
);
)}
const renderBoard = () => (
<Route
path={`${match.path}/board`}
render={() => (
<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}
<Route
path={`${match.path}/settings`}
render={() => <ProjectSettings project={project} fetchProject={fetchProject} />}
/>
<Sidebar project={project} />
{issueSearchModalHelpers.isOpen() && renderIssueSearchModal()}
{issueCreateModalHelpers.isOpen() && renderIssueCreateModal()}
<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>
);

View File

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

View File

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

View File

@@ -57,7 +57,12 @@ const Modal = ({
useOnEscapeKeyDown(isOpen, closeModal);
useEffect(setBodyScrollLock, [isOpen]);
const renderModal = () => (
return (
<>
{!isControlled && renderLink({ open: () => setStateOpen(true) })}
{isOpen &&
ReactDOM.createPortal(
<ScrollOverlay data-jira-modal="true">
<ClickableOverlay variant={variant} ref={$clickableOverlayRef}>
<StyledModal className={className} variant={variant} width={width} ref={$modalRef}>
@@ -65,13 +70,9 @@ const Modal = ({
{renderContent({ close: closeModal })}
</StyledModal>
</ClickableOverlay>
</ScrollOverlay>
);
return (
<>
{!isControlled && renderLink({ open: () => setStateOpen(true) })}
{isOpen && ReactDOM.createPortal(renderModal(), $root)}
</ScrollOverlay>,
$root,
)}
</>
);
};

View File

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

View File

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

View File

@@ -131,10 +131,23 @@ const Select = ({
const isValueEmpty = isMulti ? !value.length : !getOption(value);
const renderSingleValue = () =>
propsRenderValue ? propsRenderValue({ value }) : getOptionLabel(value);
return (
<StyledSelect
className={className}
variant={variant}
ref={$selectRef}
tabIndex="0"
onKeyDown={handleFocusedSelectKeydown}
invalid={invalid}
>
<ValueContainer variant={variant} onClick={activateDropdown}>
{isValueEmpty && <Placeholder>{placeholder}</Placeholder>}
const renderMultiValue = () => (
{!isValueEmpty && !isMulti && propsRenderValue
? propsRenderValue({ value })
: getOptionLabel(value)}
{!isValueEmpty && isMulti && (
<ValueMulti variant={variant}>
{value.map(optionValue =>
propsRenderValue ? (
@@ -154,25 +167,13 @@ const Select = ({
Add more
</AddMore>
</ValueMulti>
);
)}
return (
<StyledSelect
className={className}
variant={variant}
ref={$selectRef}
tabIndex="0"
onKeyDown={handleFocusedSelectKeydown}
invalid={invalid}
>
<ValueContainer variant={variant} onClick={activateDropdown}>
{isValueEmpty && <Placeholder>{placeholder}</Placeholder>}
{!isValueEmpty && !isMulti && renderSingleValue()}
{!isValueEmpty && isMulti && renderMultiValue()}
{(!isMulti || isValueEmpty) && variant !== 'empty' && (
<ChevronIcon type="chevron-down" top={1} />
)}
</ValueContainer>
{isDropdownOpen && (
<Dropdown
dropdownWidth={dropdownWidth}

View File

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