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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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