Implemented issue create modal, further polish
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
44
client/package-lock.json
generated
44
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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)}
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
39
client/src/Project/IssueCreateForm/Styles.js
Normal file
39
client/src/Project/IssueCreateForm/Styles.js
Normal 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;
|
||||
`;
|
||||
169
client/src/Project/IssueCreateForm/index.jsx
Normal file
169
client/src/Project/IssueCreateForm/index.jsx
Normal 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;
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -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)}
|
||||
`;
|
||||
@@ -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 => ({
|
||||
@@ -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 => ({
|
||||
21
client/src/Project/IssueDetails/Type/Styles.js
Normal file
21
client/src/Project/IssueDetails/Type/Styles.js
Normal 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)}
|
||||
`;
|
||||
39
client/src/Project/IssueDetails/Type/index.jsx
Normal file
39
client/src/Project/IssueDetails/Type/index.jsx
Normal 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;
|
||||
@@ -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 =>
|
||||
@@ -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))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -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}
|
||||
34
client/src/Project/NavbarLeft/index.jsx
Normal file
34
client/src/Project/NavbarLeft/index.jsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
51
client/src/shared/components/Form/Field.jsx
Normal file
51
client/src/shared/components/Form/Field.jsx
Normal 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),
|
||||
};
|
||||
29
client/src/shared/components/Form/Styles.js
Normal file
29
client/src/shared/components/Form/Styles.js
Normal 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)}
|
||||
`;
|
||||
57
client/src/shared/components/Form/index.jsx
Normal file
57
client/src/shared/components/Form/index.jsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
"hooks": {
|
||||
"pre-commit": "npm run pre-commit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user