Implemented issue create modal, further polish

This commit is contained in:
ireic
2019-12-23 00:30:00 +01:00
parent 6809ec494a
commit 4941261251
67 changed files with 684 additions and 237 deletions

View File

@@ -1,38 +0,0 @@
import styled from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
import { Button } from 'shared/components';
export const TypeButton = styled(Button)`
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${color.textMedium};
${font.size(13)}
`;
export const TypeDropdown = styled.div`
padding-bottom: 6px;
`;
export const TypeTitle = styled.div`
padding: 10px 0 7px 12px;
text-transform: uppercase;
color: ${color.textMedium};
${font.size(12)}
`;
export const Type = styled.div`
display: flex;
align-items: center;
padding: 7px 12px;
${mixin.clickable}
&:hover {
background: ${color.backgroundLight};
}
`;
export const TypeLabel = styled.div`
padding: 0 5px 0 7px;
text-transform: capitalize;
${font.size(15)}
`;

View File

@@ -1,38 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IssueType } from 'shared/constants/issues';
import { IssueTypeIcon, Tooltip } from 'shared/components';
import { TypeButton, TypeDropdown, TypeTitle, Type, TypeLabel } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
updateIssue: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsType = ({ issue, updateIssue }) => (
<Tooltip
width={150}
offset={{ top: -15 }}
renderLink={linkProps => (
<TypeButton {...linkProps} color="empty" icon={<IssueTypeIcon type={issue.type} />}>
{`${issue.type}-${issue.id}`}
</TypeButton>
)}
renderContent={() => (
<TypeDropdown>
<TypeTitle>Change issue type</TypeTitle>
{Object.values(IssueType).map(type => (
<Type key={type} onClick={() => updateIssue({ type })}>
<IssueTypeIcon type={type} top={1} />
<TypeLabel>{type}</TypeLabel>
</Type>
))}
</TypeDropdown>
)}
/>
);
ProjectBoardIssueDetailsType.propTypes = propTypes;
export default ProjectBoardIssueDetailsType;

View File

