Implemented project settings page, search issues modal, general refactoring
This commit is contained in:
@@ -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 (
|
||||
<Filters>
|
||||
<SearchInput
|
||||
icon="search"
|
||||
value={searchQuery}
|
||||
onChange={value => mergeFilters({ searchQuery: value })}
|
||||
value={searchTerm}
|
||||
onChange={value => mergeFilters({ searchTerm: value })}
|
||||
/>
|
||||
<Avatars>
|
||||
{projectUsers.map(user => (
|
||||
|
||||
@@ -23,7 +23,7 @@ const ProjectBoardListsIssue = ({ projectUsers, issue, index }) => {
|
||||
<Draggable draggableId={issue.id.toString()} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<IssueLink
|
||||
to={`${match.url}/issue/${issue.id}`}
|
||||
to={`${match.url}/issues/${issue.id}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
|
||||
@@ -2,10 +2,10 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
||||
import { get, intersection } from 'lodash';
|
||||
import { intersection } from 'lodash';
|
||||
|
||||
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 { IssueStatus, IssueStatusCopy } from 'shared/constants/issues';
|
||||
|
||||
@@ -15,26 +15,24 @@ import { Lists, List, Title, IssuesCount, Issues } from './Styles';
|
||||
const propTypes = {
|
||||
project: PropTypes.object.isRequired,
|
||||
filters: PropTypes.object.isRequired,
|
||||
updateLocalIssuesArray: PropTypes.func.isRequired,
|
||||
updateLocalProjectIssues: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectBoardLists = ({ project, filters, updateLocalIssuesArray }) => {
|
||||
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);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Header projectName={project.name} />
|
||||
@@ -30,7 +31,11 @@ const ProjectBoard = ({ project, updateLocalIssuesArray }) => {
|
||||
filters={filters}
|
||||
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';
|
||||
|
||||
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`
|
||||
|
||||
@@ -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 (
|
||||
<Form
|
||||
enableReinitialize
|
||||
initialValues={{
|
||||
status: IssueStatus.BACKLOG,
|
||||
type: IssueType.TASK,
|
||||
title: '',
|
||||
description: '',
|
||||
reporterId: null,
|
||||
reporterId: currentUserId,
|
||||
userIds: [],
|
||||
priority: null,
|
||||
priority: IssuePriority.MEDIUM,
|
||||
}}
|
||||
validations={{
|
||||
type: Form.is.required(),
|
||||
@@ -97,13 +102,10 @@ const ProjectIssueCreateForm = ({ project, fetchProject, modalClose }) => {
|
||||
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}
|
||||
/>
|
||||
<Actions>
|
||||
<ActionButton type="submit" variant="primary" working={isCreating}>
|
||||
<ActionButton type="submit" variant="primary" isWorking={isCreating}>
|
||||
Create Issue
|
||||
</ActionButton>
|
||||
<ActionButton variant="empty" onClick={modalClose}>
|
||||
<ActionButton type="button" variant="empty" onClick={modalClose}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }) => (
|
||||
<Title>Comments</Title>
|
||||
<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} />
|
||||
))}
|
||||
</Comments>
|
||||
);
|
||||
|
||||
const sortByNewest = items => items.sort((a, b) => -a.createdAt.localeCompare(b.createdAt));
|
||||
|
||||
ProjectBoardIssueDetailsComments.propTypes = propTypes;
|
||||
|
||||
export default ProjectBoardIssueDetailsComments;
|
||||
|
||||
@@ -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 ? (
|
||||
<EmptyLabel onClick={() => setEditing(true)}>Add a description...</EmptyLabel>
|
||||
) : (
|
||||
<TextEditedContent content={issue.description} onClick={() => setEditing(true)} />
|
||||
<TextEditedContent content={description} onClick={() => setEditing(true)} />
|
||||
);
|
||||
|
||||
const renderEditingMode = () => (
|
||||
<>
|
||||
<TextEditor placeholder="Describe the issue" defaultValue={value} onChange={setValue} />
|
||||
<TextEditor
|
||||
placeholder="Describe the issue"
|
||||
defaultValue={description}
|
||||
onChange={setDescription}
|
||||
/>
|
||||
<Actions>
|
||||
<Button variant="primary" onClick={handleUpdate}>
|
||||
Save
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { font } from 'shared/utils/styles';
|
||||
|
||||
export const FeedbackDropdown = styled.div`
|
||||
padding: 16px 24px 24px;
|
||||
`;
|
||||
|
||||
export const FeedbackImageCont = styled.div`
|
||||
padding: 24px 56px 20px;
|
||||
`;
|
||||
|
||||
export const FeedbackImage = styled.img`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const FeedbackParagraph = styled.p`
|
||||
margin-bottom: 12px;
|
||||
${font.size(15)}
|
||||
&:last-of-type {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
`;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,49 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button, Tooltip } from 'shared/components';
|
||||
|
||||
import feedbackImage from './assets/feedback.png';
|
||||
import { FeedbackDropdown, FeedbackImageCont, FeedbackImage, FeedbackParagraph } from './Styles';
|
||||
|
||||
const ProjectBoardIssueDetailsFeedback = () => (
|
||||
<Tooltip
|
||||
width={300}
|
||||
offset={{ top: -15 }}
|
||||
renderLink={linkProps => (
|
||||
<Button icon="feedback" variant="empty" {...linkProps}>
|
||||
Give feedback
|
||||
</Button>
|
||||
)}
|
||||
renderContent={() => (
|
||||
<FeedbackDropdown>
|
||||
<FeedbackImageCont>
|
||||
<FeedbackImage src={feedbackImage} alt="Give feedback" />
|
||||
</FeedbackImageCont>
|
||||
|
||||
<FeedbackParagraph>
|
||||
This simplified Jira clone is built with React on the front-end and Node/TypeScript on the
|
||||
back-end.
|
||||
</FeedbackParagraph>
|
||||
|
||||
<FeedbackParagraph>
|
||||
{'Read more on our website or reach out via '}
|
||||
<a href="mailto:ivor@codetree.co">
|
||||
<strong>ivor@codetree.co</strong>
|
||||
</a>
|
||||
</FeedbackParagraph>
|
||||
|
||||
<a href="https://codetree.co/" target="_blank" rel="noreferrer noopener">
|
||||
<Button variant="primary">Visit Website</Button>
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/oldboyxx/jira_clone" target="_blank" rel="noreferrer noopener">
|
||||
<Button style={{ marginLeft: 10 }} icon="github">
|
||||
Github Repo
|
||||
</Button>
|
||||
</a>
|
||||
</FeedbackDropdown>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export default ProjectBoardIssueDetailsFeedback;
|
||||
@@ -23,7 +23,7 @@ const ProjectBoardIssueDetailsType = ({ issue, updateIssue }) => (
|
||||
onChange={type => updateIssue({ type })}
|
||||
renderValue={({ value: type }) => (
|
||||
<TypeButton variant="empty" icon={<IssueTypeIcon type={type} />}>
|
||||
{`${type}-${issue.id}`}
|
||||
{`${IssueTypeCopy[type]}-${issue.id}`}
|
||||
</TypeButton>
|
||||
)}
|
||||
renderOption={({ value: type }) => (
|
||||
|
||||
@@ -3,11 +3,10 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import api from 'shared/utils/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 Type from './Type';
|
||||
import Feedback from './Feedback';
|
||||
import Delete from './Delete';
|
||||
import Title from './Title';
|
||||
import Description from './Description';
|
||||
@@ -23,7 +22,7 @@ const propTypes = {
|
||||
issueId: PropTypes.string.isRequired,
|
||||
projectUsers: PropTypes.array.isRequired,
|
||||
fetchProject: PropTypes.func.isRequired,
|
||||
updateLocalIssuesArray: PropTypes.func.isRequired,
|
||||
updateLocalProjectIssues: PropTypes.func.isRequired,
|
||||
modalClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -31,7 +30,7 @@ const ProjectBoardIssueDetails = ({
|
||||
issueId,
|
||||
projectUsers,
|
||||
fetchProject,
|
||||
updateLocalIssuesArray,
|
||||
updateLocalProjectIssues,
|
||||
modalClose,
|
||||
}) => {
|
||||
const [{ data, error, setLocalData }, fetchIssue] = useApi.get(`/issues/${issueId}`);
|
||||
@@ -41,17 +40,16 @@ const ProjectBoardIssueDetails = ({
|
||||
|
||||
const { issue } = data;
|
||||
|
||||
const updateLocalIssue = fields =>
|
||||
const updateLocalIssueDetails = fields =>
|
||||
setLocalData(currentData => ({ issue: { ...currentData.issue, ...fields } }));
|
||||
|
||||
const updateIssue = updatedFields => {
|
||||
api.optimisticUpdate({
|
||||
url: `/issues/${issueId}`,
|
||||
api.optimisticUpdate(`/issues/${issueId}`, {
|
||||
updatedFields,
|
||||
currentFields: issue,
|
||||
setLocalData: fields => {
|
||||
updateLocalIssue(fields);
|
||||
updateLocalIssuesArray(issue.id, fields);
|
||||
updateLocalIssueDetails(fields);
|
||||
updateLocalProjectIssues(issue.id, fields);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -61,7 +59,13 @@ const ProjectBoardIssueDetails = ({
|
||||
<TopActions>
|
||||
<Type issue={issue} updateIssue={updateIssue} />
|
||||
<TopActionsRight>
|
||||
<Feedback />
|
||||
<AboutTooltip
|
||||
renderLink={linkProps => (
|
||||
<Button icon="feedback" variant="empty" {...linkProps}>
|
||||
Give feedback
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<CopyLinkButton variant="empty" />
|
||||
<Delete issue={issue} fetchProject={fetchProject} modalClose={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 { 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';
|
||||
|
||||
const ProjectNavbarLeft = () => {
|
||||
const match = useRouteMatch();
|
||||
return (
|
||||
<NavLeft>
|
||||
<LogoLink to="/">
|
||||
<StyledLogo color="#fff" />
|
||||
</LogoLink>
|
||||
<Item>
|
||||
<Icon type="search" size={22} top={1} left={3} />
|
||||
<ItemText>Search issues</ItemText>
|
||||
</Item>
|
||||
<Link to={`${match.path}/board/create-issue`}>
|
||||
<Item>
|
||||
<Icon type="plus" size={27} />
|
||||
<ItemText>Create Issue</ItemText>
|
||||
</Item>
|
||||
</Link>
|
||||
<Bottom>
|
||||
<Item>
|
||||
<Icon type="help" size={25} />
|
||||
<ItemText>Help</ItemText>
|
||||
</Item>
|
||||
</Bottom>
|
||||
</NavLeft>
|
||||
);
|
||||
const propTypes = {
|
||||
issueSearchModalOpen: PropTypes.func.isRequired,
|
||||
issueCreateModalOpen: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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 { NavLink } from 'react-router-dom';
|
||||
|
||||
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};
|
||||
`;
|
||||
|
||||
export const LinkItem = styled(NavLink)`
|
||||
export const LinkItem = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
border-radius: 3px;
|
||||
color: ${color.textDark};
|
||||
${mixin.clickable}
|
||||
${props =>
|
||||
!props.implemented
|
||||
? `cursor: not-allowed;`
|
||||
: `&:hover { background: ${color.backgroundLight}; }`}
|
||||
!props.to ? `cursor: not-allowed;` : `&:hover { background: ${color.backgroundLight}; }`}
|
||||
i {
|
||||
margin-right: 15px;
|
||||
font-size: 20px;
|
||||
color: ${color.textDarkest};
|
||||
}
|
||||
&.active {
|
||||
color: ${color.primary};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
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 {
|
||||
@@ -17,38 +18,44 @@ import {
|
||||
} from './Styles';
|
||||
|
||||
const propTypes = {
|
||||
projectName: PropTypes.string.isRequired,
|
||||
project: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
const ProjectSidebar = ({ projectName }) => {
|
||||
const ProjectSidebar = ({ project }) => {
|
||||
const match = useRouteMatch();
|
||||
|
||||
const renderLinkItem = (text, iconType, path = '') => (
|
||||
<LinkItem exact to={`${match.path}${path}`} implemented={path}>
|
||||
<Icon type={iconType} />
|
||||
<LinkText>{text}</LinkText>
|
||||
{!path && <NotImplemented>Not implemented</NotImplemented>}
|
||||
</LinkItem>
|
||||
);
|
||||
const renderLinkItem = (text, iconType, path) => {
|
||||
const linkItemProps = path
|
||||
? { as: NavLink, exact: true, to: `${match.path}${path}` }
|
||||
: { as: 'div' };
|
||||
|
||||
return (
|
||||
<LinkItem {...linkItemProps}>
|
||||
<Icon type={iconType} />
|
||||
<LinkText>{text}</LinkText>
|
||||
{!path && <NotImplemented>Not implemented</NotImplemented>}
|
||||
</LinkItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<ProjectInfo>
|
||||
<ProjectAvatar />
|
||||
<ProjectTexts>
|
||||
<ProjectName>{projectName}</ProjectName>
|
||||
<ProjectCategory>Software project</ProjectCategory>
|
||||
<ProjectName>{project.name}</ProjectName>
|
||||
<ProjectCategory>{ProjectCategoryCopy[project.category]} project</ProjectCategory>
|
||||
</ProjectTexts>
|
||||
</ProjectInfo>
|
||||
|
||||
{renderLinkItem('Kanban Board', 'board', '/board')}
|
||||
{renderLinkItem('Reports', 'reports')}
|
||||
{renderLinkItem('Project settings', 'settings', '/settings')}
|
||||
<Divider />
|
||||
{renderLinkItem('Releases', 'shipping')}
|
||||
{renderLinkItem('Issues and filters', 'issues')}
|
||||
{renderLinkItem('Pages', 'page')}
|
||||
{renderLinkItem('Reports', 'reports')}
|
||||
{renderLinkItem('Components', 'component')}
|
||||
{renderLinkItem('Project settings', 'settings')}
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,40 +3,48 @@ import { Route, Redirect, useRouteMatch, useHistory } from 'react-router-dom';
|
||||
|
||||
import useApi from 'shared/hooks/api';
|
||||
import { updateArrayItemById } from 'shared/utils/javascript';
|
||||
import { createQueryParamModalHelpers } from 'shared/utils/queryParamModal';
|
||||
import { PageLoader, PageError, Modal } from 'shared/components';
|
||||
|
||||
import NavbarLeft from './NavbarLeft';
|
||||
import Sidebar from './Sidebar';
|
||||
import Board from './Board';
|
||||
import IssueDetails from './IssueDetails';
|
||||
import IssueSearch from './IssueSearch';
|
||||
import IssueCreateForm from './IssueCreateForm';
|
||||
import IssueDetails from './IssueDetails';
|
||||
import ProjectSettings from './ProjectSettings';
|
||||
import { ProjectPage } from './Styles';
|
||||
|
||||
const Project = () => {
|
||||
const match = useRouteMatch();
|
||||
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) => {
|
||||
setLocalData(currentData => ({
|
||||
project: {
|
||||
...currentData.project,
|
||||
issues: updateArrayItemById(data.project.issues, issueId, updatedFields),
|
||||
},
|
||||
}));
|
||||
};
|
||||
const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project');
|
||||
|
||||
if (!data) return <PageLoader />;
|
||||
if (error) return <PageError />;
|
||||
|
||||
const { project } = data;
|
||||
|
||||
const renderBoard = () => (
|
||||
<Board
|
||||
project={project}
|
||||
fetchProject={fetchProject}
|
||||
updateLocalIssuesArray={updateLocalIssuesArray}
|
||||
const updateLocalProjectIssues = (issueId, updatedFields) => {
|
||||
setLocalData(currentData => ({
|
||||
project: {
|
||||
...currentData.project,
|
||||
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
|
||||
isOpen
|
||||
width={800}
|
||||
onClose={() => history.push(`${match.url}/board`)}
|
||||
withCloseIcon={false}
|
||||
onClose={issueCreateModalHelpers.close}
|
||||
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 => (
|
||||
<Modal
|
||||
isOpen
|
||||
@@ -62,20 +84,31 @@ const Project = () => {
|
||||
issueId={routeProps.match.params.issueId}
|
||||
projectUsers={project.users}
|
||||
fetchProject={fetchProject}
|
||||
updateLocalIssuesArray={updateLocalIssuesArray}
|
||||
updateLocalProjectIssues={updateLocalProjectIssues}
|
||||
modalClose={modal.close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderProjectSettings = () => (
|
||||
<ProjectSettings project={project} fetchProject={fetchProject} />
|
||||
);
|
||||
|
||||
return (
|
||||
<ProjectPage>
|
||||
<NavbarLeft />
|
||||
<Sidebar projectName={project.name} />
|
||||
<NavbarLeft
|
||||
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/create-issue`} render={renderIssueCreateModal} />
|
||||
<Route path={`${match.path}/board/issue/:issueId`} render={renderIssueDetailsModal} />
|
||||
<Route path={`${match.path}/board/issues/:issueId`} render={renderIssueDetailsModal} />
|
||||
<Route path={`${match.path}/settings`} render={renderProjectSettings} />
|
||||
{match.isExact && <Redirect to={`${match.url}/board`} />}
|
||||
</ProjectPage>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user