Implemented project settings page, search issues modal, general refactoring
This commit is contained in:
5
api/package-lock.json
generated
5
api/package-lock.json
generated
@@ -3870,6 +3870,11 @@
|
|||||||
"integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==",
|
"integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"striptags": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/striptags/-/striptags-3.1.1.tgz",
|
||||||
|
"integrity": "sha1-yMPn/db7S7OjKjt1LltePjgJPr0="
|
||||||
|
},
|
||||||
"supports-color": {
|
"supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"module-alias": "^2.2.2",
|
"module-alias": "^2.2.2",
|
||||||
"pg": "^7.14.0",
|
"pg": "^7.14.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"striptags": "^3.1.1",
|
||||||
"typeorm": "^0.2.20"
|
"typeorm": "^0.2.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import { updateEntity, deleteEntity, createEntity, findEntityOrThrow } from 'uti
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/issues',
|
||||||
|
catchErrors(async (req, res) => {
|
||||||
|
const { projectId } = req.currentUser;
|
||||||
|
const { searchTerm } = req.query;
|
||||||
|
|
||||||
|
let whereSQL = 'issue.projectId = :projectId';
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
whereSQL += ' AND (issue.title ILIKE :searchTerm OR issue.descriptionText ILIKE :searchTerm)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = await Issue.createQueryBuilder('issue')
|
||||||
|
.select()
|
||||||
|
.where(whereSQL, { projectId, searchTerm: `%${searchTerm}%` })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
res.respond({ issues });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/issues/:issueId',
|
'/issues/:issueId',
|
||||||
catchErrors(async (req, res) => {
|
catchErrors(async (req, res) => {
|
||||||
@@ -41,11 +62,11 @@ router.delete(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const calculateListPosition = async (newIssue: Issue): Promise<number> => {
|
const calculateListPosition = async ({ projectId, status }: Issue): Promise<number> => {
|
||||||
const issues = await Issue.find({
|
const issues = await Issue.find({ projectId, status });
|
||||||
where: { projectId: newIssue.projectId, status: newIssue.status },
|
|
||||||
});
|
|
||||||
const listPositions = issues.map(({ listPosition }) => listPosition);
|
const listPositions = issues.map(({ listPosition }) => listPosition);
|
||||||
|
|
||||||
if (listPositions.length > 0) {
|
if (listPositions.length > 0) {
|
||||||
return Math.min(...listPositions) - 1;
|
return Math.min(...listPositions) - 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ router.get(
|
|||||||
);
|
);
|
||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
'/projects/:projectId',
|
'/project',
|
||||||
catchErrors(async (req, res) => {
|
catchErrors(async (req, res) => {
|
||||||
const project = await updateEntity(Project, req.params.projectId, req.body);
|
const project = await updateEntity(Project, req.currentUser.projectId, req.body);
|
||||||
res.respond({ project });
|
res.respond({ project });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const createDatabaseConnection = (): Promise<Connection> =>
|
|||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_DATABASE,
|
database: process.env.DB_DATABASE,
|
||||||
entities: Object.values(entities),
|
entities: Object.values(entities),
|
||||||
|
synchronize: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default createDatabaseConnection;
|
export default createDatabaseConnection;
|
||||||
|
|||||||
@@ -74,8 +74,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
|||||||
status: IssueStatus.BACKLOG,
|
status: IssueStatus.BACKLOG,
|
||||||
priority: IssuePriority.HIGH,
|
priority: IssuePriority.HIGH,
|
||||||
listPosition: 4,
|
listPosition: 4,
|
||||||
description:
|
description: '#### Colons can be used to align columns.',
|
||||||
"#### Colons can be used to align columns.\n\n| Tables | Are | Cool |\n| ------------- |:-------------:| -----:|\n| col 3 is | right-aligned | |\n| col 2 is | centered | |\n| zebra stripes | are neat | |\n\nThe outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.\n\nMarkdown | Less | Pretty\n--- | --- | ---\n*Still* | `renders` | **nicely**\n1 | 2 | 3",
|
|
||||||
estimate: 4,
|
estimate: 4,
|
||||||
reporterId: getRandomUser().id,
|
reporterId: getRandomUser().id,
|
||||||
project,
|
project,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import striptags from 'striptags';
|
||||||
import {
|
import {
|
||||||
BaseEntity,
|
BaseEntity,
|
||||||
Entity,
|
Entity,
|
||||||
@@ -10,6 +11,8 @@ import {
|
|||||||
ManyToMany,
|
ManyToMany,
|
||||||
JoinTable,
|
JoinTable,
|
||||||
RelationId,
|
RelationId,
|
||||||
|
BeforeUpdate,
|
||||||
|
BeforeInsert,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
import is from 'utils/validation';
|
import is from 'utils/validation';
|
||||||
@@ -48,6 +51,9 @@ class Issue extends BaseEntity {
|
|||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
descriptionText: string | null;
|
||||||
|
|
||||||
@Column('integer', { nullable: true })
|
@Column('integer', { nullable: true })
|
||||||
estimate: number | null;
|
estimate: number | null;
|
||||||
|
|
||||||
@@ -90,6 +96,14 @@ class Issue extends BaseEntity {
|
|||||||
|
|
||||||
@RelationId((issue: Issue) => issue.users)
|
@RelationId((issue: Issue) => issue.users)
|
||||||
userIds: number[];
|
userIds: number[];
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
@BeforeUpdate()
|
||||||
|
setDescriptionText = (): void => {
|
||||||
|
if (this.description) {
|
||||||
|
this.descriptionText = striptags(this.description);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Issue;
|
export default Issue;
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ class Project extends BaseEntity {
|
|||||||
static validations = {
|
static validations = {
|
||||||
name: [is.required(), is.maxLength(100)],
|
name: [is.required(), is.maxLength(100)],
|
||||||
url: is.url(),
|
url: is.url(),
|
||||||
description: is.maxLength(10000),
|
|
||||||
category: [is.required(), is.oneOf(Object.values(ProjectCategory))],
|
category: [is.required(), is.oneOf(Object.values(ProjectCategory))],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|||||||
|
|
||||||
const isErrorSafeForClient = error instanceof CustomError;
|
const isErrorSafeForClient = error instanceof CustomError;
|
||||||
|
|
||||||
const errorData = isErrorSafeForClient
|
const clientError = isErrorSafeForClient
|
||||||
? pick(error, ['message', 'code', 'status', 'data'])
|
? pick(error, ['message', 'code', 'status', 'data'])
|
||||||
: {
|
: {
|
||||||
message: 'Something went wrong, please contact our support.',
|
message: 'Something went wrong, please contact our support.',
|
||||||
@@ -17,5 +17,5 @@ export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|||||||
data: {},
|
data: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
res.status(errorData.status).send({ error: errorData });
|
res.status(clientError.status).send({ error: clientError });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,6 @@ import { verifyToken } from 'utils/authToken';
|
|||||||
import { catchErrors, InvalidTokenError } from 'errors';
|
import { catchErrors, InvalidTokenError } from 'errors';
|
||||||
import { User } from 'entities';
|
import { User } from 'entities';
|
||||||
|
|
||||||
const getAuthTokenFromRequest = (req: Request): string | null => {
|
|
||||||
const header = req.get('Authorization') || '';
|
|
||||||
const [bearer, token] = header.split(' ');
|
|
||||||
return bearer === 'Bearer' && token ? token : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const authenticateUser = catchErrors(async (req, _res, next) => {
|
export const authenticateUser = catchErrors(async (req, _res, next) => {
|
||||||
const token = getAuthTokenFromRequest(req);
|
const token = getAuthTokenFromRequest(req);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -26,3 +20,9 @@ export const authenticateUser = catchErrors(async (req, _res, next) => {
|
|||||||
req.currentUser = user;
|
req.currentUser = user;
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getAuthTokenFromRequest = (req: Request): string | null => {
|
||||||
|
const header = req.get('Authorization') || '';
|
||||||
|
const [bearer, token] = header.split(' ');
|
||||||
|
return bearer === 'Bearer' && token ? token : null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const generateErrors = (
|
|||||||
Object.entries(fieldValidators).forEach(([fieldName, validators]) => {
|
Object.entries(fieldValidators).forEach(([fieldName, validators]) => {
|
||||||
[validators].flat().forEach(validator => {
|
[validators].flat().forEach(validator => {
|
||||||
const errorMessage = validator(fieldValues[fieldName], fieldValues);
|
const errorMessage = validator(fieldValues[fieldName], fieldValues);
|
||||||
|
|
||||||
if (errorMessage !== false && !fieldErrors[fieldName]) {
|
if (errorMessage !== false && !fieldErrors[fieldName]) {
|
||||||
fieldErrors[fieldName] = errorMessage;
|
fieldErrors[fieldName] = errorMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default createGlobalStyle`
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
a, a:hover, a:visited, a:active {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const CloseIcon = styled(Icon)`
|
|||||||
|
|
||||||
export const Title = styled.div`
|
export const Title = styled.div`
|
||||||
padding-right: 22px;
|
padding-right: 22px;
|
||||||
${font.size(16)}
|
${font.size(15)}
|
||||||
${font.medium}
|
${font.medium}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -20,16 +20,16 @@ const propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, mergeFilters }) => {
|
const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, mergeFilters }) => {
|
||||||
const { searchQuery, userIds, myOnly, recent } = filters;
|
const { searchTerm, userIds, myOnly, recent } = filters;
|
||||||
|
|
||||||
const areFiltersCleared = !searchQuery && userIds.length === 0 && !myOnly && !recent;
|
const areFiltersCleared = !searchTerm && userIds.length === 0 && !myOnly && !recent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Filters>
|
<Filters>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
icon="search"
|
icon="search"
|
||||||
value={searchQuery}
|
value={searchTerm}
|
||||||
onChange={value => mergeFilters({ searchQuery: value })}
|
onChange={value => mergeFilters({ searchTerm: value })}
|
||||||
/>
|
/>
|
||||||
<Avatars>
|
<Avatars>
|
||||||
{projectUsers.map(user => (
|
{projectUsers.map(user => (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const ProjectBoardListsIssue = ({ projectUsers, issue, index }) => {
|
|||||||
<Draggable draggableId={issue.id.toString()} index={index}>
|
<Draggable draggableId={issue.id.toString()} index={index}>
|
||||||
{(provided, snapshot) => (
|
{(provided, snapshot) => (
|
||||||
<IssueLink
|
<IssueLink
|
||||||
to={`${match.url}/issue/${issue.id}`}
|
to={`${match.url}/issues/${issue.id}`}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||||
import { get, intersection } from 'lodash';
|
import { intersection } from 'lodash';
|
||||||
|
|
||||||
import api from 'shared/utils/api';
|
import api from 'shared/utils/api';
|
||||||
import useApi from 'shared/hooks/api';
|
import useCurrentUser from 'shared/hooks/currentUser';
|
||||||
import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/javascript';
|
import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/javascript';
|
||||||
import { IssueStatus, IssueStatusCopy } from 'shared/constants/issues';
|
import { IssueStatus, IssueStatusCopy } from 'shared/constants/issues';
|
||||||
|
|
||||||
@@ -15,26 +15,24 @@ import { Lists, List, Title, IssuesCount, Issues } from './Styles';
|
|||||||
const propTypes = {
|
const propTypes = {
|
||||||
project: PropTypes.object.isRequired,
|
project: PropTypes.object.isRequired,
|
||||||
filters: PropTypes.object.isRequired,
|
filters: PropTypes.object.isRequired,
|
||||||
updateLocalIssuesArray: PropTypes.func.isRequired,
|
updateLocalProjectIssues: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectBoardLists = ({ project, filters, updateLocalIssuesArray }) => {
|
const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => {
|
||||||
const [{ data: currentUserData }] = useApi.get('/currentUser');
|
const { currentUserId } = useCurrentUser();
|
||||||
const currentUserId = get(currentUserData, 'currentUser.id');
|
|
||||||
|
|
||||||
const handleIssueDrop = async ({ draggableId, destination, source }) => {
|
const handleIssueDrop = async ({ draggableId, destination, source }) => {
|
||||||
if (!isPositionChanged(source, destination)) return;
|
if (!isPositionChanged(source, destination)) return;
|
||||||
|
|
||||||
const issueId = Number(draggableId);
|
const issueId = Number(draggableId);
|
||||||
|
|
||||||
api.optimisticUpdate({
|
api.optimisticUpdate(`/issues/${issueId}`, {
|
||||||
url: `/issues/${issueId}`,
|
|
||||||
updatedFields: {
|
updatedFields: {
|
||||||
status: destination.droppableId,
|
status: destination.droppableId,
|
||||||
listPosition: calculateListPosition(project.issues, destination, source, issueId),
|
listPosition: calculateListPosition(project.issues, destination, source, issueId),
|
||||||
},
|
},
|
||||||
currentFields: project.issues.find(({ id }) => id === issueId),
|
currentFields: project.issues.find(({ id }) => id === issueId),
|
||||||
setLocalData: fields => updateLocalIssuesArray(issueId, fields),
|
setLocalData: fields => updateLocalProjectIssues(issueId, fields),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,11 +71,11 @@ const ProjectBoardLists = ({ project, filters, updateLocalIssuesArray }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filterIssues = (projectIssues, filters, currentUserId) => {
|
const filterIssues = (projectIssues, filters, currentUserId) => {
|
||||||
|
const { searchTerm, userIds, myOnly, recent } = filters;
|
||||||
let issues = projectIssues;
|
let issues = projectIssues;
|
||||||
const { searchQuery, userIds, myOnly, recent } = filters;
|
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchTerm) {
|
||||||
issues = issues.filter(issue => issue.title.toLowerCase().includes(searchQuery.toLowerCase()));
|
issues = issues.filter(issue => issue.title.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
}
|
}
|
||||||
if (userIds.length > 0) {
|
if (userIds.length > 0) {
|
||||||
issues = issues.filter(issue => intersection(issue.userIds, userIds).length > 0);
|
issues = issues.filter(issue => intersection(issue.userIds, userIds).length > 0);
|
||||||
|
|||||||
@@ -9,18 +9,19 @@ import Lists from './Lists';
|
|||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
project: PropTypes.object.isRequired,
|
project: PropTypes.object.isRequired,
|
||||||
updateLocalIssuesArray: PropTypes.func.isRequired,
|
updateLocalProjectIssues: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultFilters = {
|
const defaultFilters = {
|
||||||
searchQuery: '',
|
searchTerm: '',
|
||||||
userIds: [],
|
userIds: [],
|
||||||
myOnly: false,
|
myOnly: false,
|
||||||
recent: false,
|
recent: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectBoard = ({ project, updateLocalIssuesArray }) => {
|
const ProjectBoard = ({ project, updateLocalProjectIssues }) => {
|
||||||
const [filters, mergeFilters] = useMergeState(defaultFilters);
|
const [filters, mergeFilters] = useMergeState(defaultFilters);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header projectName={project.name} />
|
<Header projectName={project.name} />
|
||||||
@@ -30,7 +31,11 @@ const ProjectBoard = ({ project, updateLocalIssuesArray }) => {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
mergeFilters={mergeFilters}
|
mergeFilters={mergeFilters}
|
||||||
/>
|
/>
|
||||||
<Lists project={project} filters={filters} updateLocalIssuesArray={updateLocalIssuesArray} />
|
<Lists
|
||||||
|
project={project}
|
||||||
|
filters={filters}
|
||||||
|
updateLocalProjectIssues={updateLocalProjectIssues}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { color, font } from 'shared/utils/styles';
|
|||||||
import { Button, Form } from 'shared/components';
|
import { Button, Form } from 'shared/components';
|
||||||
|
|
||||||
export const FormElement = styled(Form.Element)`
|
export const FormElement = styled(Form.Element)`
|
||||||
padding: 20px 40px;
|
padding: 25px 40px 35px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FormHeading = styled.div`
|
export const FormHeading = styled.div`
|
||||||
padding-bottom: 15px;
|
padding-bottom: 15px;
|
||||||
${font.size(20)}
|
${font.size(21)}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const SelectItem = styled.div`
|
export const SelectItem = styled.div`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from 'shared/constants/issues';
|
} from 'shared/constants/issues';
|
||||||
import toast from 'shared/utils/toast';
|
import toast from 'shared/utils/toast';
|
||||||
import useApi from 'shared/hooks/api';
|
import useApi from 'shared/hooks/api';
|
||||||
|
import useCurrentUser from 'shared/hooks/currentUser';
|
||||||
import { Form, IssueTypeIcon, Icon, Avatar, IssuePriorityIcon } from 'shared/components';
|
import { Form, IssueTypeIcon, Icon, Avatar, IssuePriorityIcon } from 'shared/components';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -25,12 +26,15 @@ import {
|
|||||||
const propTypes = {
|
const propTypes = {
|
||||||
project: PropTypes.object.isRequired,
|
project: PropTypes.object.isRequired,
|
||||||
fetchProject: PropTypes.func.isRequired,
|
fetchProject: PropTypes.func.isRequired,
|
||||||
|
onCreate: PropTypes.func.isRequired,
|
||||||
modalClose: PropTypes.func.isRequired,
|
modalClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectIssueCreateForm = ({ project, fetchProject, modalClose }) => {
|
const ProjectIssueCreateForm = ({ project, fetchProject, onCreate, modalClose }) => {
|
||||||
const [{ isCreating }, createIssue] = useApi.post('/issues');
|
const [{ isCreating }, createIssue] = useApi.post('/issues');
|
||||||
|
|
||||||
|
const { currentUserId } = useCurrentUser();
|
||||||
|
|
||||||
const typeOptions = Object.values(IssueType).map(type => ({
|
const typeOptions = Object.values(IssueType).map(type => ({
|
||||||
value: type,
|
value: type,
|
||||||
label: IssueTypeCopy[type],
|
label: IssueTypeCopy[type],
|
||||||
@@ -74,14 +78,15 @@ const ProjectIssueCreateForm = ({ project, fetchProject, modalClose }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
|
enableReinitialize
|
||||||
initialValues={{
|
initialValues={{
|
||||||
status: IssueStatus.BACKLOG,
|
status: IssueStatus.BACKLOG,
|
||||||
type: IssueType.TASK,
|
type: IssueType.TASK,
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
reporterId: null,
|
reporterId: currentUserId,
|
||||||
userIds: [],
|
userIds: [],
|
||||||
priority: null,
|
priority: IssuePriority.MEDIUM,
|
||||||
}}
|
}}
|
||||||
validations={{
|
validations={{
|
||||||
type: Form.is.required(),
|
type: Form.is.required(),
|
||||||
@@ -97,13 +102,10 @@ const ProjectIssueCreateForm = ({ project, fetchProject, modalClose }) => {
|
|||||||
users: values.userIds.map(id => ({ id })),
|
users: values.userIds.map(id => ({ id })),
|
||||||
});
|
});
|
||||||
await fetchProject();
|
await fetchProject();
|
||||||
modalClose();
|
toast.success('Issue has been successfully created.');
|
||||||
|
onCreate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.data.fields) {
|
Form.handleAPIError(error, form);
|
||||||
form.setErrors(error.data.fields);
|
|
||||||
} else {
|
|
||||||
toast.error(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -153,10 +155,10 @@ const ProjectIssueCreateForm = ({ project, fetchProject, modalClose }) => {
|
|||||||
renderValue={renderPriority}
|
renderValue={renderPriority}
|
||||||
/>
|
/>
|
||||||
<Actions>
|
<Actions>
|
||||||
<ActionButton type="submit" variant="primary" working={isCreating}>
|
<ActionButton type="submit" variant="primary" isWorking={isCreating}>
|
||||||
Create Issue
|
Create Issue
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton variant="empty" onClick={modalClose}>
|
<ActionButton type="button" variant="empty" onClick={modalClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</Actions>
|
</Actions>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import api from 'shared/utils/api';
|
import api from 'shared/utils/api';
|
||||||
import useApi from 'shared/hooks/api';
|
import useCurrentUser from 'shared/hooks/currentUser';
|
||||||
import toast from 'shared/utils/toast';
|
import toast from 'shared/utils/toast';
|
||||||
|
|
||||||
import BodyForm from '../BodyForm';
|
import BodyForm from '../BodyForm';
|
||||||
@@ -19,8 +19,7 @@ const ProjectBoardIssueDetailsCommentsCreate = ({ issueId, fetchIssue }) => {
|
|||||||
const [isCreating, setCreating] = useState(false);
|
const [isCreating, setCreating] = useState(false);
|
||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
|
|
||||||
const [{ data: currentUserData }] = useApi.get('/currentUser');
|
const { currentUser } = useCurrentUser();
|
||||||
const currentUser = currentUserData && currentUserData.currentUser;
|
|
||||||
|
|
||||||
const handleCommentCreate = async () => {
|
const handleCommentCreate = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { sortByNewest } from 'shared/utils/javascript';
|
||||||
|
|
||||||
import Create from './Create';
|
import Create from './Create';
|
||||||
import Comment from './Comment';
|
import Comment from './Comment';
|
||||||
import { Comments, Title } from './Styles';
|
import { Comments, Title } from './Styles';
|
||||||
@@ -15,14 +17,12 @@ const ProjectBoardIssueDetailsComments = ({ issue, fetchIssue }) => (
|
|||||||
<Title>Comments</Title>
|
<Title>Comments</Title>
|
||||||
<Create issueId={issue.id} fetchIssue={fetchIssue} />
|
<Create issueId={issue.id} fetchIssue={fetchIssue} />
|
||||||
|
|
||||||
{sortByNewest(issue.comments).map(comment => (
|
{sortByNewest(issue.comments, 'createdAt').map(comment => (
|
||||||
<Comment key={comment.id} comment={comment} fetchIssue={fetchIssue} />
|
<Comment key={comment.id} comment={comment} fetchIssue={fetchIssue} />
|
||||||
))}
|
))}
|
||||||
</Comments>
|
</Comments>
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortByNewest = items => items.sort((a, b) => -a.createdAt.localeCompare(b.createdAt));
|
|
||||||
|
|
||||||
ProjectBoardIssueDetailsComments.propTypes = propTypes;
|
ProjectBoardIssueDetailsComments.propTypes = propTypes;
|
||||||
|
|
||||||
export default ProjectBoardIssueDetailsComments;
|
export default ProjectBoardIssueDetailsComments;
|
||||||
|
|||||||
@@ -12,26 +12,30 @@ const propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
|
const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
|
||||||
const [value, setValue] = useState(issue.description);
|
const [description, setDescription] = useState(issue.description);
|
||||||
const [isEditing, setEditing] = useState(false);
|
const [isEditing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const isDescriptionEmpty = getTextContentsFromHtmlString(description).trim().length === 0;
|
||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
updateIssue({ description: value });
|
updateIssue({ description });
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDescriptionEmpty = getTextContentsFromHtmlString(issue.description).trim().length === 0;
|
|
||||||
|
|
||||||
const renderPresentingMode = () =>
|
const renderPresentingMode = () =>
|
||||||
isDescriptionEmpty ? (
|
isDescriptionEmpty ? (
|
||||||
<EmptyLabel onClick={() => setEditing(true)}>Add a description...</EmptyLabel>
|
<EmptyLabel onClick={() => setEditing(true)}>Add a description...</EmptyLabel>
|
||||||
) : (
|
) : (
|
||||||
<TextEditedContent content={issue.description} onClick={() => setEditing(true)} />
|
<TextEditedContent content={description} onClick={() => setEditing(true)} />
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderEditingMode = () => (
|
const renderEditingMode = () => (
|
||||||
<>
|
<>
|
||||||
<TextEditor placeholder="Describe the issue" defaultValue={value} onChange={setValue} />
|
<TextEditor
|
||||||
|
placeholder="Describe the issue"
|
||||||
|
defaultValue={description}
|
||||||
|
onChange={setDescription}
|
||||||
|
/>
|
||||||
<Actions>
|
<Actions>
|
||||||
<Button variant="primary" onClick={handleUpdate}>
|
<Button variant="primary" onClick={handleUpdate}>
|
||||||
Save
|
Save
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const ProjectBoardIssueDetailsType = ({ issue, updateIssue }) => (
|
|||||||
onChange={type => updateIssue({ type })}
|
onChange={type => updateIssue({ type })}
|
||||||
renderValue={({ value: type }) => (
|
renderValue={({ value: type }) => (
|
||||||
<TypeButton variant="empty" icon={<IssueTypeIcon type={type} />}>
|
<TypeButton variant="empty" icon={<IssueTypeIcon type={type} />}>
|
||||||
{`${type}-${issue.id}`}
|
{`${IssueTypeCopy[type]}-${issue.id}`}
|
||||||
</TypeButton>
|
</TypeButton>
|
||||||
)}
|
)}
|
||||||
renderOption={({ value: type }) => (
|
renderOption={({ value: type }) => (
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import api from 'shared/utils/api';
|
import api from 'shared/utils/api';
|
||||||
import useApi from 'shared/hooks/api';
|
import useApi from 'shared/hooks/api';
|
||||||
import { PageError, CopyLinkButton, Button } from 'shared/components';
|
import { PageError, CopyLinkButton, Button, AboutTooltip } from 'shared/components';
|
||||||
|
|
||||||
import Loader from './Loader';
|
import Loader from './Loader';
|
||||||
import Type from './Type';
|
import Type from './Type';
|
||||||
import Feedback from './Feedback';
|
|
||||||
import Delete from './Delete';
|
import Delete from './Delete';
|
||||||
import Title from './Title';
|
import Title from './Title';
|
||||||
import Description from './Description';
|
import Description from './Description';
|
||||||
@@ -23,7 +22,7 @@ const propTypes = {
|
|||||||
issueId: PropTypes.string.isRequired,
|
issueId: PropTypes.string.isRequired,
|
||||||
projectUsers: PropTypes.array.isRequired,
|
projectUsers: PropTypes.array.isRequired,
|
||||||
fetchProject: PropTypes.func.isRequired,
|
fetchProject: PropTypes.func.isRequired,
|
||||||
updateLocalIssuesArray: PropTypes.func.isRequired,
|
updateLocalProjectIssues: PropTypes.func.isRequired,
|
||||||
modalClose: PropTypes.func.isRequired,
|
modalClose: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ const ProjectBoardIssueDetails = ({
|
|||||||
issueId,
|
issueId,
|
||||||
projectUsers,
|
projectUsers,
|
||||||
fetchProject,
|
fetchProject,
|
||||||
updateLocalIssuesArray,
|
updateLocalProjectIssues,
|
||||||
modalClose,
|
modalClose,
|
||||||
}) => {
|
}) => {
|
||||||
const [{ data, error, setLocalData }, fetchIssue] = useApi.get(`/issues/${issueId}`);
|
const [{ data, error, setLocalData }, fetchIssue] = useApi.get(`/issues/${issueId}`);
|
||||||
@@ -41,17 +40,16 @@ const ProjectBoardIssueDetails = ({
|
|||||||
|
|
||||||
const { issue } = data;
|
const { issue } = data;
|
||||||
|
|
||||||
const updateLocalIssue = fields =>
|
const updateLocalIssueDetails = fields =>
|
||||||
setLocalData(currentData => ({ issue: { ...currentData.issue, ...fields } }));
|
setLocalData(currentData => ({ issue: { ...currentData.issue, ...fields } }));
|
||||||
|
|
||||||
const updateIssue = updatedFields => {
|
const updateIssue = updatedFields => {
|
||||||
api.optimisticUpdate({
|
api.optimisticUpdate(`/issues/${issueId}`, {
|
||||||
url: `/issues/${issueId}`,
|
|
||||||
updatedFields,
|
updatedFields,
|
||||||
currentFields: issue,
|
currentFields: issue,
|
||||||
setLocalData: fields => {
|
setLocalData: fields => {
|
||||||
updateLocalIssue(fields);
|
updateLocalIssueDetails(fields);
|
||||||
updateLocalIssuesArray(issue.id, fields);
|
updateLocalProjectIssues(issue.id, fields);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -61,7 +59,13 @@ const ProjectBoardIssueDetails = ({
|
|||||||
<TopActions>
|
<TopActions>
|
||||||
<Type issue={issue} updateIssue={updateIssue} />
|
<Type issue={issue} updateIssue={updateIssue} />
|
||||||
<TopActionsRight>
|
<TopActionsRight>
|
||||||
<Feedback />
|
<AboutTooltip
|
||||||
|
renderLink={linkProps => (
|
||||||
|
<Button icon="feedback" variant="empty" {...linkProps}>
|
||||||
|
Give feedback
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<CopyLinkButton variant="empty" />
|
<CopyLinkButton variant="empty" />
|
||||||
<Delete issue={issue} fetchProject={fetchProject} modalClose={modalClose} />
|
<Delete issue={issue} fetchProject={fetchProject} modalClose={modalClose} />
|
||||||
<Button icon="close" iconSize={24} variant="empty" onClick={modalClose} />
|
<Button icon="close" iconSize={24} variant="empty" onClick={modalClose} />
|
||||||
|
|||||||
77
client/src/Project/IssueSearch/NoResultsSvg.jsx
Normal file
77
client/src/Project/IssueSearch/NoResultsSvg.jsx
Normal file
File diff suppressed because one or more lines are too long
96
client/src/Project/IssueSearch/Styles.js
Normal file
96
client/src/Project/IssueSearch/Styles.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import { color, font, mixin } from 'shared/utils/styles';
|
||||||
|
import { InputDebounced, Spinner, Icon } from 'shared/components';
|
||||||
|
|
||||||
|
export const IssueSearch = styled.div`
|
||||||
|
padding: 25px 35px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SearchInputCont = styled.div`
|
||||||
|
position: relative;
|
||||||
|
padding-right: 30px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SearchInputDebounced = styled(InputDebounced)`
|
||||||
|
height: 40px;
|
||||||
|
input {
|
||||||
|
padding: 0 0 0 32px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid ${color.primary};
|
||||||
|
background: #fff;
|
||||||
|
${font.size(21)}
|
||||||
|
&:focus,
|
||||||
|
&:hover {
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid ${color.primary};
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SearchIcon = styled(Icon)`
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 0;
|
||||||
|
color: ${color.textMedium};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SearchSpinner = styled(Spinner)`
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 30px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Issue = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
${mixin.clickable}
|
||||||
|
&:hover {
|
||||||
|
background: ${color.backgroundLight};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const IssueData = styled.div`
|
||||||
|
padding-left: 15px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const IssueTitle = styled.div`
|
||||||
|
color: ${color.textDark};
|
||||||
|
${font.size(15)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const IssueTypeId = styled.div`
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: ${color.textMedium};
|
||||||
|
${font.size(12.5)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SectionTitle = styled.div`
|
||||||
|
padding-bottom: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: ${color.textMedium};
|
||||||
|
${font.bold}
|
||||||
|
${font.size(11.5)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const NoResults = styled.div`
|
||||||
|
padding-top: 50px;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const NoResultsTitle = styled.div`
|
||||||
|
padding-top: 30px;
|
||||||
|
${font.medium}
|
||||||
|
${font.size(20)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const NoResultsTip = styled.div`
|
||||||
|
padding-top: 10px;
|
||||||
|
${font.size(15)}
|
||||||
|
`;
|
||||||
101
client/src/Project/IssueSearch/index.jsx
Normal file
101
client/src/Project/IssueSearch/index.jsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { get } from 'lodash';
|
||||||
|
|
||||||
|
import useApi from 'shared/hooks/api';
|
||||||
|
import { sortByNewest } from 'shared/utils/javascript';
|
||||||
|
import { IssueTypeIcon } from 'shared/components';
|
||||||
|
|
||||||
|
import NoResultsSVG from './NoResultsSvg';
|
||||||
|
import {
|
||||||
|
IssueSearch,
|
||||||
|
SearchInputCont,
|
||||||
|
SearchInputDebounced,
|
||||||
|
SearchIcon,
|
||||||
|
SearchSpinner,
|
||||||
|
Issue,
|
||||||
|
IssueData,
|
||||||
|
IssueTitle,
|
||||||
|
IssueTypeId,
|
||||||
|
SectionTitle,
|
||||||
|
NoResults,
|
||||||
|
NoResultsTitle,
|
||||||
|
NoResultsTip,
|
||||||
|
} from './Styles';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
project: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectIssueSearch = ({ project }) => {
|
||||||
|
const [isSearchTermEmpty, setIsSearchTermEmpty] = useState(true);
|
||||||
|
|
||||||
|
const [{ data, isLoading }, fetchIssues] = useApi.get('/issues', {}, { lazy: true });
|
||||||
|
|
||||||
|
const matchingIssues = get(data, 'issues', []);
|
||||||
|
|
||||||
|
const recentIssues = sortByNewest(project.issues, 'createdAt').slice(0, 10);
|
||||||
|
|
||||||
|
const handleSearchChange = value => {
|
||||||
|
const searchTerm = value.trim();
|
||||||
|
|
||||||
|
setIsSearchTermEmpty(!searchTerm);
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
fetchIssues({ searchTerm });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderIssue = issue => (
|
||||||
|
<Link key={issue.id} to={`/project/board/issues/${issue.id}`}>
|
||||||
|
<Issue>
|
||||||
|
<IssueTypeIcon type={issue.type} size={25} />
|
||||||
|
<IssueData>
|
||||||
|
<IssueTitle>{issue.title}</IssueTitle>
|
||||||
|
<IssueTypeId>{`${issue.type}-${issue.id}`}</IssueTypeId>
|
||||||
|
</IssueData>
|
||||||
|
</Issue>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IssueSearch>
|
||||||
|
<SearchInputCont>
|
||||||
|
<SearchInputDebounced
|
||||||
|
autoFocus
|
||||||
|
placeholder="Search issues by summary, description..."
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
<SearchIcon type="search" size={22} />
|
||||||
|
{isLoading && <SearchSpinner />}
|
||||||
|
</SearchInputCont>
|
||||||
|
|
||||||
|
{isSearchTermEmpty && recentIssues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionTitle>Recent Issues</SectionTitle>
|
||||||
|
{recentIssues.map(renderIssue)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearchTermEmpty && matchingIssues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionTitle>Matching Issues</SectionTitle>
|
||||||
|
{matchingIssues.map(renderIssue)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearchTermEmpty && !isLoading && matchingIssues.length === 0 && (
|
||||||
|
<NoResults>
|
||||||
|
<NoResultsSVG />
|
||||||
|
<NoResultsTitle>We couldn't find anything matching your search</NoResultsTitle>
|
||||||
|
<NoResultsTip>Try again with a different term.</NoResultsTip>
|
||||||
|
</NoResults>
|
||||||
|
)}
|
||||||
|
</IssueSearch>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProjectIssueSearch.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default ProjectIssueSearch;
|
||||||
@@ -1,35 +1,43 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useRouteMatch } from 'react-router-dom';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { Icon } from 'shared/components';
|
import { Icon, AboutTooltip } from 'shared/components';
|
||||||
|
|
||||||
import { NavLeft, LogoLink, StyledLogo, Bottom, Item, ItemText } from './Styles';
|
import { NavLeft, LogoLink, StyledLogo, Bottom, Item, ItemText } from './Styles';
|
||||||
|
|
||||||
const ProjectNavbarLeft = () => {
|
const propTypes = {
|
||||||
const match = useRouteMatch();
|
issueSearchModalOpen: PropTypes.func.isRequired,
|
||||||
return (
|
issueCreateModalOpen: PropTypes.func.isRequired,
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ProjectNavbarLeft = ({ issueSearchModalOpen, issueCreateModalOpen }) => (
|
||||||
|
<NavLeft>
|
||||||
|
<LogoLink to="/">
|
||||||
|
<StyledLogo color="#fff" />
|
||||||
|
</LogoLink>
|
||||||
|
<Item onClick={issueSearchModalOpen}>
|
||||||
|
<Icon type="search" size={22} top={1} left={3} />
|
||||||
|
<ItemText>Search issues</ItemText>
|
||||||
|
</Item>
|
||||||
|
<Item onClick={issueCreateModalOpen}>
|
||||||
|
<Icon type="plus" size={27} />
|
||||||
|
<ItemText>Create Issue</ItemText>
|
||||||
|
</Item>
|
||||||
|
<Bottom>
|
||||||
|
<AboutTooltip
|
||||||
|
placement="right"
|
||||||
|
offset={{ top: -218 }}
|
||||||
|
renderLink={linkProps => (
|
||||||
|
<Item {...linkProps}>
|
||||||
|
<Icon type="help" size={25} />
|
||||||
|
<ItemText>About</ItemText>
|
||||||
|
</Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Bottom>
|
||||||
|
</NavLeft>
|
||||||
|
);
|
||||||
|
|
||||||
|
ProjectNavbarLeft.propTypes = propTypes;
|
||||||
|
|
||||||
export default ProjectNavbarLeft;
|
export default ProjectNavbarLeft;
|
||||||
|
|||||||
24
client/src/Project/ProjectSettings/Styles.js
Normal file
24
client/src/Project/ProjectSettings/Styles.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import { font } from 'shared/utils/styles';
|
||||||
|
import { Button, Form } from 'shared/components';
|
||||||
|
|
||||||
|
export const FormCont = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FormElement = styled(Form.Element)`
|
||||||
|
max-width: 640px;
|
||||||
|
padding: 20px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FormHeading = styled.div`
|
||||||
|
padding-bottom: 15px;
|
||||||
|
${font.size(24)}
|
||||||
|
${font.medium}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ActionButton = styled(Button)`
|
||||||
|
margin-top: 30px;
|
||||||
|
`;
|
||||||
69
client/src/Project/ProjectSettings/index.jsx
Normal file
69
client/src/Project/ProjectSettings/index.jsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { ProjectCategory, ProjectCategoryCopy } from 'shared/constants/projects';
|
||||||
|
import toast from 'shared/utils/toast';
|
||||||
|
import useApi from 'shared/hooks/api';
|
||||||
|
import { Form } from 'shared/components';
|
||||||
|
|
||||||
|
import { FormCont, FormHeading, FormElement, ActionButton } from './Styles';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
project: PropTypes.object.isRequired,
|
||||||
|
fetchProject: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectSettings = ({ project, fetchProject }) => {
|
||||||
|
const [{ isUpdating }, updateProject] = useApi.put('/project');
|
||||||
|
|
||||||
|
const categoryOptions = Object.values(ProjectCategory).map(category => ({
|
||||||
|
value: category,
|
||||||
|
label: ProjectCategoryCopy[category],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
initialValues={Form.initialValues(project, get => ({
|
||||||
|
name: get('name'),
|
||||||
|
url: get('url'),
|
||||||
|
category: get('category'),
|
||||||
|
description: get('description'),
|
||||||
|
}))}
|
||||||
|
validations={{
|
||||||
|
name: [Form.is.required(), Form.is.maxLength(100)],
|
||||||
|
url: Form.is.url(),
|
||||||
|
category: Form.is.required(),
|
||||||
|
}}
|
||||||
|
onSubmit={async (values, form) => {
|
||||||
|
try {
|
||||||
|
await updateProject(values);
|
||||||
|
await fetchProject();
|
||||||
|
toast.success('Changes have been successfully saved.');
|
||||||
|
} catch (error) {
|
||||||
|
Form.handleAPIError(error, form);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormCont>
|
||||||
|
<FormElement>
|
||||||
|
<FormHeading>Project Details</FormHeading>
|
||||||
|
<Form.Field.Input name="name" label="Name" />
|
||||||
|
<Form.Field.Input name="url" label="URL" />
|
||||||
|
<Form.Field.TextEditor
|
||||||
|
name="description"
|
||||||
|
label="Description"
|
||||||
|
tip="Describe the project in as much detail as you'd like."
|
||||||
|
/>
|
||||||
|
<Form.Field.Select name="category" label="Project Category" options={categoryOptions} />
|
||||||
|
<ActionButton type="submit" variant="primary" isWorking={isUpdating}>
|
||||||
|
Save changes
|
||||||
|
</ActionButton>
|
||||||
|
</FormElement>
|
||||||
|
</FormCont>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProjectSettings.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default ProjectSettings;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { NavLink } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { color, sizes, font, mixin, zIndexValues } from 'shared/utils/styles';
|
import { color, sizes, font, mixin, zIndexValues } from 'shared/utils/styles';
|
||||||
|
|
||||||
@@ -41,21 +40,17 @@ export const Divider = styled.div`
|
|||||||
border-top: 1px solid ${color.borderLight};
|
border-top: 1px solid ${color.borderLight};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const LinkItem = styled(NavLink)`
|
export const LinkItem = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
color: ${color.textDark};
|
|
||||||
${mixin.clickable}
|
${mixin.clickable}
|
||||||
${props =>
|
${props =>
|
||||||
!props.implemented
|
!props.to ? `cursor: not-allowed;` : `&:hover { background: ${color.backgroundLight}; }`}
|
||||||
? `cursor: not-allowed;`
|
|
||||||
: `&:hover { background: ${color.backgroundLight}; }`}
|
|
||||||
i {
|
i {
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: ${color.textDarkest};
|
|
||||||
}
|
}
|
||||||
&.active {
|
&.active {
|
||||||
color: ${color.primary};
|
color: ${color.primary};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useRouteMatch } from 'react-router-dom';
|
import { NavLink, useRouteMatch } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ProjectCategoryCopy } from 'shared/constants/projects';
|
||||||
import { Icon, ProjectAvatar } from 'shared/components';
|
import { Icon, ProjectAvatar } from 'shared/components';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,38 +18,44 @@ import {
|
|||||||
} from './Styles';
|
} from './Styles';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
projectName: PropTypes.string.isRequired,
|
project: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectSidebar = ({ projectName }) => {
|
const ProjectSidebar = ({ project }) => {
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
|
|
||||||
const renderLinkItem = (text, iconType, path = '') => (
|
const renderLinkItem = (text, iconType, path) => {
|
||||||
<LinkItem exact to={`${match.path}${path}`} implemented={path}>
|
const linkItemProps = path
|
||||||
<Icon type={iconType} />
|
? { as: NavLink, exact: true, to: `${match.path}${path}` }
|
||||||
<LinkText>{text}</LinkText>
|
: { as: 'div' };
|
||||||
{!path && <NotImplemented>Not implemented</NotImplemented>}
|
|
||||||
</LinkItem>
|
return (
|
||||||
);
|
<LinkItem {...linkItemProps}>
|
||||||
|
<Icon type={iconType} />
|
||||||
|
<LinkText>{text}</LinkText>
|
||||||
|
{!path && <NotImplemented>Not implemented</NotImplemented>}
|
||||||
|
</LinkItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<ProjectInfo>
|
<ProjectInfo>
|
||||||
<ProjectAvatar />
|
<ProjectAvatar />
|
||||||
<ProjectTexts>
|
<ProjectTexts>
|
||||||
<ProjectName>{projectName}</ProjectName>
|
<ProjectName>{project.name}</ProjectName>
|
||||||
<ProjectCategory>Software project</ProjectCategory>
|
<ProjectCategory>{ProjectCategoryCopy[project.category]} project</ProjectCategory>
|
||||||
</ProjectTexts>
|
</ProjectTexts>
|
||||||
</ProjectInfo>
|
</ProjectInfo>
|
||||||
|
|
||||||
{renderLinkItem('Kanban Board', 'board', '/board')}
|
{renderLinkItem('Kanban Board', 'board', '/board')}
|
||||||
{renderLinkItem('Reports', 'reports')}
|
{renderLinkItem('Project settings', 'settings', '/settings')}
|
||||||
<Divider />
|
<Divider />
|
||||||
{renderLinkItem('Releases', 'shipping')}
|
{renderLinkItem('Releases', 'shipping')}
|
||||||
{renderLinkItem('Issues and filters', 'issues')}
|
{renderLinkItem('Issues and filters', 'issues')}
|
||||||
{renderLinkItem('Pages', 'page')}
|
{renderLinkItem('Pages', 'page')}
|
||||||
|
{renderLinkItem('Reports', 'reports')}
|
||||||
{renderLinkItem('Components', 'component')}
|
{renderLinkItem('Components', 'component')}
|
||||||
{renderLinkItem('Project settings', 'settings')}
|
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,40 +3,48 @@ import { Route, Redirect, useRouteMatch, useHistory } from 'react-router-dom';
|
|||||||
|
|
||||||
import useApi from 'shared/hooks/api';
|
import useApi from 'shared/hooks/api';
|
||||||
import { updateArrayItemById } from 'shared/utils/javascript';
|
import { updateArrayItemById } from 'shared/utils/javascript';
|
||||||
|
import { createQueryParamModalHelpers } from 'shared/utils/queryParamModal';
|
||||||
import { PageLoader, PageError, Modal } from 'shared/components';
|
import { PageLoader, PageError, Modal } from 'shared/components';
|
||||||
|
|
||||||
import NavbarLeft from './NavbarLeft';
|
import NavbarLeft from './NavbarLeft';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
import Board from './Board';
|
import Board from './Board';
|
||||||
import IssueDetails from './IssueDetails';
|
import IssueSearch from './IssueSearch';
|
||||||
import IssueCreateForm from './IssueCreateForm';
|
import IssueCreateForm from './IssueCreateForm';
|
||||||
|
import IssueDetails from './IssueDetails';
|
||||||
|
import ProjectSettings from './ProjectSettings';
|
||||||
import { ProjectPage } from './Styles';
|
import { ProjectPage } from './Styles';
|
||||||
|
|
||||||
const Project = () => {
|
const Project = () => {
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project');
|
const issueSearchModalHelpers = createQueryParamModalHelpers('issue-search');
|
||||||
|
const issueCreateModalHelpers = createQueryParamModalHelpers('issue-create');
|
||||||
|
|
||||||
const updateLocalIssuesArray = (issueId, updatedFields) => {
|
const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project');
|
||||||
setLocalData(currentData => ({
|
|
||||||
project: {
|
|
||||||
...currentData.project,
|
|
||||||
issues: updateArrayItemById(data.project.issues, issueId, updatedFields),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data) return <PageLoader />;
|
if (!data) return <PageLoader />;
|
||||||
if (error) return <PageError />;
|
if (error) return <PageError />;
|
||||||
|
|
||||||
const { project } = data;
|
const { project } = data;
|
||||||
|
|
||||||
const renderBoard = () => (
|
const updateLocalProjectIssues = (issueId, updatedFields) => {
|
||||||
<Board
|
setLocalData(currentData => ({
|
||||||
project={project}
|
project: {
|
||||||
fetchProject={fetchProject}
|
...currentData.project,
|
||||||
updateLocalIssuesArray={updateLocalIssuesArray}
|
issues: updateArrayItemById(currentData.project.issues, issueId, updatedFields),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderIssueSearchModal = () => (
|
||||||
|
<Modal
|
||||||
|
isOpen
|
||||||
|
variant="aside"
|
||||||
|
width={600}
|
||||||
|
onClose={issueSearchModalHelpers.close}
|
||||||
|
renderContent={() => <IssueSearch project={project} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -44,13 +52,27 @@ const Project = () => {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen
|
isOpen
|
||||||
width={800}
|
width={800}
|
||||||
onClose={() => history.push(`${match.url}/board`)}
|
withCloseIcon={false}
|
||||||
|
onClose={issueCreateModalHelpers.close}
|
||||||
renderContent={modal => (
|
renderContent={modal => (
|
||||||
<IssueCreateForm project={project} fetchProject={fetchProject} modalClose={modal.close} />
|
<IssueCreateForm
|
||||||
|
project={project}
|
||||||
|
fetchProject={fetchProject}
|
||||||
|
onCreate={() => history.push(`${match.url}/board`)}
|
||||||
|
modalClose={modal.close}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderBoard = () => (
|
||||||
|
<Board
|
||||||
|
project={project}
|
||||||
|
fetchProject={fetchProject}
|
||||||
|
updateLocalProjectIssues={updateLocalProjectIssues}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const renderIssueDetailsModal = routeProps => (
|
const renderIssueDetailsModal = routeProps => (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen
|
isOpen
|
||||||
@@ -62,20 +84,31 @@ const Project = () => {
|
|||||||
issueId={routeProps.match.params.issueId}
|
issueId={routeProps.match.params.issueId}
|
||||||
projectUsers={project.users}
|
projectUsers={project.users}
|
||||||
fetchProject={fetchProject}
|
fetchProject={fetchProject}
|
||||||
updateLocalIssuesArray={updateLocalIssuesArray}
|
updateLocalProjectIssues={updateLocalProjectIssues}
|
||||||
modalClose={modal.close}
|
modalClose={modal.close}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderProjectSettings = () => (
|
||||||
|
<ProjectSettings project={project} fetchProject={fetchProject} />
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProjectPage>
|
<ProjectPage>
|
||||||
<NavbarLeft />
|
<NavbarLeft
|
||||||
<Sidebar projectName={project.name} />
|
issueSearchModalOpen={issueSearchModalHelpers.open}
|
||||||
|
issueCreateModalOpen={issueCreateModalHelpers.open}
|
||||||
|
/>
|
||||||
|
<Sidebar project={project} />
|
||||||
|
|
||||||
|
{issueSearchModalHelpers.isOpen() && renderIssueSearchModal()}
|
||||||
|
{issueCreateModalHelpers.isOpen() && renderIssueCreateModal()}
|
||||||
|
|
||||||
<Route path={`${match.path}/board`} render={renderBoard} />
|
<Route path={`${match.path}/board`} render={renderBoard} />
|
||||||
<Route path={`${match.path}/board/create-issue`} render={renderIssueCreateModal} />
|
<Route path={`${match.path}/board/issues/:issueId`} render={renderIssueDetailsModal} />
|
||||||
<Route path={`${match.path}/board/issue/:issueId`} render={renderIssueDetailsModal} />
|
<Route path={`${match.path}/settings`} render={renderProjectSettings} />
|
||||||
{match.isExact && <Redirect to={`${match.url}/board`} />}
|
{match.isExact && <Redirect to={`${match.url}/board`} />}
|
||||||
</ProjectPage>
|
</ProjectPage>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import ReactDOM from 'react-dom';
|
|||||||
|
|
||||||
import App from 'App';
|
import App from 'App';
|
||||||
|
|
||||||
|
// APP IS NOT RESPONSIVE - REDUCE BROWSER HEIGHT, ISSUES DONT SCROLL
|
||||||
|
|
||||||
// TODO: UPDATE FORMIK TO FIX SETFIELDVALUE TO EMPTY ARRAY ISSUE https://github.com/jaredpalmer/formik/pull/2144
|
// TODO: UPDATE FORMIK TO FIX SETFIELDVALUE TO EMPTY ARRAY ISSUE https://github.com/jaredpalmer/formik/pull/2144
|
||||||
|
|
||||||
|
// REFACTOR HTML TO USE SEMANTIC ELEMENTS
|
||||||
|
|
||||||
ReactDOM.render(<App />, document.getElementById('root'));
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,19 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Button, Tooltip } from 'shared/components';
|
import Button from 'shared/components/Button';
|
||||||
|
import Tooltip from 'shared/components/Tooltip';
|
||||||
|
|
||||||
import feedbackImage from './assets/feedback.png';
|
import feedbackImage from './assets/feedback.png';
|
||||||
import { FeedbackDropdown, FeedbackImageCont, FeedbackImage, FeedbackParagraph } from './Styles';
|
import { FeedbackDropdown, FeedbackImageCont, FeedbackImage, FeedbackParagraph } from './Styles';
|
||||||
|
|
||||||
const ProjectBoardIssueDetailsFeedback = () => (
|
const AboutTooltip = tooltipProps => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
width={300}
|
width={300}
|
||||||
offset={{ top: -15 }}
|
{...tooltipProps}
|
||||||
renderLink={linkProps => (
|
|
||||||
<Button icon="feedback" variant="empty" {...linkProps}>
|
|
||||||
Give feedback
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
renderContent={() => (
|
renderContent={() => (
|
||||||
<FeedbackDropdown>
|
<FeedbackDropdown>
|
||||||
<FeedbackImageCont>
|
<FeedbackImageCont>
|
||||||
@@ -46,4 +42,4 @@ const ProjectBoardIssueDetailsFeedback = () => (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default ProjectBoardIssueDetailsFeedback;
|
export default AboutTooltip;
|
||||||
@@ -47,6 +47,7 @@ const ConfirmModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledConfirmModal
|
<StyledConfirmModal
|
||||||
|
withCloseIcon={false}
|
||||||
className={className}
|
className={className}
|
||||||
renderLink={renderLink}
|
renderLink={renderLink}
|
||||||
renderContent={modal => (
|
renderContent={modal => (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
|||||||
import { Formik, Form as FormikForm, Field as FormikField } from 'formik';
|
import { Formik, Form as FormikForm, Field as FormikField } from 'formik';
|
||||||
import { get, mapValues } from 'lodash';
|
import { get, mapValues } from 'lodash';
|
||||||
|
|
||||||
|
import toast from 'shared/utils/toast';
|
||||||
import { is, generateErrors } from 'shared/utils/validation';
|
import { is, generateErrors } from 'shared/utils/validation';
|
||||||
|
|
||||||
import Field from './Field';
|
import Field from './Field';
|
||||||
@@ -50,6 +51,20 @@ Form.Field = mapValues(Field, FieldComponent => ({ name, validate, ...props }) =
|
|||||||
</FormikField>
|
</FormikField>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
Form.initialValues = (data, getFieldValues) =>
|
||||||
|
getFieldValues((key, defaultValue = '') => {
|
||||||
|
const value = get(data, key);
|
||||||
|
return value === undefined || value === null ? defaultValue : value;
|
||||||
|
});
|
||||||
|
|
||||||
|
Form.handleAPIError = (error, form) => {
|
||||||
|
if (error.data.fields) {
|
||||||
|
form.setErrors(error.data.fields);
|
||||||
|
} else {
|
||||||
|
toast.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Form.is = is;
|
Form.is = is;
|
||||||
|
|
||||||
Form.propTypes = propTypes;
|
Form.propTypes = propTypes;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const InputElement = styled.input`
|
|||||||
padding: 0 7px;
|
padding: 0 7px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid ${color.borderLightest};
|
border: 1px solid ${color.borderLightest};
|
||||||
|
color: ${color.textDarkest};
|
||||||
background: ${color.backgroundLightest};
|
background: ${color.backgroundLightest};
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
${font.regular}
|
${font.regular}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import { debounce } from 'lodash';
|
|||||||
import { Input } from 'shared/components';
|
import { Input } from 'shared/components';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
};
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
value: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const InputDebounced = ({ onChange, value: propsValue, ...inputProps }) => {
|
const InputDebounced = ({ onChange, value: propsValue, ...inputProps }) => {
|
||||||
const [value, setValue] = useState(propsValue);
|
const [value, setValue] = useState(propsValue);
|
||||||
|
const isControlled = propsValue !== undefined;
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
debounce(newValue => onChange(newValue), 500),
|
debounce(newValue => onChange(newValue), 500),
|
||||||
@@ -29,7 +34,7 @@ const InputDebounced = ({ onChange, value: propsValue, ...inputProps }) => {
|
|||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
value={value}
|
value={isControlled ? value : undefined}
|
||||||
onChange={newValue => {
|
onChange={newValue => {
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
handleChange(newValue);
|
handleChange(newValue);
|
||||||
@@ -39,5 +44,6 @@ const InputDebounced = ({ onChange, value: propsValue, ...inputProps }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
InputDebounced.propTypes = propTypes;
|
InputDebounced.propTypes = propTypes;
|
||||||
|
InputDebounced.defaultProps = defaultProps;
|
||||||
|
|
||||||
export default InputDebounced;
|
export default InputDebounced;
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ import { issuePriorityColors } from 'shared/utils/styles';
|
|||||||
import { Icon } from 'shared/components';
|
import { Icon } from 'shared/components';
|
||||||
|
|
||||||
export const PriorityIcon = styled(Icon)`
|
export const PriorityIcon = styled(Icon)`
|
||||||
font-size: 18px;
|
|
||||||
color: ${props => issuePriorityColors[props.color]};
|
color: ${props => issuePriorityColors[props.color]};
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const IssuePriorityIcon = ({ priority, ...otherProps }) => {
|
|||||||
? 'arrow-down'
|
? 'arrow-down'
|
||||||
: 'arrow-up';
|
: 'arrow-up';
|
||||||
|
|
||||||
return <PriorityIcon type={iconType} color={priority} {...otherProps} />;
|
return <PriorityIcon type={iconType} color={priority} size={18} {...otherProps} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
IssuePriorityIcon.propTypes = propTypes;
|
IssuePriorityIcon.propTypes = propTypes;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import { Icon } from 'shared/components';
|
|
||||||
import { issueTypeColors } from 'shared/utils/styles';
|
import { issueTypeColors } from 'shared/utils/styles';
|
||||||
|
import { Icon } from 'shared/components';
|
||||||
|
|
||||||
export const TypeIcon = styled(Icon)`
|
export const TypeIcon = styled(Icon)`
|
||||||
font-size: 18px;
|
|
||||||
color: ${props => issueTypeColors[props.color]};
|
color: ${props => issueTypeColors[props.color]};
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const IssueTypeIcon = ({ type, ...otherProps }) => (
|
const IssueTypeIcon = ({ type, ...otherProps }) => (
|
||||||
<TypeIcon type={type} color={type} {...otherProps} />
|
<TypeIcon type={type} color={type} size={18} {...otherProps} />
|
||||||
);
|
);
|
||||||
|
|
||||||
IssueTypeIcon.propTypes = propTypes;
|
IssueTypeIcon.propTypes = propTypes;
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ const clickOverlayStyles = {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 50px;
|
padding: 50px;
|
||||||
`,
|
`,
|
||||||
aside: css`
|
aside: '',
|
||||||
text-align: right;
|
|
||||||
`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StyledModal = styled.div`
|
export const StyledModal = styled.div`
|
||||||
@@ -43,14 +41,12 @@ const modalStyles = {
|
|||||||
center: css`
|
center: css`
|
||||||
max-width: ${props => props.width}px;
|
max-width: ${props => props.width}px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
text-align: left;
|
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
${mixin.boxShadowMedium}
|
${mixin.boxShadowMedium}
|
||||||
`,
|
`,
|
||||||
aside: css`
|
aside: css`
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
max-width: ${props => props.width}px;
|
max-width: ${props => props.width}px;
|
||||||
text-align: left;
|
|
||||||
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.15);
|
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.15);
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
@@ -59,25 +55,34 @@ export const CloseIcon = styled(Icon)`
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 25px;
|
font-size: 25px;
|
||||||
color: ${color.textMedium};
|
color: ${color.textMedium};
|
||||||
|
transition: all 0.1s;
|
||||||
${mixin.clickable}
|
${mixin.clickable}
|
||||||
${props => closeIconStyles[props.variant]}
|
${props => closeIconStyles[props.variant]}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const closeIconStyles = {
|
const closeIconStyles = {
|
||||||
center: css`
|
center: css`
|
||||||
top: 8px;
|
top: 10px;
|
||||||
right: 10px;
|
right: 12px;
|
||||||
padding: 7px 7px 0;
|
padding: 3px 5px 0px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
&:hover {
|
||||||
|
background: ${color.backgroundLight};
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
aside: css`
|
aside: css`
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: -50px;
|
right: -30px;
|
||||||
width: 40px;
|
width: 50px;
|
||||||
height: 40px;
|
height: 50px;
|
||||||
padding-top: 8px;
|
padding-top: 10px;
|
||||||
border-radius: 40px;
|
border-radius: 3px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
opacity: 0.5;
|
border: 1px solid ${color.borderLightest};
|
||||||
|
${mixin.boxShadowMedium};
|
||||||
|
&:hover {
|
||||||
|
color: ${color.primary};
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const EditorCont = styled.div`
|
|||||||
border-radius: 0 0 4px 4px;
|
border-radius: 0 0 4px 4px;
|
||||||
border: 1px solid ${color.borderLightest};
|
border: 1px solid ${color.borderLightest};
|
||||||
border-top: none;
|
border-top: none;
|
||||||
|
color: ${color.textDarkest};
|
||||||
${font.size(15)}
|
${font.size(15)}
|
||||||
${font.regular}
|
${font.regular}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const TextEditor = ({
|
|||||||
|
|
||||||
const insertInitialValue = () => {
|
const insertInitialValue = () => {
|
||||||
quill.clipboard.dangerouslyPasteHTML(0, initialValueRef.current);
|
quill.clipboard.dangerouslyPasteHTML(0, initialValueRef.current);
|
||||||
|
quill.blur();
|
||||||
};
|
};
|
||||||
const handleContentsChange = () => {
|
const handleContentsChange = () => {
|
||||||
onChange(getHTMLValue());
|
onChange(getHTMLValue());
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const StyledTextarea = styled.div`
|
|||||||
padding: 8px 12px 9px;
|
padding: 8px 12px 9px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid ${color.borderLightest};
|
border: 1px solid ${color.borderLightest};
|
||||||
|
color: ${color.textDarkest};
|
||||||
background: ${color.backgroundLightest};
|
background: ${color.backgroundLightest};
|
||||||
${font.regular}
|
${font.regular}
|
||||||
${font.size(15)}
|
${font.size(15)}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const Tooltip = ({ className, placement, offset, width, renderLink, renderConten
|
|||||||
};
|
};
|
||||||
|
|
||||||
const calcPosition = (offset, placement, $tooltipRef, $linkRef) => {
|
const calcPosition = (offset, placement, $tooltipRef, $linkRef) => {
|
||||||
const margin = 20;
|
const margin = 10;
|
||||||
const finalOffset = { ...defaultProps.offset, ...offset };
|
const finalOffset = { ...defaultProps.offset, ...offset };
|
||||||
|
|
||||||
const tooltipRect = $tooltipRef.current.getBoundingClientRect();
|
const tooltipRect = $tooltipRef.current.getBoundingClientRect();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { default as AboutTooltip } from './AboutTooltip';
|
||||||
export { default as Avatar } from './Avatar';
|
export { default as Avatar } from './Avatar';
|
||||||
export { default as Button } from './Button';
|
export { default as Button } from './Button';
|
||||||
export { default as ConfirmModal } from './ConfirmModal';
|
export { default as ConfirmModal } from './ConfirmModal';
|
||||||
|
|||||||
11
client/src/shared/constants/projects.js
Normal file
11
client/src/shared/constants/projects.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export const ProjectCategory = {
|
||||||
|
SOFTWARE: 'software',
|
||||||
|
MARKETING: 'marketing',
|
||||||
|
BUSINESS: 'business',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectCategoryCopy = {
|
||||||
|
[ProjectCategory.SOFTWARE]: 'Software',
|
||||||
|
[ProjectCategory.MARKETING]: 'Marketing',
|
||||||
|
[ProjectCategory.BUSINESS]: 'Business',
|
||||||
|
};
|
||||||
@@ -5,11 +5,7 @@ import api from 'shared/utils/api';
|
|||||||
import useMergeState from 'shared/hooks/mergeState';
|
import useMergeState from 'shared/hooks/mergeState';
|
||||||
import useDeepCompareMemoize from 'shared/hooks/deepCompareMemoize';
|
import useDeepCompareMemoize from 'shared/hooks/deepCompareMemoize';
|
||||||
|
|
||||||
const useQuery = (
|
const useQuery = (url, propsVariables = {}, { lazy = false, cachePolicy = 'cache-first' } = {}) => {
|
||||||
url,
|
|
||||||
propsVariables = {},
|
|
||||||
{ lazy = false, cachePolicy = CachePolicy.CACHE_FIRST } = {},
|
|
||||||
) => {
|
|
||||||
const [state, mergeState] = useMergeState({
|
const [state, mergeState] = useMergeState({
|
||||||
data: null,
|
data: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -25,17 +21,19 @@ const useQuery = (
|
|||||||
stateRef.current = state;
|
stateRef.current = state;
|
||||||
|
|
||||||
const makeRequest = useCallback(
|
const makeRequest = useCallback(
|
||||||
(newVariables = {}) => {
|
(newVariables = {}, isAutoCalled) => {
|
||||||
const variables = { ...stateRef.current.variables, ...newVariables };
|
const variables = { ...stateRef.current.variables, ...newVariables };
|
||||||
const apiVariables = { ...propsVariablesMemoized, ...variables };
|
const apiVariables = { ...propsVariablesMemoized, ...variables };
|
||||||
|
|
||||||
const isCacheAvailable = cache[url] && isEqual(cache[url].apiVariables, apiVariables);
|
const isCacheAvailable = cache[url] && isEqual(cache[url].apiVariables, apiVariables);
|
||||||
const isCacheAvailableAndPermitted = isCacheAvailable && cachePolicy !== CachePolicy.NO_CACHE;
|
|
||||||
|
const isCacheAvailableAndPermitted =
|
||||||
|
isCacheAvailable && isAutoCalled && cachePolicy !== 'no-cache';
|
||||||
|
|
||||||
if (isCacheAvailableAndPermitted) {
|
if (isCacheAvailableAndPermitted) {
|
||||||
mergeState({ data: cache[url].data, error: null, isLoading: false, variables });
|
mergeState({ data: cache[url].data, error: null, isLoading: false, variables });
|
||||||
|
|
||||||
if (cachePolicy === CachePolicy.CACHE_ONLY) {
|
if (cachePolicy === 'cache-only') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +59,7 @@ const useQuery = (
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lazy || wasCalledRef.current) {
|
if (!lazy || wasCalledRef.current) {
|
||||||
makeRequest();
|
makeRequest({}, true);
|
||||||
}
|
}
|
||||||
}, [lazy, makeRequest]);
|
}, [lazy, makeRequest]);
|
||||||
|
|
||||||
@@ -83,10 +81,4 @@ const useQuery = (
|
|||||||
|
|
||||||
const cache = {};
|
const cache = {};
|
||||||
|
|
||||||
const CachePolicy = {
|
|
||||||
CACHE_ONLY: 'cache-only',
|
|
||||||
CACHE_FIRST: 'cache-first',
|
|
||||||
NO_CACHE: 'no-cache',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useQuery;
|
export default useQuery;
|
||||||
|
|||||||
14
client/src/shared/hooks/currentUser.js
Normal file
14
client/src/shared/hooks/currentUser.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { get } from 'lodash';
|
||||||
|
|
||||||
|
import useApi from 'shared/hooks/api';
|
||||||
|
|
||||||
|
const useCurrentUser = ({ cachePolicy = 'cache-only' } = {}) => {
|
||||||
|
const [{ data }] = useApi.get('/currentUser', {}, { cachePolicy });
|
||||||
|
|
||||||
|
const currentUser = get(data, 'currentUser');
|
||||||
|
const currentUserId = get(data, 'currentUser.id');
|
||||||
|
|
||||||
|
return { currentUser, currentUserId };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCurrentUser;
|
||||||
@@ -47,7 +47,7 @@ const api = (method, url, variables) =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const optimisticUpdate = async ({ url, updatedFields, currentFields, setLocalData }) => {
|
const optimisticUpdate = async (url, { updatedFields, currentFields, setLocalData }) => {
|
||||||
try {
|
try {
|
||||||
setLocalData(updatedFields);
|
setLocalData(updatedFields);
|
||||||
await api('put', url, updatedFields);
|
await api('put', url, updatedFields);
|
||||||
|
|||||||
@@ -20,3 +20,6 @@ export const updateArrayItemById = (arr, itemId, fields) => {
|
|||||||
}
|
}
|
||||||
return arrClone;
|
return arrClone;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sortByNewest = (items, sortField) =>
|
||||||
|
items.sort((a, b) => -a[sortField].localeCompare(b[sortField]));
|
||||||
|
|||||||
22
client/src/shared/utils/queryParamModal.js
Normal file
22
client/src/shared/utils/queryParamModal.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import history from 'browserHistory';
|
||||||
|
import { queryStringToObject, addToQueryString, omitFromQueryString } from 'shared/utils/url';
|
||||||
|
|
||||||
|
const open = param =>
|
||||||
|
history.push({
|
||||||
|
pathname: history.location.pathname,
|
||||||
|
search: addToQueryString(history.location.search, { [`modal-${param}`]: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = param =>
|
||||||
|
history.push({
|
||||||
|
pathname: history.location.pathname,
|
||||||
|
search: omitFromQueryString(history.location.search, [`modal-${param}`]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOpen = param => !!queryStringToObject(history.location.search)[`modal-${param}`];
|
||||||
|
|
||||||
|
export const createQueryParamModalHelpers = param => ({
|
||||||
|
open: () => open(param),
|
||||||
|
close: () => close(param),
|
||||||
|
isOpen: () => isOpen(param),
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
|
import { omit } from 'lodash';
|
||||||
|
|
||||||
export const queryStringToObject = (str, options = {}) =>
|
export const queryStringToObject = (str, options = {}) =>
|
||||||
queryString.parse(str, {
|
queryString.parse(str, {
|
||||||
@@ -11,3 +12,12 @@ export const objectToQueryString = (obj, options = {}) =>
|
|||||||
arrayFormat: 'bracket',
|
arrayFormat: 'bracket',
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const omitFromQueryString = (str, keys) =>
|
||||||
|
objectToQueryString(omit(queryStringToObject(str), keys));
|
||||||
|
|
||||||
|
export const addToQueryString = (str, fields) =>
|
||||||
|
objectToQueryString({
|
||||||
|
...queryStringToObject(str),
|
||||||
|
...fields,
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user