@@ -22,7 +22,7 @@ const ProjectBoardListsIssue = ({ projectUsers, issue, index }) => {
<Draggable draggableId={issue.id.toString()} index={index}>
{(provided, snapshot) => (
<IssueLink
to={`${match.url}/${issue.id}`}
to={`${match.url}/issue/${issue.id}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}

View File

@@ -1,16 +1,12 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Route, useRouteMatch, useHistory } from 'react-router-dom';
import { Modal } from 'shared/components';
import Header from './Header';
import Filters from './Filters';
import Lists from './Lists';
import IssueDetails from './IssueDetails';
const propTypes = {
project: PropTypes.object.isRequired,
fetchProject: PropTypes.func.isRequired,
updateLocalIssuesArray: PropTypes.func.isRequired,
};
@@ -21,11 +17,8 @@ const defaultFilters = {
recent: false,
};
const ProjectBoard = ({ project, fetchProject, updateLocalIssuesArray }) => {
const match = useRouteMatch();
const history = useHistory();
const ProjectBoard = ({ project, updateLocalIssuesArray }) => {
const [filters, setFilters] = useState(defaultFilters);
return (
<>
<Header projectName={project.name} />
@@ -36,26 +29,6 @@ const ProjectBoard = ({ project, fetchProject, updateLocalIssuesArray }) => {
setFilters={setFilters}
/>
<Lists project={project} filters={filters} updateLocalIssuesArray={updateLocalIssuesArray} />
<Route
path={`${match.path}/:issueId`}
render={({ match: { params } }) => (
<Modal
isOpen
width={1040}
withCloseIcon={false}
onClose={() => history.push(match.url)}
renderContent={modal => (
<IssueDetails
issueId={params.issueId}
projectUsers={project.users}
fetchProject={fetchProject}
updateLocalIssuesArray={updateLocalIssuesArray}
modalClose={modal.close}
/>
)}
/>
)}
/>
</>
);
};

View File

@@ -0,0 +1,39 @@
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
import { Button, Form } from 'shared/components';
export const FormElement = styled(Form.Element)`
padding: 20px 40px;
`;
export const FormHeading = styled.div`
padding-bottom: 15px;
${font.size(20)}
`;
export const SelectItem = styled.div`
display: flex;
align-items: center;
margin-right: 15px;
${props => props.withBottomMargin && `margin-bottom: 5px;`}
`;
export const SelectItemLabel = styled.div`
padding: 0 3px 0 6px;
`;
export const Divider = styled.div`
margin-top: 22px;
border-top: 1px solid ${color.borderLightest};
`;
export const Actions = styled.div`
display: flex;
justify-content: flex-end;
padding-top: 30px;
`;
export const ActionButton = styled(Button)`
margin-left: 10px;
`;

View File

@@ -0,0 +1,169 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
IssueType,
IssueStatus,
IssuePriority,
IssueTypeCopy,
IssuePriorityCopy,
} from 'shared/constants/issues';
import toast from 'shared/utils/toast';
import useApi from 'shared/hooks/api';
import { Form, IssueTypeIcon, Icon, Avatar, IssuePriorityIcon } from 'shared/components';
import {
FormHeading,
FormElement,
SelectItem,
SelectItemLabel,
Divider,
Actions,
ActionButton,
} from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
fetchProject: PropTypes.func.isRequired,
modalClose: PropTypes.func.isRequired,
};
const ProjectIssueCreateForm = ({ project, fetchProject, modalClose }) => {
const [{ isCreating }, createIssue] = useApi.post('/issues');
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
initialValues={{
status: IssueStatus.BACKLOG,
type: IssueType.TASK,
title: '',
description: '',
reporterId: null,
userIds: [],
priority: null,
}}
validations={{
type: Form.is.required(),
title: [Form.is.required(), Form.is.maxLength(200)],
reporterId: Form.is.required(),
priority: Form.is.required(),
}}
onSubmit={async (values, form) => {
try {
await createIssue({
...values,
projectId: project.id,
users: values.userIds.map(id => ({ id })),
});
await fetchProject();
modalClose();
} catch (error) {
if (error.data.fields) {
form.setErrors(error.data.fields);
} else {
toast.error(error);
}
}
}}
>
<FormElement>
<FormHeading>Create issue</FormHeading>
<Form.Field.Select
name="type"
label="Issue Type"
tip="Start typing to get a list of possible matches."
options={typeOptions}
renderOption={renderType}
renderValue={renderType}
/>
<Divider />
<Form.Field.Input
name="title"
label="Short Summary"
tip="Concisely summarize the issue in one or two sentences."
/>
<Form.Field.TextEditor
name="description"
label="Description"
tip="Describe the issue in as much detail as you'd like."
/>
<Form.Field.Select
name="reporterId"
label="Reporter"
options={userOptions}
renderOption={renderUser}
renderValue={renderUser}
/>
<Form.Field.Select
isMulti
name="userIds"
label="Assignees"
tio="People who are responsible for dealing with this issue."
options={userOptions}
renderOption={renderUser}
renderValue={renderUser}
/>
<Form.Field.Select
name="priority"
label="Priority"
tip="Priority in relation to other issues."
options={priorityOptions}
renderOption={renderPriority}
renderValue={renderPriority}
/>
<Actions>
<ActionButton type="submit" color="primary" working={isCreating}>
Create Issue
</ActionButton>
<ActionButton color="empty" onClick={modalClose}>
Cancel
</ActionButton>
</Actions>
</FormElement>
</Form>
);
};
ProjectIssueCreateForm.propTypes = propTypes;
export default ProjectIssueCreateForm;

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { KeyCodes } from 'shared/constants/keyCodes';
import { isFocusedElementEditable } from 'shared/utils/dom';
import { Tip, TipLetter } from './Style';
import { Tip, TipLetter } from './Styles';
const propTypes = {
setFormOpen: PropTypes.func.isRequired,

View File

@@ -6,7 +6,7 @@ import useApi from 'shared/hooks/api';
import toast from 'shared/utils/toast';
import BodyForm from '../BodyForm';
import ProTip from './ProTip';
import { Create, UserAvatar, Right, FakeTextarea } from './Style';
import { Create, UserAvatar, Right, FakeTextarea } from './Styles';
const propTypes = {
issueId: PropTypes.number.isRequired,

View File

@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { getTextContentsFromHtmlString } from 'shared/utils/html';
@@ -11,34 +11,30 @@ const propTypes = {
};
const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
const $editorRef = useRef();
const [isPresenting, setPresenting] = useState(true);
const [value, setValue] = useState(issue.description);
const [isEditing, setEditing] = useState(false);
const renderPresentingMode = () =>
isDescriptionEmpty(issue.description) ? (
<EmptyLabel onClick={() => setPresenting(false)}>Add a description...</EmptyLabel>
<EmptyLabel onClick={() => setEditing(true)}>Add a description...</EmptyLabel>
) : (
<TextEditedContent content={issue.description} onClick={() => setPresenting(false)} />
<TextEditedContent content={issue.description} onClick={() => setEditing(true)} />
);
const renderEditingMode = () => (
<>
<TextEditor
placeholder="Describe the issue"
defaultValue={issue.description}
getEditor={editor => ($editorRef.current = editor)}
/>
<TextEditor placeholder="Describe the issue" defaultValue={value} onChange={setValue} />
<Actions>
<Button
color="primary"
onClick={() => {
setPresenting(true);
updateIssue({ description: $editorRef.current.getHTML() });
setEditing(false);
updateIssue({ description: value });
}}
>
Save
</Button>
<Button color="empty" onClick={() => setPresenting(true)}>
<Button color="empty" onClick={() => setEditing(false)}>
Cancel
</Button>
</Actions>
@@ -47,7 +43,7 @@ const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
return (
<>
<Title>Description</Title>
{isPresenting ? renderPresentingMode() : renderEditingMode()}
{isEditing ? renderEditingMode() : renderPresentingMode()}
</>
);
};

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -18,7 +18,6 @@ export const Priority = styled.div`
`;
export const Label = styled.div`
text-transform: capitalize;
padding: 0 3px 0 8px;
${font.size(14.5)}
`;

View File

@@ -1,14 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { invert } from 'lodash';
import { IssuePriority } from 'shared/constants/issues';
import { IssuePriority, IssuePriorityCopy } from 'shared/constants/issues';
import { Select, IssuePriorityIcon } from 'shared/components';
import { Priority, Label } from './Styles';
import { SectionTitle } from '../Styles';
const IssuePriorityCopy = invert(IssuePriority);
const propTypes = {
issue: PropTypes.object.isRequired,
updateIssue: PropTypes.func.isRequired,
@@ -18,13 +15,14 @@ const ProjectBoardIssueDetailsPriority = ({ issue, updateIssue }) => {
const renderPriorityItem = (priority, isValue) => (
<Priority isValue={isValue}>
<IssuePriorityIcon priority={priority} />
<Label>{IssuePriorityCopy[priority].toLowerCase()}</Label>
<Label>{IssuePriorityCopy[priority]}</Label>
</Priority>
);
return (
<>
<SectionTitle>Priority</SectionTitle>
<Select
variant="empty"
dropdownWidth={343}
value={issue.priority}
options={Object.values(IssuePriority).map(priority => ({

View File

@@ -15,6 +15,7 @@ const ProjectBoardIssueDetailsStatus = ({ issue, updateIssue }) => (
<>
<SectionTitle>Status</SectionTitle>
<Select
variant="empty"
dropdownWidth={343}
value={issue.status}
options={Object.values(IssueStatus).map(status => ({

View File

@@ -0,0 +1,21 @@
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
import { Button } from 'shared/components';
export const TypeButton = styled(Button)`
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${color.textMedium};
${font.size(13)}
`;
export const Type = styled.div`
display: flex;
align-items: center;
`;
export const TypeLabel = styled.div`
padding: 0 5px 0 7px;
${font.size(15)}
`;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IssueType, IssueTypeCopy } from 'shared/constants/issues';
import { IssueTypeIcon, Select } from 'shared/components';
import { TypeButton, Type, TypeLabel } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
updateIssue: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsType = ({ issue, updateIssue }) => (
<Select
variant="empty"
dropdownWidth={150}
value={issue.type}
options={Object.values(IssueType).map(type => ({
value: type,
label: IssueTypeCopy[type],
}))}
onChange={type => updateIssue({ type })}
renderValue={({ value: type }) => (
<TypeButton color="empty" icon={<IssueTypeIcon type={type} />}>
{`${type}-${issue.id}`}
</TypeButton>
)}
renderOption={({ value: type }) => (
<Type key={type} onClick={() => updateIssue({ type })}>
<IssueTypeIcon type={type} top={1} />
<TypeLabel>{IssueTypeCopy[type]}</TypeLabel>
</Type>
)}
/>
);
ProjectBoardIssueDetailsType.propTypes = propTypes;
export default ProjectBoardIssueDetailsType;

View File

@@ -3,7 +3,7 @@ import styled, { css } from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
export const User = styled.div`
display: inline-flex;
display: flex;
align-items: center;
${mixin.clickable}
${props =>

View File

@@ -16,12 +16,12 @@ const ProjectBoardIssueDetailsUsers = ({ issue, updateIssue, projectUsers }) =>
const userOptions = projectUsers.map(user => ({ value: user.id, label: user.name }));
const renderUserValue = (user, withBottomMargin, removeOptionValue) => (
const renderUser = (user, isSelectValue, removeOptionValue) => (
<User
key={user.id}
isSelectValue
withBottomMargin={withBottomMargin}
onClick={() => removeOptionValue && removeOptionValue(user.id)}
isSelectValue={isSelectValue}
withBottomMargin={!!removeOptionValue}
onClick={() => removeOptionValue && removeOptionValue()}
>
<Avatar avatarUrl={user.avatarUrl} name={user.name} size={24} />
<Username>{user.name}</Username>
@@ -29,18 +29,12 @@ const ProjectBoardIssueDetailsUsers = ({ issue, updateIssue, projectUsers }) =>
</User>
);
const renderUserOption = user => (
<User key={user.id}>
<Avatar avatarUrl={user.avatarUrl} name={user.name} size={32} />
<Username>{user.name}</Username>
</User>
);
const renderAssignees = () => (
<>
<SectionTitle>Assignees</SectionTitle>
<Select
isMulti
variant="empty"
dropdownWidth={343}
placeholder="Unassigned"
value={issue.userIds}
@@ -49,9 +43,9 @@ const ProjectBoardIssueDetailsUsers = ({ issue, updateIssue, projectUsers }) =>
updateIssue({ userIds, users: userIds.map(getUserById) });
}}
renderValue={({ value, removeOptionValue }) =>
renderUserValue(getUserById(value), true, removeOptionValue)
renderUser(getUserById(value), true, removeOptionValue)
}
renderOption={({ value }) => renderUserOption(getUserById(value))}
renderOption={({ value }) => renderUser(getUserById(value), false)}
/>
</>
);
@@ -60,12 +54,13 @@ const ProjectBoardIssueDetailsUsers = ({ issue, updateIssue, projectUsers }) =>
<>
<SectionTitle>Reporter</SectionTitle>
<Select
variant="empty"
dropdownWidth={343}
value={issue.reporterId}
options={userOptions}
onChange={userId => updateIssue({ reporterId: userId })}
renderValue={({ value }) => renderUserValue(getUserById(value), false)}
renderOption={({ value }) => renderUserOption(getUserById(value))}
renderValue={({ value }) => renderUser(getUserById(value), true)}
renderOption={({ value }) => renderUser(getUserById(value))}
/>
</>
);

View File

@@ -0,0 +1,79 @@
import styled from 'styled-components';
import { NavLink } from 'react-router-dom';
import { font, sizes, color, mixin, zIndexValues } from 'shared/utils/styles';
import Logo from 'shared/components/Logo';
export const NavLeft = styled.aside`
z-index: ${zIndexValues.navLeft};
position: fixed;
top: 0;
left: 0;
overflow-x: hidden;
height: 100vh;
width: ${sizes.appNavBarLeftWidth}px;
background: ${color.backgroundDarkPrimary};
transition: all 0.1s;
${mixin.hardwareAccelerate}
&:hover {
width: 200px;
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.6);
}
`;
export const LogoLink = styled(NavLink)`
display: block;
position: relative;
left: 0;
margin: 20px 0 10px;
transition: left 0.1s;
`;
export const StyledLogo = styled(Logo)`
display: inline-block;
margin-left: 8px;
padding: 10px;
${mixin.clickable}
`;
export const Bottom = styled.div`
position: absolute;
bottom: 20px;
left: 0;
width: 100%;
`;
export const Item = styled.div`
position: relative;
width: 100%;
height: 42px;
line-height: 42px;
padding-left: 64px;
color: #deebff;
transition: color 0.1s;
${mixin.clickable}
&:hover {
background: rgba(255, 255, 255, 0.1);
}
i {
position: absolute;
left: 18px;
}
`;
export const ItemText = styled.div`
position: relative;
right: 12px;
visibility: hidden;
opacity: 0;
text-transform: uppercase;
transition: all 0.1s;
transition-property: right, visibility, opacity;
${font.bold}
${font.size(12)}
${NavLeft}:hover & {
right: 0;
visibility: visible;
opacity: 1;
}
`;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Link, useRouteMatch } from 'react-router-dom';
import { Icon } from 'shared/components';
import { NavLeft, LogoLink, StyledLogo, Bottom, Item, ItemText } from './Styles';
const ProjectNavbarLeft = () => {
const match = useRouteMatch();
return (
<NavLeft>
<LogoLink to="/">
<StyledLogo color="#fff" />
</LogoLink>
<Item>
<Icon type="search" size={22} top={1} left={3} />
<ItemText>Search issues</ItemText>
</Item>
<Link to={`${match.path}/board/create-issue`}>
<Item>
<Icon type="plus" size={27} />
<ItemText>Create Issue</ItemText>
</Item>
</Link>
<Bottom>
<Item>
<Icon type="help" size={25} />
<ItemText>Help</ItemText>
</Item>
</Bottom>
</NavLeft>
);
};
export default ProjectNavbarLeft;

View File

@@ -79,7 +79,7 @@ export const NotImplemented = styled.div`
display: none;
position: absolute;
top: 9px;
left: 104%;
left: 101%;
width: 120px;
padding: 3px 0 3px 8px;
border-radius: 3px;

View File

@@ -3,5 +3,5 @@ import styled from 'styled-components';
import { sizes } from 'shared/utils/styles';
export const ProjectPage = styled.div`
padding: 25px 32px 0 ${sizes.secondarySideBarWidth + 40}px;
padding: 25px 32px 0 ${sizes.appNavBarLeftWidth + sizes.secondarySideBarWidth + 40}px;
`;

View File

@@ -1,15 +1,20 @@
import React from 'react';
import { Route, Redirect, useRouteMatch } from 'react-router-dom';
import { Route, Redirect, useRouteMatch, useHistory } from 'react-router-dom';
import useApi from 'shared/hooks/api';
import { updateArrayItemById } from 'shared/utils/javascript';
import { PageLoader, PageError } from 'shared/components';
import { PageLoader, PageError, Modal } from 'shared/components';
import NavbarLeft from './NavbarLeft';
import Sidebar from './Sidebar';
import Board from './Board';
import IssueDetails from './IssueDetails';
import IssueCreateForm from './IssueCreateForm';
import { ProjectPage } from './Styles';
const Project = () => {
const match = useRouteMatch();
const history = useHistory();
const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project');
const updateLocalIssuesArray = (issueId, updatedFields) => {
@@ -33,15 +38,40 @@ const Project = () => {
updateLocalIssuesArray={updateLocalIssuesArray}
/>
);
const renderSettings = () => <h1>SETTINGS</h1>;
const renderIssues = () => <h1>ISSUES</h1>;
const renderIssueDetailsModal = 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}
updateLocalIssuesArray={updateLocalIssuesArray}
modalClose={modal.close}
/>
)}
/>
);
const renderIssueCreateModal = () => (
<Modal
isOpen
width={800}
onClose={() => history.push(match.url)}
renderContent={modal => (
<IssueCreateForm project={project} fetchProject={fetchProject} modalClose={modal.close} />
)}
/>
);
return (
<ProjectPage>
<NavbarLeft />
<Sidebar projectName={project.name} matchPath={match.path} />
<Route path={`${match.path}/board`} render={renderBoard} />
<Route path={`${match.path}/settings`} render={renderSettings} />
<Route path={`${match.path}/issues`} render={renderIssues} />
<Route path={`${match.path}/board/create-issue`} render={renderIssueCreateModal} />
<Route path={`${match.path}/board/issue/:issueId`} render={renderIssueDetailsModal} />
{match.isExact && <Redirect to={`${match.url}/board`} />}
</ProjectPage>
);