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

@@ -19,7 +19,8 @@ router.get(
router.post(
'/issues',
catchErrors(async (req, res) => {
const issue = await createEntity(Issue, req.body);
const listPosition = await calculateListPosition(req.body);
const issue = await createEntity(Issue, { ...req.body, listPosition });
res.respond({ issue });
}),
);
@@ -40,4 +41,15 @@ router.delete(
}),
);
const calculateListPosition = async (newIssue: Issue): Promise<number> => {
const issues = await Issue.find({
where: { projectId: newIssue.projectId, status: newIssue.status },
});
const listPositions = issues.map(({ listPosition }) => listPosition);
if (listPositions.length > 0) {
return Math.min(...listPositions) - 1;
}
return 1;
};
export default router;

View File

@@ -23,6 +23,7 @@ class Issue extends BaseEntity {
type: [is.required(), is.oneOf(Object.values(IssueType))],
status: [is.required(), is.oneOf(Object.values(IssueStatus))],
priority: [is.required(), is.oneOf(Object.values(IssuePriority))],
listPosition: is.required(),
reporterId: is.required(),
};
@@ -71,6 +72,9 @@ class Issue extends BaseEntity {
)
project: Project;
@Column('integer')
projectId: number;
@OneToMany(
() => Comment,
comment => comment.issue,

View File

@@ -28,6 +28,7 @@
"import/no-cycle": 0,
"react/no-array-index-key": 0,
"react/forbid-prop-types": 0,
"react/prop-types": [2, { "skipUndeclared": true }],
"react/state-in-constructor": 0,
"react/jsx-props-no-spreading": 0,
"jsx-a11y/click-events-have-key-events": 0

View File

@@ -2733,6 +2733,11 @@
"integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=",
"dev": true
},
"deepmerge": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA=="
},
"default-gateway": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-4.2.0.tgz",
@@ -4179,6 +4184,32 @@
"integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=",
"dev": true
},
"formik": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/formik/-/formik-2.0.8.tgz",
"integrity": "sha512-PxC9G6EvLdJzMv7z+bsvpI/Euplv2vVgTQebD5yNza4t3fiMBB+iD90VOVTLCyH5Pnv3bljsZiKsIqGp/UNKKg==",
"requires": {
"deepmerge": "^2.1.1",
"hoist-non-react-statics": "^3.3.0",
"lodash": "^4.17.14",
"lodash-es": "^4.17.14",
"react-fast-compare": "^2.0.1",
"scheduler": "^0.17.0",
"tiny-warning": "^1.0.2",
"tslib": "^1.10.0"
},
"dependencies": {
"scheduler": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.17.0.tgz",
"integrity": "sha512-7rro8Io3tnCPuY4la/NuI5F2yfESpnfZyT6TtkXnSWVkcu0BCDJ+8gk5ozUaFaxpIyNuWAPXrH0yFcSi28fnDA==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
}
}
},
"forwarded": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
@@ -6309,6 +6340,11 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
},
"lodash-es": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz",
"integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ=="
},
"log-symbols": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz",
@@ -7845,6 +7881,11 @@
"scheduler": "^0.18.0"
}
},
"react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
},
"react-is": {
"version": "16.12.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz",
@@ -9459,8 +9500,7 @@
"tslib": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz",
"integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==",
"dev": true
"integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ=="
},
"tty-browserify": {
"version": "0.0.0",

View File

@@ -40,6 +40,7 @@
"axios": "^0.19.0",
"color": "^3.1.2",
"core-js": "^3.4.7",
"formik": "^2.0.8",
"history": "^4.10.1",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.15",

View File

@@ -1,28 +0,0 @@
import React from 'react';
import { Icon } from 'shared/components';
import { NavLeft, LogoLink, StyledLogo, Bottom, Item, ItemText } from './Styles';
const NavbarLeft = () => (
<NavLeft>
<LogoLink to="/">
<StyledLogo color="#fff" />
</LogoLink>
<Item>
<Icon type="search" size={22} top={1} left={3} />
<ItemText>Search</ItemText>
</Item>
<Item>
<Icon type="plus" size={27} />
<ItemText>Create</ItemText>
</Item>
<Bottom>
<Item>
<Icon type="help" size={25} />
<ItemText>Help</ItemText>
</Item>
</Bottom>
</NavLeft>
);
export default NavbarLeft;

View File

@@ -3,24 +3,17 @@ import { Router, Switch, Route, Redirect } from 'react-router-dom';
import history from 'browserHistory';
import PageError from 'shared/components/PageError';
import Project from 'components/Project';
import NavbarLeft from './NavbarLeft';
import Project from 'Project';
import Authenticate from './Authenticate';
import { Main } from './AppStyles';
const Routes = () => (
<Router history={history}>
<Main>
<NavbarLeft />
<Switch>
<Redirect exact from="/" to="/project" />
<Route path="/authenticate" component={Authenticate} />
<Route path="/project" component={Project} />
<Route component={PageError} />
</Switch>
</Main>
<Switch>
<Redirect exact from="/" to="/project" />
<Route path="/authenticate" component={Authenticate} />
<Route path="/project" component={Project} />
<Route component={PageError} />
</Switch>
</Router>
);

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

@@ -16,7 +16,7 @@ export const NavLeft = styled.aside`
transition: all 0.1s;
${mixin.hardwareAccelerate}
&:hover {
width: 180px;
width: 200px;
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.6);
}
`;
@@ -48,7 +48,7 @@ export const Item = styled.div`
width: 100%;
height: 42px;
line-height: 42px;
padding-left: 67px;
padding-left: 64px;
color: #deebff;
transition: color 0.1s;
${mixin.clickable}

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

View File

@@ -4,6 +4,8 @@ import 'regenerator-runtime/runtime';
import React from 'react';
import ReactDOM from 'react-dom';
import App from 'components/App/App';
import App from 'App';
// TODO: UPDATE FORMIK TO FIX SETFIELDVALUE TO EMPTY ARRAY ISSUE https://github.com/jaredpalmer/formik/pull/2144
ReactDOM.render(<App />, document.getElementById('root'));

View File

@@ -0,0 +1,51 @@
import React from 'react';
import PropTypes from 'prop-types';
import { uniqueId } from 'lodash';
import Input from 'shared/components/Input';
import Select from 'shared/components/Select';
import Textarea from 'shared/components/Textarea';
import TextEditor from 'shared/components/TextEditor';
import DatePicker from 'shared/components/DatePicker';
import { StyledField, FieldLabel, FieldTip, FieldError } from './Styles';
const propTypes = {
className: PropTypes.string,
label: PropTypes.string,
tip: PropTypes.string,
error: PropTypes.string,
};
const defaultProps = {
className: undefined,
label: null,
tip: null,
error: null,
};
const generateField = FormComponent => {
const FieldComponent = ({ className, label, tip, error, ...props }) => {
const fieldId = uniqueId('form-field-');
return (
<StyledField className={className} hasLabel={!!label}>
{label && <FieldLabel htmlFor={fieldId}>{label}</FieldLabel>}
<FormComponent id={fieldId} invalid={!!error} {...props} />
{tip && <FieldTip>{tip}</FieldTip>}
{error && <FieldError>{error}</FieldError>}
</StyledField>
);
};
FieldComponent.propTypes = propTypes;
FieldComponent.defaultProps = defaultProps;
return FieldComponent;
};
export default {
Input: generateField(Input),
Select: generateField(Select),
Textarea: generateField(Textarea),
TextEditor: generateField(TextEditor),
DatePicker: generateField(DatePicker),
};

View File

@@ -0,0 +1,29 @@
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const StyledField = styled.div`
margin-top: 20px;
`;
export const FieldLabel = styled.label`
display: block;
padding-bottom: 5px;
color: ${color.textMedium};
${font.medium}
${font.size(13)}
`;
export const FieldTip = styled.div`
padding-top: 6px;
color: ${color.textMedium};
${font.size(12.5)}
`;
export const FieldError = styled.div`
margin-top: 6px;
line-height: 1;
color: ${color.danger};
${font.medium}
${font.size(12.5)}
`;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Formik, Form as FormikForm, Field as FormikField } from 'formik';
import { get, mapValues } from 'lodash';
import { is, generateErrors } from 'shared/utils/validation';
import Field from './Field';
const propTypes = {
validate: PropTypes.func,
validations: PropTypes.object,
validateOnBlur: PropTypes.bool,
};
const defaultProps = {
validate: undefined,
validations: undefined,
validateOnBlur: false,
};
const Form = ({ validate, validations, ...otherProps }) => (
<Formik
{...otherProps}
validate={values => {
if (validate) {
return validate(values);
}
if (validations) {
return generateErrors(values, validations);
}
return {};
}}
/>
);
Form.Element = props => <FormikForm noValidate {...props} />;
Form.Field = mapValues(Field, FieldComponent => ({ name, validate, ...props }) => (
<FormikField name={name} validate={validate}>
{({ field, form: { touched, errors, setFieldValue } }) => (
<FieldComponent
{...field}
{...props}
name={name}
error={get(touched, name) && get(errors, name)}
onChange={value => setFieldValue(name, value)}
/>
)}
</FormikField>
));
Form.is = is;
Form.propTypes = propTypes;
Form.defaultProps = defaultProps;
export default Form;

View File

@@ -1,4 +1,4 @@
import styled from 'styled-components';
import styled, { css } from 'styled-components';
import { color, font } from 'shared/utils/styles';
@@ -25,8 +25,16 @@ export default styled.div`
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
}
${props => (props.icon ? 'padding-left: 32px;' : '')}
${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
${props => props.icon && 'padding-left: 32px;'}
${props =>
props.invalid &&
css`
&,
&:focus {
border: 1px solid ${color.danger};
box-shadow: none;
}
`}
}
i {
position: absolute;

View File

@@ -44,6 +44,7 @@ const modalStyles = {
max-width: ${props => props.width}px;
vertical-align: middle;
text-align: left;
border-radius: 3px;
${mixin.boxShadowMedium}
`,
aside: css`

View File

@@ -7,27 +7,35 @@ export const StyledSelect = styled.div`
position: relative;
border-radius: 4px;
cursor: pointer;
background: #fff;
${font.size(14)}
${props => props.variant === 'empty' && `display: inline-block;`}
${props =>
props.variant === 'border' &&
props.variant === 'normal' &&
css`
width: 100%;
border: 1px solid ${color.borderLightest};
background: ${color.backgroundLightest};
`}
&:focus {
outline: none;
${props =>
props.variant === 'border' &&
props.variant === 'normal' &&
css`
background: #fff;
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
background: #fff;
}
`}
}
${props => props.invalid && `&, &:focus { border: 1px solid ${color.danger}; }`}
${props =>
props.invalid &&
css`
&,
&:focus {
border: 1px solid ${color.danger};
box-shadow: none;
}
`}
`;
export const ValueContainer = styled.div`
@@ -35,10 +43,10 @@ export const ValueContainer = styled.div`
align-items: center;
width: 100%;
${props =>
props.variant === 'border' &&
props.variant === 'normal' &&
css`
min-height: 32px;
padding: 8px 5px 8px 10px;
padding: 5px 5px 5px 10px;
`}
`;
@@ -56,7 +64,7 @@ export const ValueMulti = styled.div`
display: flex;
align-items: center;
flex-wrap: wrap;
${props => props.variant === 'border' && `padding-top: 5px;`}
${props => props.variant === 'normal' && `padding-top: 5px;`}
`;
export const ValueMultiItem = styled.div`

View File

@@ -17,7 +17,7 @@ import {
const propTypes = {
className: PropTypes.string,
variant: PropTypes.oneOf(['border', 'empty']),
variant: PropTypes.oneOf(['normal', 'empty']),
dropdownWidth: PropTypes.number,
value: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
defaultValue: PropTypes.any,
@@ -33,7 +33,7 @@ const propTypes = {
const defaultProps = {
className: undefined,
variant: 'empty',
variant: 'normal',
dropdownWidth: undefined,
value: undefined,
defaultValue: undefined,
@@ -134,7 +134,10 @@ const Select = ({
<ValueMulti variant={variant}>
{value.map(optionValue =>
propsRenderValue ? (
propsRenderValue({ value: optionValue, removeOptionValue })
propsRenderValue({
value: optionValue,
removeOptionValue: () => removeOptionValue(optionValue),
})
) : (
<ValueMultiItem key={optionValue} onClick={() => removeOptionValue(optionValue)}>
{getOptionLabel(optionValue)}

View File

@@ -10,47 +10,68 @@ const propTypes = {
className: PropTypes.string,
placeholder: PropTypes.string,
defaultValue: PropTypes.string,
getEditor: PropTypes.func.isRequired,
value: PropTypes.string,
onChange: PropTypes.func,
getEditor: PropTypes.func,
};
const defaultProps = {
className: undefined,
placeholder: undefined,
defaultValue: undefined,
value: undefined,
onChange: () => {},
getEditor: () => {},
};
const TextEditor = ({ className, placeholder, defaultValue, getEditor, ...otherProps }) => {
const TextEditor = ({
className,
placeholder,
defaultValue,
// we're not really feeding new value to quill instance on each render because it's too
// expensive, but we're still accepting 'value' prop as alias for defaultValue because
// other components like <Form.Field> feed their children with data via the 'value' prop
value: alsoDefaultValue,
onChange,
getEditor,
}) => {
const $editorContRef = useRef();
const $editorRef = useRef();
const quillRef = useRef();
const initialValueRef = useRef(defaultValue || alsoDefaultValue || '');
useLayoutEffect(() => {
let editor = null;
const setup = () => {
editor = new Quill($editorRef.current, { placeholder, ...editorConfig });
editor.clipboard.dangerouslyPasteHTML(0, defaultValue);
getEditor({
getHTML: () => $editorContRef.current.querySelector('.ql-editor').innerHTML,
});
const setupQuill = () => {
quillRef.current = new Quill($editorRef.current, { placeholder, ...quillConfig });
};
setup();
const insertInitialValue = () => {
quillRef.current.clipboard.dangerouslyPasteHTML(0, initialValueRef.current);
};
const handleContentsChange = () => {
onChange(getHTMLValue());
};
const getHTMLValue = () => $editorContRef.current.querySelector('.ql-editor').innerHTML;
setupQuill();
insertInitialValue();
getEditor({ getValue: getHTMLValue });
quillRef.current.on('text-change', handleContentsChange);
return () => {
editor = null;
quillRef.current.off('text-change', handleContentsChange);
quillRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<EditorCont className={className} ref={$editorContRef}>
<div ref={$editorRef} {...otherProps} />
<div ref={$editorRef} />
</EditorCont>
);
};
const editorConfig = {
const quillConfig = {
theme: 'snow',
modules: {
toolbar: [

View File

@@ -3,7 +3,7 @@ export { default as Button } from './Button';
export { default as ConfirmModal } from './ConfirmModal';
export { default as CopyLinkButton } from './CopyLinkButton';
export { default as DatePicker } from './DatePicker';
export { default as Tooltip } from './Tooltip';
export { default as Form } from './Form';
export { default as Icon } from './Icon';
export { default as Input } from './Input';
export { default as InputDebounced } from './InputDebounced';
@@ -19,3 +19,4 @@ export { default as Spinner } from './Spinner';
export { default as Textarea } from './Textarea';
export { default as TextEditedContent } from './TextEditedContent';
export { default as TextEditor } from './TextEditor';
export { default as Tooltip } from './Tooltip';

View File

@@ -19,9 +19,23 @@ export const IssuePriority = {
LOWEST: '1',
};
export const IssueTypeCopy = {
[IssueType.TASK]: 'Task',
[IssueType.BUG]: 'Bug',
[IssueType.STORY]: 'Story',
};
export const IssueStatusCopy = {
[IssueStatus.BACKLOG]: 'Backlog',
[IssueStatus.SELECTED]: 'Selected for development',
[IssueStatus.INPROGRESS]: 'In progress',
[IssueStatus.DONE]: 'Done',
};
export const IssuePriorityCopy = {
[IssuePriority.HIGHEST]: 'Highest',
[IssuePriority.HIGH]: 'High',
[IssuePriority.MEDIUM]: 'Medium',
[IssuePriority.LOW]: 'Low',
[IssuePriority.LOWEST]: 'Lowest',
};

View File

@@ -9,7 +9,7 @@ const useApi = (method, url, variables = {}, { lazy = false } = {}) => {
const [state, setState] = useState({
data: null,
error: null,
isLoading: isCalledAutomatically,
isWorking: isCalledAutomatically,
additionalVariables: {},
});
@@ -27,17 +27,17 @@ const useApi = (method, url, variables = {}, { lazy = false } = {}) => {
const additionalVariables = { ...stateRef.current.additionalVariables, ...newVariables };
if (!isCalledAutomatically || wasCalledRef.current) {
setStateMerge({ additionalVariables, isLoading: true });
setStateMerge({ additionalVariables, isWorking: true });
}
api[method](url, { ...variablesMemoized, ...additionalVariables }).then(
data => {
resolve(data);
setStateMerge({ data, error: null, isLoading: false });
setStateMerge({ data, error: null, isWorking: false });
},
error => {
reject(error);
setStateMerge({ error, data: null, isLoading: false });
setStateMerge({ error, data: null, isWorking: false });
},
);
@@ -61,6 +61,7 @@ const useApi = (method, url, variables = {}, { lazy = false } = {}) => {
const result = [
{
...state,
[isWorkingAlias[method]]: state.isWorking,
wasCalled: wasCalledRef.current,
variables: { ...variablesMemoized, ...state.additionalVariables },
setLocalData,
@@ -71,6 +72,14 @@ const useApi = (method, url, variables = {}, { lazy = false } = {}) => {
return result;
};
const isWorkingAlias = {
get: 'isLoading',
post: 'isCreating',
put: 'isUpdating',
patch: 'isUpdating',
delete: 'isDeleting',
};
/* eslint-disable react-hooks/rules-of-hooks */
export default {
get: (...args) => useApi('get', ...args),

View File

@@ -15,6 +15,7 @@ const defaults = {
code: 'INTERNAL_ERROR',
message: 'Something went wrong. Please check your internet connection or contact our support.',
status: 503,
data: {},
},
};

View File

@@ -176,7 +176,7 @@ export const mixin = {
text-decoration: underline;
}
`,
tag: (background = color.backgroundLight, colorValue = color.textDarkest) => `
tag: (background = color.backgroundMedium, colorValue = color.textDarkest) => `
display: inline-flex;
align-items: center;
height: 24px;

View File

@@ -16,5 +16,6 @@
"hooks": {
"pre-commit": "npm run pre-commit"
}
}
},
"dependencies": {}
}