From 7ceb18ee844050d9b705e771af82323d89b01f40 Mon Sep 17 00:00:00 2001 From: ireic Date: Fri, 27 Dec 2019 15:25:23 +0100 Subject: [PATCH] Implemented project settings page, search issues modal, general refactoring --- api/package-lock.json | 5 + api/package.json | 1 + api/src/controllers/issues.ts | 29 ++++- api/src/controllers/projects.ts | 4 +- api/src/database/connection.ts | 1 + api/src/database/seeds/guestUser.ts | 3 +- api/src/entities/Issue.ts | 14 +++ api/src/entities/Project.ts | 1 - api/src/errors/errorHandler.ts | 4 +- api/src/middleware/authentication.ts | 12 +-- api/src/utils/validation.ts | 1 + client/src/App/BaseStyles.js | 2 +- client/src/App/Toast/Styles.js | 2 +- client/src/Project/Board/Filters/index.jsx | 8 +- .../src/Project/Board/Lists/Issue/index.jsx | 2 +- client/src/Project/Board/Lists/index.jsx | 22 ++-- client/src/Project/Board/index.jsx | 13 ++- client/src/Project/IssueCreateForm/Styles.js | 4 +- client/src/Project/IssueCreateForm/index.jsx | 24 +++-- .../IssueDetails/Comments/Create/index.jsx | 5 +- .../Project/IssueDetails/Comments/index.jsx | 6 +- .../IssueDetails/Description/index.jsx | 16 +-- .../src/Project/IssueDetails/Type/index.jsx | 2 +- client/src/Project/IssueDetails/index.jsx | 24 +++-- .../src/Project/IssueSearch/NoResultsSvg.jsx | 77 +++++++++++++ client/src/Project/IssueSearch/Styles.js | 96 +++++++++++++++++ client/src/Project/IssueSearch/index.jsx | 101 ++++++++++++++++++ client/src/Project/NavbarLeft/index.jsx | 62 ++++++----- client/src/Project/ProjectSettings/Styles.js | 24 +++++ client/src/Project/ProjectSettings/index.jsx | 69 ++++++++++++ client/src/Project/Sidebar/Styles.js | 9 +- client/src/Project/Sidebar/index.jsx | 35 +++--- client/src/Project/index.jsx | 77 +++++++++---- client/src/index.jsx | 4 + .../components/AboutTooltip}/Styles.js | 0 .../AboutTooltip}/assets/feedback.png | Bin .../components/AboutTooltip}/index.jsx | 14 +-- .../shared/components/ConfirmModal/index.jsx | 1 + client/src/shared/components/Form/index.jsx | 15 +++ client/src/shared/components/Input/Styles.js | 1 + .../src/shared/components/InputDebounced.jsx | 10 +- .../components/IssuePriorityIcon/Styles.js | 1 - .../components/IssuePriorityIcon/index.jsx | 2 +- .../shared/components/IssueTypeIcon/Styles.js | 3 +- .../shared/components/IssueTypeIcon/index.jsx | 2 +- client/src/shared/components/Modal/Styles.js | 33 +++--- .../shared/components/TextEditor/Styles.js | 1 + .../shared/components/TextEditor/index.jsx | 1 + .../src/shared/components/Textarea/Styles.js | 1 + .../src/shared/components/Tooltip/index.jsx | 2 +- client/src/shared/components/index.js | 1 + client/src/shared/constants/projects.js | 11 ++ client/src/shared/hooks/api/query.js | 22 ++-- client/src/shared/hooks/currentUser.js | 14 +++ client/src/shared/utils/api.js | 2 +- client/src/shared/utils/javascript.js | 3 + client/src/shared/utils/queryParamModal.js | 22 ++++ client/src/shared/utils/url.js | 10 ++ 58 files changed, 738 insertions(+), 193 deletions(-) create mode 100644 client/src/Project/IssueSearch/NoResultsSvg.jsx create mode 100644 client/src/Project/IssueSearch/Styles.js create mode 100644 client/src/Project/IssueSearch/index.jsx create mode 100644 client/src/Project/ProjectSettings/Styles.js create mode 100644 client/src/Project/ProjectSettings/index.jsx rename client/src/{Project/IssueDetails/Feedback => shared/components/AboutTooltip}/Styles.js (100%) rename client/src/{Project/IssueDetails/Feedback => shared/components/AboutTooltip}/assets/feedback.png (100%) rename client/src/{Project/IssueDetails/Feedback => shared/components/AboutTooltip}/index.jsx (79%) create mode 100644 client/src/shared/constants/projects.js create mode 100644 client/src/shared/hooks/currentUser.js create mode 100644 client/src/shared/utils/queryParamModal.js diff --git a/api/package-lock.json b/api/package-lock.json index 293925f..0a9260d 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -3870,6 +3870,11 @@ "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", "dev": true }, + "striptags": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/striptags/-/striptags-3.1.1.tgz", + "integrity": "sha1-yMPn/db7S7OjKjt1LltePjgJPr0=" + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", diff --git a/api/package.json b/api/package.json index ae1e7a0..ae171f8 100644 --- a/api/package.json +++ b/api/package.json @@ -19,6 +19,7 @@ "module-alias": "^2.2.2", "pg": "^7.14.0", "reflect-metadata": "^0.1.13", + "striptags": "^3.1.1", "typeorm": "^0.2.20" }, "devDependencies": { diff --git a/api/src/controllers/issues.ts b/api/src/controllers/issues.ts index da274e6..b83edff 100644 --- a/api/src/controllers/issues.ts +++ b/api/src/controllers/issues.ts @@ -6,6 +6,27 @@ import { updateEntity, deleteEntity, createEntity, findEntityOrThrow } from 'uti 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( '/issues/:issueId', catchErrors(async (req, res) => { @@ -41,11 +62,11 @@ router.delete( }), ); -const calculateListPosition = async (newIssue: Issue): Promise => { - const issues = await Issue.find({ - where: { projectId: newIssue.projectId, status: newIssue.status }, - }); +const calculateListPosition = async ({ projectId, status }: Issue): Promise => { + const issues = await Issue.find({ projectId, status }); + const listPositions = issues.map(({ listPosition }) => listPosition); + if (listPositions.length > 0) { return Math.min(...listPositions) - 1; } diff --git a/api/src/controllers/projects.ts b/api/src/controllers/projects.ts index 8ed67de..82a40bd 100644 --- a/api/src/controllers/projects.ts +++ b/api/src/controllers/projects.ts @@ -23,9 +23,9 @@ router.get( ); router.put( - '/projects/:projectId', + '/project', 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 }); }), ); diff --git a/api/src/database/connection.ts b/api/src/database/connection.ts index d0b87f9..1558101 100644 --- a/api/src/database/connection.ts +++ b/api/src/database/connection.ts @@ -11,6 +11,7 @@ const createDatabaseConnection = (): Promise => password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, entities: Object.values(entities), + synchronize: true, }); export default createDatabaseConnection; diff --git a/api/src/database/seeds/guestUser.ts b/api/src/database/seeds/guestUser.ts index 8dcc52c..33bc599 100644 --- a/api/src/database/seeds/guestUser.ts +++ b/api/src/database/seeds/guestUser.ts @@ -74,8 +74,7 @@ const seedIssues = (project: Project): Promise => { status: IssueStatus.BACKLOG, priority: IssuePriority.HIGH, listPosition: 4, - description: - "#### 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", + description: '#### Colons can be used to align columns.', estimate: 4, reporterId: getRandomUser().id, project, diff --git a/api/src/entities/Issue.ts b/api/src/entities/Issue.ts index f02d88b..ed324fa 100644 --- a/api/src/entities/Issue.ts +++ b/api/src/entities/Issue.ts @@ -1,3 +1,4 @@ +import striptags from 'striptags'; import { BaseEntity, Entity, @@ -10,6 +11,8 @@ import { ManyToMany, JoinTable, RelationId, + BeforeUpdate, + BeforeInsert, } from 'typeorm'; import is from 'utils/validation'; @@ -48,6 +51,9 @@ class Issue extends BaseEntity { @Column('text', { nullable: true }) description: string | null; + @Column('text', { nullable: true }) + descriptionText: string | null; + @Column('integer', { nullable: true }) estimate: number | null; @@ -90,6 +96,14 @@ class Issue extends BaseEntity { @RelationId((issue: Issue) => issue.users) userIds: number[]; + + @BeforeInsert() + @BeforeUpdate() + setDescriptionText = (): void => { + if (this.description) { + this.descriptionText = striptags(this.description); + } + }; } export default Issue; diff --git a/api/src/entities/Project.ts b/api/src/entities/Project.ts index 110e907..cb15e9c 100644 --- a/api/src/entities/Project.ts +++ b/api/src/entities/Project.ts @@ -17,7 +17,6 @@ class Project extends BaseEntity { static validations = { name: [is.required(), is.maxLength(100)], url: is.url(), - description: is.maxLength(10000), category: [is.required(), is.oneOf(Object.values(ProjectCategory))], }; diff --git a/api/src/errors/errorHandler.ts b/api/src/errors/errorHandler.ts index fdab456..3f2d670 100644 --- a/api/src/errors/errorHandler.ts +++ b/api/src/errors/errorHandler.ts @@ -8,7 +8,7 @@ export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => { const isErrorSafeForClient = error instanceof CustomError; - const errorData = isErrorSafeForClient + const clientError = isErrorSafeForClient ? pick(error, ['message', 'code', 'status', 'data']) : { message: 'Something went wrong, please contact our support.', @@ -17,5 +17,5 @@ export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => { data: {}, }; - res.status(errorData.status).send({ error: errorData }); + res.status(clientError.status).send({ error: clientError }); }; diff --git a/api/src/middleware/authentication.ts b/api/src/middleware/authentication.ts index 099c0f8..0661f4a 100644 --- a/api/src/middleware/authentication.ts +++ b/api/src/middleware/authentication.ts @@ -4,12 +4,6 @@ import { verifyToken } from 'utils/authToken'; import { catchErrors, InvalidTokenError } from 'errors'; 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) => { const token = getAuthTokenFromRequest(req); if (!token) { @@ -26,3 +20,9 @@ export const authenticateUser = catchErrors(async (req, _res, next) => { req.currentUser = user; next(); }); + +const getAuthTokenFromRequest = (req: Request): string | null => { + const header = req.get('Authorization') || ''; + const [bearer, token] = header.split(' '); + return bearer === 'Bearer' && token ? token : null; +}; diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index 985c4b5..c3af934 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -48,6 +48,7 @@ export const generateErrors = ( Object.entries(fieldValidators).forEach(([fieldName, validators]) => { [validators].flat().forEach(validator => { const errorMessage = validator(fieldValues[fieldName], fieldValues); + if (errorMessage !== false && !fieldErrors[fieldName]) { fieldErrors[fieldName] = errorMessage; } diff --git a/client/src/App/BaseStyles.js b/client/src/App/BaseStyles.js index ca2969c..64b4d59 100644 --- a/client/src/App/BaseStyles.js +++ b/client/src/App/BaseStyles.js @@ -33,7 +33,7 @@ export default createGlobalStyle` box-sizing: border-box; } - a, a:hover, a:visited, a:active { + a { color: inherit; text-decoration: none; } diff --git a/client/src/App/Toast/Styles.js b/client/src/App/Toast/Styles.js index 554fbc6..070542c 100644 --- a/client/src/App/Toast/Styles.js +++ b/client/src/App/Toast/Styles.js @@ -47,7 +47,7 @@ export const CloseIcon = styled(Icon)` export const Title = styled.div` padding-right: 22px; - ${font.size(16)} + ${font.size(15)} ${font.medium} `; diff --git a/client/src/Project/Board/Filters/index.jsx b/client/src/Project/Board/Filters/index.jsx index 16a7a4d..f537d37 100644 --- a/client/src/Project/Board/Filters/index.jsx +++ b/client/src/Project/Board/Filters/index.jsx @@ -20,16 +20,16 @@ const propTypes = { }; 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 ( mergeFilters({ searchQuery: value })} + value={searchTerm} + onChange={value => mergeFilters({ searchTerm: value })} /> {projectUsers.map(user => ( diff --git a/client/src/Project/Board/Lists/Issue/index.jsx b/client/src/Project/Board/Lists/Issue/index.jsx index 122aad6..d01fb32 100644 --- a/client/src/Project/Board/Lists/Issue/index.jsx +++ b/client/src/Project/Board/Lists/Issue/index.jsx @@ -23,7 +23,7 @@ const ProjectBoardListsIssue = ({ projectUsers, issue, index }) => { {(provided, snapshot) => ( { - const [{ data: currentUserData }] = useApi.get('/currentUser'); - const currentUserId = get(currentUserData, 'currentUser.id'); +const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => { + const { currentUserId } = useCurrentUser(); const handleIssueDrop = async ({ draggableId, destination, source }) => { if (!isPositionChanged(source, destination)) return; const issueId = Number(draggableId); - api.optimisticUpdate({ - url: `/issues/${issueId}`, + api.optimisticUpdate(`/issues/${issueId}`, { updatedFields: { status: destination.droppableId, listPosition: calculateListPosition(project.issues, destination, source, 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 { searchTerm, userIds, myOnly, recent } = filters; let issues = projectIssues; - const { searchQuery, userIds, myOnly, recent } = filters; - if (searchQuery) { - issues = issues.filter(issue => issue.title.toLowerCase().includes(searchQuery.toLowerCase())); + if (searchTerm) { + issues = issues.filter(issue => issue.title.toLowerCase().includes(searchTerm.toLowerCase())); } if (userIds.length > 0) { issues = issues.filter(issue => intersection(issue.userIds, userIds).length > 0); diff --git a/client/src/Project/Board/index.jsx b/client/src/Project/Board/index.jsx index a786813..0ec1fbf 100644 --- a/client/src/Project/Board/index.jsx +++ b/client/src/Project/Board/index.jsx @@ -9,18 +9,19 @@ import Lists from './Lists'; const propTypes = { project: PropTypes.object.isRequired, - updateLocalIssuesArray: PropTypes.func.isRequired, + updateLocalProjectIssues: PropTypes.func.isRequired, }; const defaultFilters = { - searchQuery: '', + searchTerm: '', userIds: [], myOnly: false, recent: false, }; -const ProjectBoard = ({ project, updateLocalIssuesArray }) => { +const ProjectBoard = ({ project, updateLocalProjectIssues }) => { const [filters, mergeFilters] = useMergeState(defaultFilters); + return ( <>
@@ -30,7 +31,11 @@ const ProjectBoard = ({ project, updateLocalIssuesArray }) => { filters={filters} mergeFilters={mergeFilters} /> - + ); }; diff --git a/client/src/Project/IssueCreateForm/Styles.js b/client/src/Project/IssueCreateForm/Styles.js index 6be61d7..455ab76 100644 --- a/client/src/Project/IssueCreateForm/Styles.js +++ b/client/src/Project/IssueCreateForm/Styles.js @@ -4,12 +4,12 @@ import { color, font } from 'shared/utils/styles'; import { Button, Form } from 'shared/components'; export const FormElement = styled(Form.Element)` - padding: 20px 40px; + padding: 25px 40px 35px; `; export const FormHeading = styled.div` padding-bottom: 15px; - ${font.size(20)} + ${font.size(21)} `; export const SelectItem = styled.div` diff --git a/client/src/Project/IssueCreateForm/index.jsx b/client/src/Project/IssueCreateForm/index.jsx index 0bdd79f..2335fa1 100644 --- a/client/src/Project/IssueCreateForm/index.jsx +++ b/client/src/Project/IssueCreateForm/index.jsx @@ -10,6 +10,7 @@ import { } from 'shared/constants/issues'; import toast from 'shared/utils/toast'; import useApi from 'shared/hooks/api'; +import useCurrentUser from 'shared/hooks/currentUser'; import { Form, IssueTypeIcon, Icon, Avatar, IssuePriorityIcon } from 'shared/components'; import { @@ -25,12 +26,15 @@ import { const propTypes = { project: PropTypes.object.isRequired, fetchProject: PropTypes.func.isRequired, + onCreate: 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 { currentUserId } = useCurrentUser(); + const typeOptions = Object.values(IssueType).map(type => ({ value: type, label: IssueTypeCopy[type], @@ -74,14 +78,15 @@ const ProjectIssueCreateForm = ({ project, fetchProject, modalClose }) => { return (
{ users: values.userIds.map(id => ({ id })), }); await fetchProject(); - modalClose(); + toast.success('Issue has been successfully created.'); + onCreate(); } catch (error) { - if (error.data.fields) { - form.setErrors(error.data.fields); - } else { - toast.error(error); - } + Form.handleAPIError(error, form); } }} > @@ -153,10 +155,10 @@ const ProjectIssueCreateForm = ({ project, fetchProject, modalClose }) => { renderValue={renderPriority} /> - + Create Issue - + Cancel diff --git a/client/src/Project/IssueDetails/Comments/Create/index.jsx b/client/src/Project/IssueDetails/Comments/Create/index.jsx index 720ee2a..a443a3c 100644 --- a/client/src/Project/IssueDetails/Comments/Create/index.jsx +++ b/client/src/Project/IssueDetails/Comments/Create/index.jsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; 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 BodyForm from '../BodyForm'; @@ -19,8 +19,7 @@ const ProjectBoardIssueDetailsCommentsCreate = ({ issueId, fetchIssue }) => { const [isCreating, setCreating] = useState(false); const [body, setBody] = useState(''); - const [{ data: currentUserData }] = useApi.get('/currentUser'); - const currentUser = currentUserData && currentUserData.currentUser; + const { currentUser } = useCurrentUser(); const handleCommentCreate = async () => { try { diff --git a/client/src/Project/IssueDetails/Comments/index.jsx b/client/src/Project/IssueDetails/Comments/index.jsx index 63239c0..9decd8c 100644 --- a/client/src/Project/IssueDetails/Comments/index.jsx +++ b/client/src/Project/IssueDetails/Comments/index.jsx @@ -1,6 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { sortByNewest } from 'shared/utils/javascript'; + import Create from './Create'; import Comment from './Comment'; import { Comments, Title } from './Styles'; @@ -15,14 +17,12 @@ const ProjectBoardIssueDetailsComments = ({ issue, fetchIssue }) => ( Comments - {sortByNewest(issue.comments).map(comment => ( + {sortByNewest(issue.comments, 'createdAt').map(comment => ( ))} ); -const sortByNewest = items => items.sort((a, b) => -a.createdAt.localeCompare(b.createdAt)); - ProjectBoardIssueDetailsComments.propTypes = propTypes; export default ProjectBoardIssueDetailsComments; diff --git a/client/src/Project/IssueDetails/Description/index.jsx b/client/src/Project/IssueDetails/Description/index.jsx index ee90002..cb663a5 100644 --- a/client/src/Project/IssueDetails/Description/index.jsx +++ b/client/src/Project/IssueDetails/Description/index.jsx @@ -12,26 +12,30 @@ const propTypes = { }; const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => { - const [value, setValue] = useState(issue.description); + const [description, setDescription] = useState(issue.description); const [isEditing, setEditing] = useState(false); + const isDescriptionEmpty = getTextContentsFromHtmlString(description).trim().length === 0; + const handleUpdate = () => { setEditing(false); - updateIssue({ description: value }); + updateIssue({ description }); }; - const isDescriptionEmpty = getTextContentsFromHtmlString(issue.description).trim().length === 0; - const renderPresentingMode = () => isDescriptionEmpty ? ( setEditing(true)}>Add a description... ) : ( - setEditing(true)} /> + setEditing(true)} /> ); const renderEditingMode = () => ( <> - + + )} + /> - )} + {...tooltipProps} renderContent={() => ( @@ -46,4 +42,4 @@ const ProjectBoardIssueDetailsFeedback = () => ( /> ); -export default ProjectBoardIssueDetailsFeedback; +export default AboutTooltip; diff --git a/client/src/shared/components/ConfirmModal/index.jsx b/client/src/shared/components/ConfirmModal/index.jsx index 319daa7..de2a43d 100644 --- a/client/src/shared/components/ConfirmModal/index.jsx +++ b/client/src/shared/components/ConfirmModal/index.jsx @@ -47,6 +47,7 @@ const ConfirmModal = ({ return ( ( diff --git a/client/src/shared/components/Form/index.jsx b/client/src/shared/components/Form/index.jsx index 87b0ef9..6d24d6b 100644 --- a/client/src/shared/components/Form/index.jsx +++ b/client/src/shared/components/Form/index.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { Formik, Form as FormikForm, Field as FormikField } from 'formik'; import { get, mapValues } from 'lodash'; +import toast from 'shared/utils/toast'; import { is, generateErrors } from 'shared/utils/validation'; import Field from './Field'; @@ -50,6 +51,20 @@ Form.Field = mapValues(Field, FieldComponent => ({ name, validate, ...props }) = )); +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.propTypes = propTypes; diff --git a/client/src/shared/components/Input/Styles.js b/client/src/shared/components/Input/Styles.js index da0cf38..1f52b26 100644 --- a/client/src/shared/components/Input/Styles.js +++ b/client/src/shared/components/Input/Styles.js @@ -16,6 +16,7 @@ export const InputElement = styled.input` padding: 0 7px; border-radius: 3px; border: 1px solid ${color.borderLightest}; + color: ${color.textDarkest}; background: ${color.backgroundLightest}; transition: background 0.1s; ${font.regular} diff --git a/client/src/shared/components/InputDebounced.jsx b/client/src/shared/components/InputDebounced.jsx index adf50b4..cd8a646 100644 --- a/client/src/shared/components/InputDebounced.jsx +++ b/client/src/shared/components/InputDebounced.jsx @@ -5,12 +5,17 @@ import { debounce } from 'lodash'; import { Input } from 'shared/components'; const propTypes = { + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), onChange: PropTypes.func.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, +}; + +const defaultProps = { + value: undefined, }; const InputDebounced = ({ onChange, value: propsValue, ...inputProps }) => { const [value, setValue] = useState(propsValue); + const isControlled = propsValue !== undefined; const handleChange = useCallback( debounce(newValue => onChange(newValue), 500), @@ -29,7 +34,7 @@ const InputDebounced = ({ onChange, value: propsValue, ...inputProps }) => { return ( { setValue(newValue); handleChange(newValue); @@ -39,5 +44,6 @@ const InputDebounced = ({ onChange, value: propsValue, ...inputProps }) => { }; InputDebounced.propTypes = propTypes; +InputDebounced.defaultProps = defaultProps; export default InputDebounced; diff --git a/client/src/shared/components/IssuePriorityIcon/Styles.js b/client/src/shared/components/IssuePriorityIcon/Styles.js index 64b5e51..4352e48 100644 --- a/client/src/shared/components/IssuePriorityIcon/Styles.js +++ b/client/src/shared/components/IssuePriorityIcon/Styles.js @@ -4,6 +4,5 @@ import { issuePriorityColors } from 'shared/utils/styles'; import { Icon } from 'shared/components'; export const PriorityIcon = styled(Icon)` - font-size: 18px; color: ${props => issuePriorityColors[props.color]}; `; diff --git a/client/src/shared/components/IssuePriorityIcon/index.jsx b/client/src/shared/components/IssuePriorityIcon/index.jsx index 7398eb2..64c8248 100644 --- a/client/src/shared/components/IssuePriorityIcon/index.jsx +++ b/client/src/shared/components/IssuePriorityIcon/index.jsx @@ -14,7 +14,7 @@ const IssuePriorityIcon = ({ priority, ...otherProps }) => { ? 'arrow-down' : 'arrow-up'; - return ; + return ; }; IssuePriorityIcon.propTypes = propTypes; diff --git a/client/src/shared/components/IssueTypeIcon/Styles.js b/client/src/shared/components/IssueTypeIcon/Styles.js index 25115c8..6ec1862 100644 --- a/client/src/shared/components/IssueTypeIcon/Styles.js +++ b/client/src/shared/components/IssueTypeIcon/Styles.js @@ -1,9 +1,8 @@ import styled from 'styled-components'; -import { Icon } from 'shared/components'; import { issueTypeColors } from 'shared/utils/styles'; +import { Icon } from 'shared/components'; export const TypeIcon = styled(Icon)` - font-size: 18px; color: ${props => issueTypeColors[props.color]}; `; diff --git a/client/src/shared/components/IssueTypeIcon/index.jsx b/client/src/shared/components/IssueTypeIcon/index.jsx index 6fb4995..2d1fa15 100644 --- a/client/src/shared/components/IssueTypeIcon/index.jsx +++ b/client/src/shared/components/IssueTypeIcon/index.jsx @@ -8,7 +8,7 @@ const propTypes = { }; const IssueTypeIcon = ({ type, ...otherProps }) => ( - + ); IssueTypeIcon.propTypes = propTypes; diff --git a/client/src/shared/components/Modal/Styles.js b/client/src/shared/components/Modal/Styles.js index 23d131e..ad6d264 100644 --- a/client/src/shared/components/Modal/Styles.js +++ b/client/src/shared/components/Modal/Styles.js @@ -26,9 +26,7 @@ const clickOverlayStyles = { align-items: center; padding: 50px; `, - aside: css` - text-align: right; - `, + aside: '', }; export const StyledModal = styled.div` @@ -43,14 +41,12 @@ const modalStyles = { center: css` max-width: ${props => props.width}px; vertical-align: middle; - text-align: left; border-radius: 3px; ${mixin.boxShadowMedium} `, aside: css` min-height: 100vh; max-width: ${props => props.width}px; - text-align: left; box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.15); `, }; @@ -59,25 +55,34 @@ export const CloseIcon = styled(Icon)` position: absolute; font-size: 25px; color: ${color.textMedium}; + transition: all 0.1s; ${mixin.clickable} ${props => closeIconStyles[props.variant]} `; const closeIconStyles = { center: css` - top: 8px; - right: 10px; - padding: 7px 7px 0; + top: 10px; + right: 12px; + padding: 3px 5px 0px 5px; + border-radius: 4px; + &:hover { + background: ${color.backgroundLight}; + } `, aside: css` top: 10px; - left: -50px; - width: 40px; - height: 40px; - padding-top: 8px; - border-radius: 40px; + right: -30px; + width: 50px; + height: 50px; + padding-top: 10px; + border-radius: 3px; text-align: center; background: #fff; - opacity: 0.5; + border: 1px solid ${color.borderLightest}; + ${mixin.boxShadowMedium}; + &:hover { + color: ${color.primary}; + } `, }; diff --git a/client/src/shared/components/TextEditor/Styles.js b/client/src/shared/components/TextEditor/Styles.js index 1dbaa75..fa5bf61 100644 --- a/client/src/shared/components/TextEditor/Styles.js +++ b/client/src/shared/components/TextEditor/Styles.js @@ -12,6 +12,7 @@ export const EditorCont = styled.div` border-radius: 0 0 4px 4px; border: 1px solid ${color.borderLightest}; border-top: none; + color: ${color.textDarkest}; ${font.size(15)} ${font.regular} } diff --git a/client/src/shared/components/TextEditor/index.jsx b/client/src/shared/components/TextEditor/index.jsx index 82908ad..685a792 100644 --- a/client/src/shared/components/TextEditor/index.jsx +++ b/client/src/shared/components/TextEditor/index.jsx @@ -43,6 +43,7 @@ const TextEditor = ({ const insertInitialValue = () => { quill.clipboard.dangerouslyPasteHTML(0, initialValueRef.current); + quill.blur(); }; const handleContentsChange = () => { onChange(getHTMLValue()); diff --git a/client/src/shared/components/Textarea/Styles.js b/client/src/shared/components/Textarea/Styles.js index 3ed975d..5d38fbc 100644 --- a/client/src/shared/components/Textarea/Styles.js +++ b/client/src/shared/components/Textarea/Styles.js @@ -11,6 +11,7 @@ export const StyledTextarea = styled.div` padding: 8px 12px 9px; border-radius: 3px; border: 1px solid ${color.borderLightest}; + color: ${color.textDarkest}; background: ${color.backgroundLightest}; ${font.regular} ${font.size(15)} diff --git a/client/src/shared/components/Tooltip/index.jsx b/client/src/shared/components/Tooltip/index.jsx index 34a4065..928e694 100644 --- a/client/src/shared/components/Tooltip/index.jsx +++ b/client/src/shared/components/Tooltip/index.jsx @@ -70,7 +70,7 @@ const Tooltip = ({ className, placement, offset, width, renderLink, renderConten }; const calcPosition = (offset, placement, $tooltipRef, $linkRef) => { - const margin = 20; + const margin = 10; const finalOffset = { ...defaultProps.offset, ...offset }; const tooltipRect = $tooltipRef.current.getBoundingClientRect(); diff --git a/client/src/shared/components/index.js b/client/src/shared/components/index.js index d37af6e..da41094 100644 --- a/client/src/shared/components/index.js +++ b/client/src/shared/components/index.js @@ -1,3 +1,4 @@ +export { default as AboutTooltip } from './AboutTooltip'; export { default as Avatar } from './Avatar'; export { default as Button } from './Button'; export { default as ConfirmModal } from './ConfirmModal'; diff --git a/client/src/shared/constants/projects.js b/client/src/shared/constants/projects.js new file mode 100644 index 0000000..05d4a86 --- /dev/null +++ b/client/src/shared/constants/projects.js @@ -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', +}; diff --git a/client/src/shared/hooks/api/query.js b/client/src/shared/hooks/api/query.js index e18339d..af143d7 100644 --- a/client/src/shared/hooks/api/query.js +++ b/client/src/shared/hooks/api/query.js @@ -5,11 +5,7 @@ import api from 'shared/utils/api'; import useMergeState from 'shared/hooks/mergeState'; import useDeepCompareMemoize from 'shared/hooks/deepCompareMemoize'; -const useQuery = ( - url, - propsVariables = {}, - { lazy = false, cachePolicy = CachePolicy.CACHE_FIRST } = {}, -) => { +const useQuery = (url, propsVariables = {}, { lazy = false, cachePolicy = 'cache-first' } = {}) => { const [state, mergeState] = useMergeState({ data: null, error: null, @@ -25,17 +21,19 @@ const useQuery = ( stateRef.current = state; const makeRequest = useCallback( - (newVariables = {}) => { + (newVariables = {}, isAutoCalled) => { const variables = { ...stateRef.current.variables, ...newVariables }; const apiVariables = { ...propsVariablesMemoized, ...variables }; 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) { mergeState({ data: cache[url].data, error: null, isLoading: false, variables }); - if (cachePolicy === CachePolicy.CACHE_ONLY) { + if (cachePolicy === 'cache-only') { return; } } @@ -61,7 +59,7 @@ const useQuery = ( useEffect(() => { if (!lazy || wasCalledRef.current) { - makeRequest(); + makeRequest({}, true); } }, [lazy, makeRequest]); @@ -83,10 +81,4 @@ const useQuery = ( const cache = {}; -const CachePolicy = { - CACHE_ONLY: 'cache-only', - CACHE_FIRST: 'cache-first', - NO_CACHE: 'no-cache', -}; - export default useQuery; diff --git a/client/src/shared/hooks/currentUser.js b/client/src/shared/hooks/currentUser.js new file mode 100644 index 0000000..fb5d86c --- /dev/null +++ b/client/src/shared/hooks/currentUser.js @@ -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; diff --git a/client/src/shared/utils/api.js b/client/src/shared/utils/api.js index d18bb72..551cf70 100644 --- a/client/src/shared/utils/api.js +++ b/client/src/shared/utils/api.js @@ -47,7 +47,7 @@ const api = (method, url, variables) => ); }); -const optimisticUpdate = async ({ url, updatedFields, currentFields, setLocalData }) => { +const optimisticUpdate = async (url, { updatedFields, currentFields, setLocalData }) => { try { setLocalData(updatedFields); await api('put', url, updatedFields); diff --git a/client/src/shared/utils/javascript.js b/client/src/shared/utils/javascript.js index 05b33d9..42610c3 100644 --- a/client/src/shared/utils/javascript.js +++ b/client/src/shared/utils/javascript.js @@ -20,3 +20,6 @@ export const updateArrayItemById = (arr, itemId, fields) => { } return arrClone; }; + +export const sortByNewest = (items, sortField) => + items.sort((a, b) => -a[sortField].localeCompare(b[sortField])); diff --git a/client/src/shared/utils/queryParamModal.js b/client/src/shared/utils/queryParamModal.js new file mode 100644 index 0000000..fd38e2d --- /dev/null +++ b/client/src/shared/utils/queryParamModal.js @@ -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), +}); diff --git a/client/src/shared/utils/url.js b/client/src/shared/utils/url.js index 93c6125..f94723c 100644 --- a/client/src/shared/utils/url.js +++ b/client/src/shared/utils/url.js @@ -1,4 +1,5 @@ import queryString from 'query-string'; +import { omit } from 'lodash'; export const queryStringToObject = (str, options = {}) => queryString.parse(str, { @@ -11,3 +12,12 @@ export const objectToQueryString = (obj, options = {}) => arrayFormat: 'bracket', ...options, }); + +export const omitFromQueryString = (str, keys) => + objectToQueryString(omit(queryStringToObject(str), keys)); + +export const addToQueryString = (str, fields) => + objectToQueryString({ + ...queryStringToObject(str), + ...fields, + });