Improved code styling

This commit is contained in:
ireic
2019-12-24 16:39:03 +01:00
parent 4941261251
commit 3c705a6084
81 changed files with 671 additions and 583 deletions

View File

@@ -1,8 +0,0 @@
import styled from 'styled-components';
import { sizes } from 'shared/utils/styles';
export const Main = styled.main`
position: relative;
width: 100%;
padding-left: ${sizes.appNavBarLeftWidth}px;
`;

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { Router, Switch, Route, Redirect } from 'react-router-dom';
import history from 'browserHistory';
import PageError from 'shared/components/PageError';
import history from 'browserHistory';
import Project from 'Project';
import Authenticate from './Authenticate';
import Authenticate from 'Auth/Authenticate';
import PageError from 'shared/components/PageError';
const Routes = () => (
<Router history={history}>

View File

@@ -1,6 +1,7 @@
import styled from 'styled-components';
import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
import { Icon } from 'shared/components';
export const Container = styled.div`
z-index: ${zIndexValues.modal + 1};
@@ -33,15 +34,15 @@ export const StyledToast = styled.div`
opacity: 1;
right: 0;
}
`;
i {
position: absolute;
top: 13px;
right: 14px;
font-size: 22px;
cursor: pointer;
color: #fff;
}
export const CloseIcon = styled(Icon)`
position: absolute;
top: 13px;
right: 14px;
font-size: 22px;
cursor: pointer;
color: #fff;
`;
export const Title = styled.div`

View File

@@ -3,15 +3,14 @@ import { CSSTransition, TransitionGroup } from 'react-transition-group';
import pubsub from 'sweet-pubsub';
import { uniqueId } from 'lodash';
import { Icon } from 'shared/components';
import { Container, StyledToast, Title, Message } from './Styles';
import { Container, StyledToast, CloseIcon, Title, Message } from './Styles';
const Toast = () => {
const [toasts, setToasts] = useState([]);
useEffect(() => {
const addToast = ({ type = 'success', title, message, duration = 5 }) => {
const id = uniqueId();
const id = uniqueId('toast-');
setToasts(currentToasts => [...currentToasts, { id, type, title, message }]);
@@ -19,7 +18,9 @@ const Toast = () => {
setTimeout(() => removeToast(id), duration * 1000);
}
};
pubsub.on('toast', addToast);
return () => {
pubsub.off('toast', addToast);
};
@@ -35,7 +36,7 @@ const Toast = () => {
{toasts.map(toast => (
<CSSTransition key={toast.id} classNames="jira-toast" timeout={200}>
<StyledToast key={toast.id} type={toast.type} onClick={() => removeToast(toast.id)}>
<Icon type="close" />
<CloseIcon type="close" />
{toast.title && <Title>{toast.title}</Title>}
{toast.message && <Message>{toast.message}</Message>}
</StyledToast>

View File

@@ -36,4 +36,7 @@
<glyph unicode="&#xe91a;" glyph-name="component" d="M618.965 537.387l-106.965-61.867-297.003 171.819 107.136 61.227zM809.003 647.339l-104.789-60.629-296.277 170.88 82.517 47.147c4.779 2.731 9.899 4.48 15.147 5.333 9.301 1.451 18.987-0.128 27.904-5.291zM491.776-41.002c6.016-3.243 12.928-5.077 20.224-5.077 7.381 0 14.336 1.877 20.395 5.163 15.189 2.475 29.909 7.68 43.392 15.36l298.709 170.709c26.368 15.232 45.269 38.315 55.424 64.597 5.675 14.592 8.619 30.165 8.747 46.251v341.333c0 20.395-4.821 39.723-13.397 56.917-0.939 3.029-2.219 5.973-3.883 8.832-1.963 3.371-4.267 6.357-6.912 8.96-1.323 1.835-2.731 3.669-4.139 5.419-9.813 12.203-21.845 22.528-35.456 30.507l-299.051 170.88c-26.027 15.019-55.467 19.84-83.328 15.531-15.531-2.432-30.507-7.637-44.288-15.488l-136.491-77.995c-8.96-1.749-17.323-6.4-23.595-13.483l-138.624-79.232c-16.341-9.429-29.824-21.888-40.149-36.267-2.56-2.56-4.864-5.547-6.784-8.832-1.664-2.901-2.987-5.888-3.925-8.96-1.707-3.456-3.243-6.955-4.608-10.496-5.632-14.635-8.576-30.208-8.704-45.995v-341.632c0.043-30.293 10.581-58.197 28.331-80.128 9.813-12.203 21.845-22.528 35.456-30.507l299.051-170.88c13.824-7.979 28.587-13.099 43.605-15.445zM469.333 401.622v-340.949l-277.12 158.336c-4.736 2.773-8.832 6.315-12.16 10.411-5.931 7.381-9.387 16.512-9.387 26.581v318.379zM554.667 60.672v340.949l298.667 172.757v-318.379c-0.043-5.163-1.067-10.496-2.987-15.445-3.413-8.789-9.6-16.384-18.176-21.333z" />
<glyph unicode="&#xe91b;" glyph-name="reports" d="M725.333 640h153.003l-302.336-302.336-183.168 183.168c-16.683 16.683-43.691 16.683-60.331 0l-320-320c-16.683-16.683-16.683-43.691 0-60.331s43.691-16.683 60.331 0l289.835 289.835 183.168-183.168c16.683-16.683 43.691-16.683 60.331 0l332.501 332.501v-153.003c0-23.552 19.115-42.667 42.667-42.667s42.667 19.115 42.667 42.667v256c0 5.803-1.152 11.307-3.243 16.341s-5.163 9.728-9.216 13.781c-0.043 0.043-0.043 0.043-0.085 0.085-3.925 3.925-8.619 7.083-13.781 9.216-5.035 2.091-10.539 3.243-16.341 3.243h-256c-23.552 0-42.667-19.115-42.667-42.667s19.115-42.667 42.667-42.667z" />
<glyph unicode="&#xe91c;" glyph-name="shipping" d="M640 298.667h-554.667v469.333h554.667v-170.667zM725.333 554.667h110.336l102.997-102.997v-153.003h-213.333zM298.667 149.334c0-17.664-7.125-33.621-18.731-45.269s-27.605-18.731-45.269-18.731-33.621 7.125-45.269 18.731-18.731 27.605-18.731 45.269 7.125 33.621 18.731 45.269 27.605 18.731 45.269 18.731 33.621-7.125 45.269-18.731 18.731-27.605 18.731-45.269zM938.667 149.334c0 22.912-5.163 44.587-14.379 64h57.045c23.552 0 42.667 19.115 42.667 42.667v213.333c0 10.923-4.181 21.845-12.501 30.165l-128 128c-7.723 7.723-18.389 12.501-30.165 12.501h-128v170.667c0 23.552-19.115 42.667-42.667 42.667h-640c-23.552 0-42.667-19.115-42.667-42.667v-554.667c0-23.552 19.115-42.667 42.667-42.667h57.045c-9.216-19.413-14.379-41.088-14.379-64 0-41.216 16.768-78.635 43.733-105.6s64.384-43.733 105.6-43.733 78.635 16.768 105.6 43.733 43.733 64.384 43.733 105.6c0 22.912-5.163 44.587-14.379 64h284.757c-9.216-19.413-14.379-41.088-14.379-64 0-41.216 16.768-78.635 43.733-105.6s64.384-43.733 105.6-43.733 78.635 16.768 105.6 43.733 43.733 64.384 43.733 105.6zM853.333 149.334c0-17.664-7.125-33.621-18.731-45.269s-27.605-18.731-45.269-18.731-33.621 7.125-45.269 18.731-18.731 27.605-18.731 45.269 7.125 33.621 18.731 45.269 27.605 18.731 45.269 18.731 33.621-7.125 45.269-18.731 18.731-27.605 18.731-45.269z" />
<glyph unicode="&#xe91d;" glyph-name="calendar" d="M298.667 853.334v-42.667h-85.333c-35.328 0-67.413-14.379-90.496-37.504s-37.504-55.168-37.504-90.496v-597.333c0-35.328 14.379-67.413 37.504-90.496s55.168-37.504 90.496-37.504h597.333c35.328 0 67.413 14.379 90.496 37.504s37.504 55.168 37.504 90.496v597.333c0 35.328-14.379 67.413-37.504 90.496s-55.168 37.504-90.496 37.504h-85.333v42.667c0 23.552-19.115 42.667-42.667 42.667s-42.667-19.115-42.667-42.667v-42.667h-256v42.667c0 23.552-19.115 42.667-42.667 42.667s-42.667-19.115-42.667-42.667zM853.333 554.667h-682.667v128c0 11.776 4.736 22.4 12.501 30.165s18.389 12.501 30.165 12.501h85.333v-42.667c0-23.552 19.115-42.667 42.667-42.667s42.667 19.115 42.667 42.667v42.667h256v-42.667c0-23.552 19.115-42.667 42.667-42.667s42.667 19.115 42.667 42.667v42.667h85.333c11.776 0 22.4-4.736 30.165-12.501s12.501-18.389 12.501-30.165zM170.667 469.334h682.667v-384c0-11.776-4.736-22.4-12.501-30.165s-18.389-12.501-30.165-12.501h-597.333c-11.776 0-22.4 4.736-30.165 12.501s-12.501 18.389-12.501 30.165z" />
<glyph unicode="&#xe91e;" glyph-name="arrow-left" d="M542.165 158.166l-225.835 225.835h494.336c23.552 0 42.667 19.115 42.667 42.667s-19.115 42.667-42.667 42.667h-494.336l225.835 225.835c16.683 16.683 16.683 43.691 0 60.331s-43.691 16.683-60.331 0l-298.667-298.667c-4.096-4.096-7.168-8.789-9.259-13.824-2.176-5.205-3.243-10.795-3.243-16.341 0-10.923 4.181-21.845 12.501-30.165l298.667-298.667c16.683-16.683 43.691-16.683 60.331 0s16.683 43.691 0 60.331z" />
<glyph unicode="&#xe91f;" glyph-name="arrow-right" d="M481.835 695.168l225.835-225.835h-494.336c-23.552 0-42.667-19.115-42.667-42.667s19.115-42.667 42.667-42.667h494.336l-225.835-225.835c-16.683-16.683-16.683-43.691 0-60.331s43.691-16.683 60.331 0l298.667 298.667c3.925 3.925 7.083 8.619 9.259 13.824 4.309 10.453 4.309 22.229 0 32.683-2.091 5.035-5.163 9.728-9.259 13.824l-298.667 298.667c-16.683 16.683-43.691 16.683-60.331 0s-16.683-43.691 0-60.331z" />
</font></defs></svg>

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

View File

@@ -5,7 +5,7 @@ import BaseStyles from './BaseStyles';
import Toast from './Toast';
import Routes from './Routes';
// We're importing css because @font-face in styled-components causes font files
// We're importing .css because @font-face in styled-components causes font files
// to be constantly re-requested from the server (which causes screen flicker)
// https://github.com/styled-components/styled-components/issues/1593
import './fontStyles.css';

View File

@@ -1,8 +1,8 @@
import React, { useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import toast from 'shared/utils/toast';
import api from 'shared/utils/api';
import toast from 'shared/utils/toast';
import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken';
import { PageLoader } from 'shared/components';
@@ -11,17 +11,18 @@ const Authenticate = () => {
useEffect(() => {
const createGuestAccount = async () => {
if (!getStoredAuthToken()) {
try {
const { authToken } = await api.post('/authentication/guest');
storeAuthToken(authToken);
history.push('/');
} catch (error) {
toast.error(error);
}
try {
const { authToken } = await api.post('/authentication/guest');
storeAuthToken(authToken);
history.push('/');
} catch (error) {
toast.error(error);
}
};
createGuestAccount();
if (!getStoredAuthToken()) {
createGuestAccount();
}
}, [history]);
return <PageLoader />;

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
import { InputDebounced, Avatar, Button } from 'shared/components';
import { color, font, mixin } from 'shared/utils/styles';
import { InputDebounced, Avatar, Button } from 'shared/components';
export const Filters = styled.div`
display: flex;

View File

@@ -16,14 +16,12 @@ const propTypes = {
projectUsers: PropTypes.array.isRequired,
defaultFilters: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
setFilters: PropTypes.func.isRequired,
mergeFilters: PropTypes.func.isRequired,
};
const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, setFilters }) => {
const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, mergeFilters }) => {
const { searchQuery, userIds, myOnly, recent } = filters;
const setFiltersMerge = newFilters => setFilters({ ...filters, ...newFilters });
const areFiltersCleared = !searchQuery && userIds.length === 0 && !myOnly && !recent;
return (
@@ -31,7 +29,7 @@ const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, setFilters
<SearchInput
icon="search"
value={searchQuery}
onChange={value => setFiltersMerge({ searchQuery: value })}
onChange={value => mergeFilters({ searchQuery: value })}
/>
<Avatars>
{projectUsers.map(user => (
@@ -39,27 +37,27 @@ const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, setFilters
<StyledAvatar
avatarUrl={user.avatarUrl}
name={user.name}
onClick={() => setFiltersMerge({ userIds: xor(userIds, [user.id]) })}
onClick={() => mergeFilters({ userIds: xor(userIds, [user.id]) })}
/>
</AvatarIsActiveBorder>
))}
</Avatars>
<StyledButton
color="empty"
variant="empty"
isActive={myOnly}
onClick={() => setFiltersMerge({ myOnly: !myOnly })}
onClick={() => mergeFilters({ myOnly: !myOnly })}
>
Only My Issues
</StyledButton>
<StyledButton
color="empty"
variant="empty"
isActive={recent}
onClick={() => setFiltersMerge({ recent: !recent })}
onClick={() => mergeFilters({ recent: !recent })}
>
Recently Updated
</StyledButton>
{!areFiltersCleared && (
<ClearAll onClick={() => setFilters(defaultFilters)}>Clear all</ClearAll>
<ClearAll onClick={() => mergeFilters(defaultFilters)}>Clear all</ClearAll>
)}
</Filters>
);

View File

@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { CopyLinkButton } from 'shared/components';
import { Breadcrumbs, Divider, Header, BoardName } from './Styles';
const propTypes = {
@@ -17,6 +18,7 @@ const ProjectBoardHeader = ({ projectName }) => (
<Divider>/</Divider>
Kanban Board
</Breadcrumbs>
<Header>
<BoardName>Kanban board</BoardName>
<CopyLinkButton />

View File

@@ -1,8 +1,8 @@
import styled, { css } from 'styled-components';
import { Link } from 'react-router-dom';
import { Avatar } from 'shared/components';
import { color, font, mixin } from 'shared/utils/styles';
import { Avatar } from 'shared/components';
export const IssueLink = styled(Link)`
display: block;

View File

@@ -4,6 +4,7 @@ import { useRouteMatch } from 'react-router-dom';
import { Draggable } from 'react-beautiful-dnd';
import { IssueTypeIcon, IssuePriorityIcon } from 'shared/components';
import { IssueLink, Issue, Title, Bottom, Assignees, AssigneeAvatar } from './Styles';
const propTypes = {

View File

@@ -8,6 +8,7 @@ import api from 'shared/utils/api';
import useApi from 'shared/hooks/api';
import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/javascript';
import { IssueStatus, IssueStatusCopy } from 'shared/constants/issues';
import Issue from './Issue';
import { Lists, List, Title, IssuesCount, Issues } from './Styles';
@@ -21,13 +22,8 @@ const ProjectBoardLists = ({ project, filters, updateLocalIssuesArray }) => {
const [{ data: currentUserData }] = useApi.get('/currentUser');
const currentUserId = get(currentUserData, 'currentUser.id');
const filteredIssues = filterIssues(project.issues, filters, currentUserId);
const handleIssueDrop = async ({ draggableId, destination, source }) => {
if (!destination) return;
const isSameList = destination.droppableId === source.droppableId;
const isSamePosition = destination.index === source.index;
if (isSameList && isSamePosition) return;
if (!isPositionChanged(source, destination)) return;
const issueId = Number(draggableId);
@@ -35,7 +31,7 @@ const ProjectBoardLists = ({ project, filters, updateLocalIssuesArray }) => {
url: `/issues/${issueId}`,
updatedFields: {
status: destination.droppableId,
listPosition: calculateListPosition(project.issues, destination, isSameList, issueId),
listPosition: calculateListPosition(project.issues, destination, source, issueId),
},
currentFields: project.issues.find(({ id }) => id === issueId),
setLocalData: fields => updateLocalIssuesArray(issueId, fields),
@@ -43,21 +39,17 @@ const ProjectBoardLists = ({ project, filters, updateLocalIssuesArray }) => {
};
const renderList = status => {
const filteredIssues = filterIssues(project.issues, filters, currentUserId);
const filteredListIssues = getSortedListIssues(filteredIssues, status);
const allListIssues = getSortedListIssues(project.issues, status);
const issuesCount =
allListIssues.length !== filteredListIssues.length
? `${filteredListIssues.length} of ${allListIssues.length}`
: allListIssues.length;
return (
<Droppable key={status} droppableId={status}>
{provided => (
<List>
<Title>
{`${IssueStatusCopy[status]} `}
<IssuesCount>{issuesCount}</IssuesCount>
<IssuesCount>{formatIssuesCount(allListIssues, filteredListIssues)}</IssuesCount>
</Title>
<Issues {...provided.droppableProps} ref={provided.innerRef}>
{filteredListIssues.map((issue, index) => (
@@ -102,6 +94,20 @@ const filterIssues = (projectIssues, filters, currentUserId) => {
const getSortedListIssues = (issues, status) =>
issues.filter(issue => issue.status === status).sort((a, b) => a.listPosition - b.listPosition);
const formatIssuesCount = (allListIssues, filteredListIssues) => {
if (allListIssues.length !== filteredListIssues.length) {
return `${filteredListIssues.length} of ${allListIssues.length}`;
}
return allListIssues.length;
};
const isPositionChanged = (destination, source) => {
if (!destination) return false;
const isSameList = destination.droppableId === source.droppableId;
const isSamePosition = destination.index === source.index;
return !isSameList || !isSamePosition;
};
const calculateListPosition = (...args) => {
const { prevIssue, nextIssue } = getAfterDropPrevNextIssue(...args);
let position;
@@ -118,9 +124,10 @@ const calculateListPosition = (...args) => {
return position;
};
const getAfterDropPrevNextIssue = (allIssues, destination, isSameList, droppedIssueId) => {
const getAfterDropPrevNextIssue = (allIssues, destination, source, droppedIssueId) => {
const destinationIssues = getSortedListIssues(allIssues, destination.droppableId);
const droppedIssue = allIssues.find(issue => issue.id === droppedIssueId);
const isSameList = destination.droppableId === source.droppableId;
const afterDropDestinationIssues = isSameList
? moveItemWithinArray(destinationIssues, droppedIssue, destination.index)

View File

@@ -1,6 +1,8 @@
import React, { useState } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import useMergeState from 'shared/hooks/mergeState';
import Header from './Header';
import Filters from './Filters';
import Lists from './Lists';
@@ -18,7 +20,7 @@ const defaultFilters = {
};
const ProjectBoard = ({ project, updateLocalIssuesArray }) => {
const [filters, setFilters] = useState(defaultFilters);
const [filters, mergeFilters] = useMergeState(defaultFilters);
return (
<>
<Header projectName={project.name} />
@@ -26,7 +28,7 @@ const ProjectBoard = ({ project, updateLocalIssuesArray }) => {
projectUsers={project.users}
defaultFilters={defaultFilters}
filters={filters}
setFilters={setFilters}
mergeFilters={mergeFilters}
/>
<Lists project={project} filters={filters} updateLocalIssuesArray={updateLocalIssuesArray} />
</>

View File

@@ -11,6 +11,7 @@ import {
import toast from 'shared/utils/toast';
import useApi from 'shared/hooks/api';
import { Form, IssueTypeIcon, Icon, Avatar, IssuePriorityIcon } from 'shared/components';
import {
FormHeading,
FormElement,
@@ -152,10 +153,10 @@ const ProjectIssueCreateForm = ({ project, fetchProject, modalClose }) => {
renderValue={renderPriority}
/>
<Actions>
<ActionButton type="submit" color="primary" working={isCreating}>
<ActionButton type="submit" variant="primary" working={isCreating}>
Create Issue
</ActionButton>
<ActionButton color="empty" onClick={modalClose}>
<ActionButton variant="empty" onClick={modalClose}>
Cancel
</ActionButton>
</Actions>

View File

@@ -2,6 +2,7 @@ import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import { Textarea } from 'shared/components';
import { Actions, FormButton } from './Styles';
const propTypes = {
@@ -20,6 +21,7 @@ const ProjectBoardIssueDetailsCommentsBodyForm = ({
onCancel,
}) => {
const $textareaRef = useRef();
return (
<>
<Textarea
@@ -31,8 +33,8 @@ const ProjectBoardIssueDetailsCommentsBodyForm = ({
/>
<Actions>
<FormButton
color="primary"
working={isWorking}
variant="primary"
isWorking={isWorking}
onClick={() => {
if ($textareaRef.current.value.trim()) {
onSubmit();
@@ -41,7 +43,7 @@ const ProjectBoardIssueDetailsCommentsBodyForm = ({
>
Save
</FormButton>
<FormButton color="empty" onClick={onCancel}>
<FormButton variant="empty" onClick={onCancel}>
Cancel
</FormButton>
</Actions>

View File

@@ -5,6 +5,7 @@ import api from 'shared/utils/api';
import toast from 'shared/utils/toast';
import { formatDateTimeConversational } from 'shared/utils/dateTime';
import { ConfirmModal } from 'shared/components';
import BodyForm from '../BodyForm';
import {
Comment,
@@ -35,6 +36,7 @@ const ProjectBoardIssueDetailsComment = ({ comment, fetchIssue }) => {
toast.error(error);
}
};
const handleCommentUpdate = async () => {
try {
setUpdating(true);
@@ -46,12 +48,14 @@ const ProjectBoardIssueDetailsComment = ({ comment, fetchIssue }) => {
toast.error(error);
}
};
return (
<Comment>
<UserAvatar name={comment.user.name} avatarUrl={comment.user.avatarUrl} />
<Content>
<Username>{comment.user.name}</Username>
<CreatedAt>{formatDateTimeConversational(comment.createdAt)}</CreatedAt>
{isFormOpen ? (
<BodyForm
value={body}

View File

@@ -2,7 +2,8 @@ import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { KeyCodes } from 'shared/constants/keyCodes';
import { isFocusedElementEditable } from 'shared/utils/dom';
import { isFocusedElementEditable } from 'shared/utils/browser';
import { Tip, TipLetter } from './Styles';
const propTypes = {
@@ -17,7 +18,9 @@ const ProjectBoardIssueDetailsCommentsCreateProTip = ({ setFormOpen }) => {
setFormOpen(true);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};

View File

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import api from 'shared/utils/api';
import useApi from 'shared/hooks/api';
import toast from 'shared/utils/toast';
import BodyForm from '../BodyForm';
import ProTip from './ProTip';
import { Create, UserAvatar, Right, FakeTextarea } from './Styles';

View File

@@ -14,13 +14,14 @@ const ProjectBoardIssueDetailsComments = ({ issue, fetchIssue }) => (
<Comments>
<Title>Comments</Title>
<Create issueId={issue.id} fetchIssue={fetchIssue} />
{sortByNewestFirst(issue.comments).map(comment => (
{sortByNewest(issue.comments).map(comment => (
<Comment key={comment.id} comment={comment} fetchIssue={fetchIssue} />
))}
</Comments>
);
const sortByNewestFirst = items => items.sort((a, b) => -a.createdAt.localeCompare(b.createdAt));
const sortByNewest = items => items.sort((a, b) => -a.createdAt.localeCompare(b.createdAt));
ProjectBoardIssueDetailsComments.propTypes = propTypes;

View File

@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { formatDateTimeConversational } from 'shared/utils/dateTime';
import { Dates } from './Styles';
const propTypes = {

View File

@@ -21,13 +21,16 @@ const ProjectBoardIssueDetailsDelete = ({ issue, fetchProject, modalClose }) =>
toast.error(error);
}
};
return (
<ConfirmModal
title="Are you sure you want to delete this issue?"
message="Once you delete, it's gone for good."
confirmText="Delete issue"
onConfirm={handleIssueDelete}
renderLink={modal => <Button icon="trash" iconSize={19} color="empty" onClick={modal.open} />}
renderLink={modal => (
<Button icon="trash" iconSize={19} variant="empty" onClick={modal.open} />
)}
/>
);
};

View File

@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { getTextContentsFromHtmlString } from 'shared/utils/html';
import { getTextContentsFromHtmlString } from 'shared/utils/browser';
import { TextEditor, TextEditedContent, Button } from 'shared/components';
import { Title, EmptyLabel, Actions } from './Styles';
const propTypes = {
@@ -14,8 +15,15 @@ const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
const [value, setValue] = useState(issue.description);
const [isEditing, setEditing] = useState(false);
const handleUpdate = () => {
setEditing(false);
updateIssue({ description: value });
};
const isDescriptionEmpty = getTextContentsFromHtmlString(issue.description).trim().length === 0;
const renderPresentingMode = () =>
isDescriptionEmpty(issue.description) ? (
isDescriptionEmpty ? (
<EmptyLabel onClick={() => setEditing(true)}>Add a description...</EmptyLabel>
) : (
<TextEditedContent content={issue.description} onClick={() => setEditing(true)} />
@@ -25,21 +33,16 @@ const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
<>
<TextEditor placeholder="Describe the issue" defaultValue={value} onChange={setValue} />
<Actions>
<Button
color="primary"
onClick={() => {
setEditing(false);
updateIssue({ description: value });
}}
>
<Button variant="primary" onClick={handleUpdate}>
Save
</Button>
<Button color="empty" onClick={() => setEditing(false)}>
<Button variant="empty" onClick={() => setEditing(false)}>
Cancel
</Button>
</Actions>
</>
);
return (
<>
<Title>Description</Title>
@@ -48,9 +51,6 @@ const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
);
};
const isDescriptionEmpty = description =>
getTextContentsFromHtmlString(description).trim().length === 0;
ProjectBoardIssueDetailsDescription.propTypes = propTypes;
export default ProjectBoardIssueDetailsDescription;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Button, Tooltip } from 'shared/components';
import feedbackImage from './assets/feedback.png';
import { FeedbackDropdown, FeedbackImageCont, FeedbackImage, FeedbackParagraph } from './Styles';
@@ -9,7 +10,7 @@ const ProjectBoardIssueDetailsFeedback = () => (
width={300}
offset={{ top: -15 }}
renderLink={linkProps => (
<Button icon="feedback" color="empty" {...linkProps}>
<Button icon="feedback" variant="empty" {...linkProps}>
Give feedback
</Button>
)}
@@ -18,19 +19,23 @@ const ProjectBoardIssueDetailsFeedback = () => (
<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 color="primary">Visit Website</Button>
<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

View File

@@ -3,8 +3,9 @@ import PropTypes from 'prop-types';
import { IssuePriority, IssuePriorityCopy } from 'shared/constants/issues';
import { Select, IssuePriorityIcon } from 'shared/components';
import { Priority, Label } from './Styles';
import { SectionTitle } from '../Styles';
import { Priority, Label } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
@@ -18,6 +19,7 @@ const ProjectBoardIssueDetailsPriority = ({ issue, updateIssue }) => {
<Label>{IssuePriorityCopy[priority]}</Label>
</Priority>
);
return (
<>
<SectionTitle>Priority</SectionTitle>

View File

@@ -3,8 +3,9 @@ import PropTypes from 'prop-types';
import { IssueStatus, IssueStatusCopy } from 'shared/constants/issues';
import { Select, Icon } from 'shared/components';
import { Status } from './Styles';
import { SectionTitle } from '../Styles';
import { Status } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,

View File

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { KeyCodes } from 'shared/constants/keyCodes';
import { is, generateErrors } from 'shared/utils/validation';
import { TitleTextarea, ErrorText } from './Styles';
const propTypes = {

View File

@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import { isNil } from 'lodash';
import { InputDebounced, Modal, Button } from 'shared/components';
import { SectionTitle } from '../Styles';
import {
TrackingLink,
Tracking,
@@ -18,7 +20,6 @@ import {
InputLabel,
Actions,
} from './Styles';
import { SectionTitle } from '../Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
@@ -38,52 +39,6 @@ const ProjectBoardIssueDetailsTracking = ({ issue, updateIssue }) => {
/>
);
const calculateTrackingBarWidth = () => {
const { timeSpent, timeRemaining, estimate } = issue;
if (!timeSpent) {
return 0;
}
if (isNil(timeRemaining) && isNil(estimate)) {
return 100;
}
if (!isNil(timeRemaining)) {
return (timeSpent / (timeSpent + timeRemaining)) * 100;
}
if (!isNil(estimate)) {
return Math.min((timeSpent / estimate) * 100, 100);
}
};
const renderRemainingOrEstimate = () => {
const { timeRemaining, estimate } = issue;
if (isNil(timeRemaining) && isNil(estimate)) {
return null;
}
if (!isNil(timeRemaining)) {
return <div>{`${timeRemaining}h remaining`}</div>;
}
if (!isNil(estimate)) {
return <div>{`${estimate}h estimated`}</div>;
}
};
const renderTrackingPreview = (onClick = () => {}) => (
<Tracking onClick={onClick}>
<WatchIcon type="stopwatch" size={26} top={-1} />
<Right>
<BarCont>
<Bar width={calculateTrackingBarWidth()} />
</BarCont>
<Values>
<div>{issue.timeSpent ? `${issue.timeSpent}h logged` : 'No time logged'}</div>
{renderRemainingOrEstimate()}
</Values>
</Right>
</Tracking>
);
const renderEstimate = () => (
<>
<SectionTitle>Original Estimate (hours)</SectionTitle>
@@ -96,11 +51,11 @@ const ProjectBoardIssueDetailsTracking = ({ issue, updateIssue }) => {
<SectionTitle>Time Tracking</SectionTitle>
<Modal
width={400}
renderLink={modal => <TrackingLink>{renderTrackingPreview(modal.open)}</TrackingLink>}
renderLink={modal => <TrackingLink>{renderTrackingWidget(modal.open)}</TrackingLink>}
renderContent={modal => (
<ModalContents>
<ModalTitle>Time tracking</ModalTitle>
{renderTrackingPreview()}
{renderTrackingWidget()}
<Inputs>
<InputCont>
<InputLabel>Time spent (hours)</InputLabel>
@@ -112,7 +67,7 @@ const ProjectBoardIssueDetailsTracking = ({ issue, updateIssue }) => {
</InputCont>
</Inputs>
<Actions>
<Button color="primary" onClick={modal.close}>
<Button variant="primary" onClick={modal.close}>
Done
</Button>
</Actions>
@@ -122,6 +77,21 @@ const ProjectBoardIssueDetailsTracking = ({ issue, updateIssue }) => {
</>
);
const renderTrackingWidget = (onClick = () => {}) => (
<Tracking onClick={onClick}>
<WatchIcon type="stopwatch" size={26} top={-1} />
<Right>
<BarCont>
<Bar width={calculateTrackingBarWidth(issue)} />
</BarCont>
<Values>
<div>{issue.timeSpent ? `${issue.timeSpent}h logged` : 'No time logged'}</div>
{renderRemainingOrEstimate(issue)}
</Values>
</Right>
</Tracking>
);
return (
<>
{renderEstimate()}
@@ -130,6 +100,33 @@ const ProjectBoardIssueDetailsTracking = ({ issue, updateIssue }) => {
);
};
const calculateTrackingBarWidth = ({ timeSpent, timeRemaining, estimate }) => {
if (!timeSpent) {
return 0;
}
if (isNil(timeRemaining) && isNil(estimate)) {
return 100;
}
if (!isNil(timeRemaining)) {
return (timeSpent / (timeSpent + timeRemaining)) * 100;
}
if (!isNil(estimate)) {
return Math.min((timeSpent / estimate) * 100, 100);
}
};
const renderRemainingOrEstimate = ({ timeRemaining, estimate }) => {
if (isNil(timeRemaining) && isNil(estimate)) {
return null;
}
if (!isNil(timeRemaining)) {
return <div>{`${timeRemaining}h remaining`}</div>;
}
if (!isNil(estimate)) {
return <div>{`${estimate}h estimated`}</div>;
}
};
ProjectBoardIssueDetailsTracking.propTypes = propTypes;
export default ProjectBoardIssueDetailsTracking;

View File

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { IssueType, IssueTypeCopy } from 'shared/constants/issues';
import { IssueTypeIcon, Select } from 'shared/components';
import { TypeButton, Type, TypeLabel } from './Styles';
const propTypes = {
@@ -21,7 +22,7 @@ const ProjectBoardIssueDetailsType = ({ issue, updateIssue }) => (
}))}
onChange={type => updateIssue({ type })}
renderValue={({ value: type }) => (
<TypeButton color="empty" icon={<IssueTypeIcon type={type} />}>
<TypeButton variant="empty" icon={<IssueTypeIcon type={type} />}>
{`${type}-${issue.id}`}
</TypeButton>
)}

View File

@@ -2,8 +2,9 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Avatar, Select, Icon } from 'shared/components';
import { User, Username } from './Styles';
import { SectionTitle } from '../Styles';
import { User, Username } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,

View File

@@ -4,6 +4,7 @@ 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 Loader from './Loader';
import Type from './Type';
import Feedback from './Feedback';
@@ -61,9 +62,9 @@ const ProjectBoardIssueDetails = ({
<Type issue={issue} updateIssue={updateIssue} />
<TopActionsRight>
<Feedback />
<CopyLinkButton color="empty" />
<CopyLinkButton variant="empty" />
<Delete issue={issue} fetchProject={fetchProject} modalClose={modalClose} />
<Button icon="close" iconSize={24} color="empty" onClick={modalClose} />
<Button icon="close" iconSize={24} variant="empty" onClick={modalClose} />
</TopActionsRight>
</TopActions>
<Content>

View File

@@ -2,7 +2,7 @@ import styled from 'styled-components';
import { NavLink } from 'react-router-dom';
import { font, sizes, color, mixin, zIndexValues } from 'shared/utils/styles';
import Logo from 'shared/components/Logo';
import { Logo } from 'shared/components';
export const NavLeft = styled.aside`
z-index: ${zIndexValues.navLeft};

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Link, useRouteMatch } from 'react-router-dom';
import { Icon } from 'shared/components';
import { NavLeft, LogoLink, StyledLogo, Bottom, Item, ItemText } from './Styles';
const ProjectNavbarLeft = () => {

View File

@@ -1,4 +1,4 @@
import styled, { css } from 'styled-components';
import styled from 'styled-components';
import { NavLink } from 'react-router-dom';
import { color, sizes, font, mixin, zIndexValues } from 'shared/utils/styles';
@@ -51,11 +51,7 @@ export const LinkItem = styled(NavLink)`
${props =>
!props.implemented
? `cursor: not-allowed;`
: css`
&:hover {
background: ${color.backgroundLight};
}
`}
: `&:hover { background: ${color.backgroundLight}; }`}
i {
margin-right: 15px;
font-size: 20px;

View File

@@ -1,7 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useRouteMatch } from 'react-router-dom';
import { Icon, ProjectAvatar } from 'shared/components';
import {
Sidebar,
ProjectInfo,
@@ -16,17 +18,19 @@ import {
const propTypes = {
projectName: PropTypes.string.isRequired,
matchPath: PropTypes.string.isRequired,
};
const ProjectSidebar = ({ projectName, matchPath }) => {
const ProjectSidebar = ({ projectName }) => {
const match = useRouteMatch();
const renderLinkItem = (text, iconType, path = '') => (
<LinkItem exact to={`${matchPath}${path}`} implemented={path}>
<LinkItem exact to={`${match.path}${path}`} implemented={path}>
<Icon type={iconType} />
<LinkText>{text}</LinkText>
{!path && <NotImplemented>Not implemented</NotImplemented>}
</LinkItem>
);
return (
<Sidebar>
<ProjectInfo>
@@ -36,6 +40,7 @@ const ProjectSidebar = ({ projectName, matchPath }) => {
<ProjectCategory>Software project</ProjectCategory>
</ProjectTexts>
</ProjectInfo>
{renderLinkItem('Kanban Board', 'board', '/board')}
{renderLinkItem('Reports', 'reports')}
<Divider />

View File

@@ -4,6 +4,7 @@ import { Route, Redirect, useRouteMatch, useHistory } from 'react-router-dom';
import useApi from 'shared/hooks/api';
import { updateArrayItemById } from 'shared/utils/javascript';
import { PageLoader, PageError, Modal } from 'shared/components';
import NavbarLeft from './NavbarLeft';
import Sidebar from './Sidebar';
import Board from './Board';
@@ -38,12 +39,24 @@ const Project = () => {
updateLocalIssuesArray={updateLocalIssuesArray}
/>
);
const renderIssueCreateModal = () => (
<Modal
isOpen
width={800}
onClose={() => history.push(`${match.url}/board`)}
renderContent={modal => (
<IssueCreateForm project={project} fetchProject={fetchProject} modalClose={modal.close} />
)}
/>
);
const renderIssueDetailsModal = routeProps => (
<Modal
isOpen
width={1040}
withCloseIcon={false}
onClose={() => history.push(match.url)}
onClose={() => history.push(`${match.url}/board`)}
renderContent={modal => (
<IssueDetails
issueId={routeProps.match.params.issueId}
@@ -55,20 +68,11 @@ const Project = () => {
)}
/>
);
const renderIssueCreateModal = () => (
<Modal
isOpen
width={800}
onClose={() => history.push(match.url)}
renderContent={modal => (
<IssueCreateForm project={project} fetchProject={fetchProject} modalClose={modal.close} />
)}
/>
);
return (
<ProjectPage>
<NavbarLeft />
<Sidebar projectName={project.name} matchPath={match.path} />
<Sidebar projectName={project.name} />
<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} />

View File

@@ -17,6 +17,17 @@ const defaultProps = {
size: 32,
};
const Avatar = ({ className, avatarUrl, name, size, ...otherProps }) => {
if (avatarUrl) {
return <Image className={className} size={size} avatarUrl={avatarUrl} {...otherProps} />;
}
return (
<Letter className={className} size={size} color={getColorFromName(name)} {...otherProps}>
<span>{name.charAt(0)}</span>
</Letter>
);
};
const colors = [
'#DA7657',
'#6ADA57',
@@ -30,17 +41,6 @@ const colors = [
const getColorFromName = name => colors[name.toLocaleLowerCase().charCodeAt(0) % colors.length];
const Avatar = ({ className, avatarUrl, name, size, ...otherProps }) => {
if (avatarUrl) {
return <Image className={className} size={size} avatarUrl={avatarUrl} {...otherProps} />;
}
return (
<Letter className={className} size={size} color={getColorFromName(name)} {...otherProps}>
<span>{name.charAt(0)}</span>
</Letter>
);
};
Avatar.propTypes = propTypes;
Avatar.defaultProps = defaultProps;

View File

@@ -1,7 +1,7 @@
import styled, { css } from 'styled-components';
import Spinner from 'shared/components/Spinner';
import { color, font, mixin } from 'shared/utils/styles';
import Spinner from 'shared/components/Spinner';
export const StyledButton = styled.button`
display: inline-flex;
@@ -17,28 +17,29 @@ export const StyledButton = styled.button`
appearance: none;
${mixin.clickable}
${font.size(14.5)}
${props => buttonColors[props.color]}
${props => buttonVariants[props.variant]}
&:disabled {
opacity: 0.6;
cursor: default;
}
i {
margin-right: ${props => (props.iconOnly ? 0 : 7)}px;
}
`;
const colored = css`
color: #fff;
background: ${props => color[props.color]};
background: ${props => color[props.variant]};
${font.medium}
&:not(:disabled) {
&:hover {
background: ${props => mixin.lighten(color[props.color], 0.15)};
background: ${props => mixin.lighten(color[props.variant], 0.15)};
}
&:active {
background: ${props => mixin.darken(color[props.color], 0.1)};
background: ${props => mixin.darken(color[props.variant], 0.1)};
}
${props => props.isActive && `background: ${mixin.darken(color[props.color], 0.1)} !important;`}
${props =>
props.isActive &&
css`
background: ${mixin.darken(color[props.variant], 0.1)} !important;
`}
}
`;
@@ -55,14 +56,14 @@ const secondaryAndEmptyShared = css`
}
${props =>
props.isActive &&
`
color: ${color.primary};
background: ${color.backgroundLightPrimary} !important;
`}
css`
color: ${color.primary};
background: ${color.backgroundLightPrimary} !important;
`}
}
`;
const buttonColors = {
const buttonVariants = {
primary: colored,
success: colored,
danger: colored,
@@ -79,5 +80,8 @@ const buttonColors = {
export const StyledSpinner = styled(Spinner)`
position: relative;
top: 1px;
margin-right: ${props => (props.iconOnly ? 0 : 7)}px;
`;
export const Text = styled.div`
padding-left: ${props => (props.withPadding ? 7 : 0)}px;
`;

View File

@@ -3,81 +3,65 @@ import PropTypes from 'prop-types';
import { color } from 'shared/utils/styles';
import Icon from 'shared/components/Icon';
import { StyledButton, StyledSpinner } from './Styles';
import { StyledButton, StyledSpinner, Text } from './Styles';
const propTypes = {
className: PropTypes.string,
children: PropTypes.node,
color: PropTypes.oneOf(['primary', 'secondary', 'empty', 'success', 'danger']),
variant: PropTypes.oneOf(['primary', 'success', 'danger', 'secondary', 'empty']),
icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
iconSize: PropTypes.number,
disabled: PropTypes.bool,
working: PropTypes.bool,
isWorking: PropTypes.bool,
onClick: PropTypes.func,
};
const defaultProps = {
className: undefined,
children: undefined,
color: 'secondary',
variant: 'secondary',
icon: undefined,
iconSize: 18,
disabled: false,
working: false,
isWorking: false,
onClick: () => {},
};
const Button = forwardRef(
(
{
children,
color: propsColor,
icon,
iconSize,
disabled,
working,
onClick = () => {},
...buttonProps
},
ref,
) => {
({ children, variant, icon, iconSize, disabled, isWorking, onClick, ...buttonProps }, ref) => {
const handleClick = () => {
if (!disabled && !working) {
if (!disabled && !isWorking) {
onClick();
}
};
const renderSpinner = () => (
<StyledSpinner
iconOnly={!children}
size={26}
color={propsColor === 'primary' ? '#fff' : color.textDark}
/>
);
const renderIcon = () => (
<Icon
type={icon}
size={iconSize}
color={propsColor === 'primary' ? '#fff' : color.textDark}
/>
);
return (
<StyledButton
{...buttonProps}
onClick={handleClick}
color={propsColor}
disabled={disabled || working}
working={working}
variant={variant}
disabled={disabled || isWorking}
isWorking={isWorking}
iconOnly={!children}
ref={ref}
>
{working && renderSpinner()}
{!working && icon && (typeof icon !== 'string' ? icon : renderIcon())}
<div>{children}</div>
{isWorking && <StyledSpinner size={26} color={getIconColor(variant)} />}
{!isWorking && icon && typeof icon === 'string' ? (
<Icon type={icon} size={iconSize} color={getIconColor(variant)} />
) : (
icon
)}
{children && <Text withPadding={isWorking || icon}>{children}</Text>}
</StyledButton>
);
},
);
const getIconColor = variant =>
['secondary', 'empty'].includes(variant) ? color.textDark : '#fff';
Button.propTypes = propTypes;
Button.defaultProps = defaultProps;

View File

@@ -1,12 +1,11 @@
import styled from 'styled-components';
import Modal from 'shared/components/Modal';
import Input from 'shared/components/Input';
import Button from 'shared/components/Button';
import { font } from 'shared/utils/styles';
import Modal from 'shared/components/Modal';
import Button from 'shared/components/Button';
export const StyledConfirmModal = styled(Modal)`
padding: 45px 50px 50px;
padding: 35px 40px 40px;
`;
export const Title = styled.div`
@@ -22,17 +21,6 @@ export const Message = styled.p`
${font.size(15)}
`;
export const InputLabel = styled.div`
padding-bottom: 12px;
${font.bold}
${font.size(15)}
`;
export const StyledInput = styled(Input)`
margin-bottom: 25px;
max-width: 220px;
`;
export const Actions = styled.div`
display: flex;
padding-top: 6px;

View File

@@ -1,50 +1,38 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import {
StyledConfirmModal,
Title,
Message,
InputLabel,
StyledInput,
Actions,
StyledButton,
} from './Styles';
import { StyledConfirmModal, Title, Message, Actions, StyledButton } from './Styles';
const propTypes = {
className: PropTypes.string,
variant: PropTypes.oneOf(['primary', 'danger']),
title: PropTypes.string,
message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
confirmText: PropTypes.string,
cancelText: PropTypes.string,
confirmInput: PropTypes.string,
type: PropTypes.oneOf(['primary', 'danger']),
onConfirm: PropTypes.func.isRequired,
renderLink: PropTypes.func.isRequired,
};
const defaultProps = {
className: undefined,
variant: 'primary',
title: 'Warning',
message: 'Are you sure you want to continue with this action?',
confirmText: 'Confirm',
cancelText: 'Cancel',
confirmInput: null,
type: 'primary',
};
const ConfirmModal = ({
className,
variant,
title,
message,
confirmText,
cancelText,
confirmInput,
type,
onConfirm,
renderLink,
}) => {
const [isConfirmEnabled, setConfirmEnabled] = useState(false);
const [isWorking, setWorking] = useState(false);
const handleConfirm = modal => {
@@ -57,31 +45,18 @@ const ConfirmModal = ({
});
};
const handleConfirmInputChange = value =>
setConfirmEnabled(value.trim().toLowerCase() === confirmInput.toLowerCase());
return (
<StyledConfirmModal
suppressClassNameWarning
className={className}
afterClose={() => setConfirmEnabled(false)}
renderLink={renderLink}
renderContent={modal => (
<>
<Title>{title}</Title>
{message && <Message>{message}</Message>}
{confirmInput && (
<>
<InputLabel>{`Type ${confirmInput} below to confirm.`}</InputLabel>
<StyledInput onChange={handleConfirmInputChange} />
<br />
</>
)}
<Actions>
<StyledButton
color={type}
disabled={confirmInput && !isConfirmEnabled}
working={isWorking}
variant={variant}
isWorking={isWorking}
onClick={() => handleConfirm(modal)}
>
{confirmText}

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { copyToClipboard } from 'shared/utils/clipboard';
import { copyToClipboard } from 'shared/utils/browser';
import { Button } from 'shared/components';
const CopyLinkButton = ({ ...buttonProps }) => {
@@ -11,6 +11,7 @@ const CopyLinkButton = ({ ...buttonProps }) => {
setTimeout(() => setLinkCopied(false), 2000);
copyToClipboard(window.location.href);
};
return (
<Button icon="link" onClick={handleLinkCopy} {...buttonProps}>
{isLinkCopied ? 'Link Copied' : 'Copy link'}

View File

@@ -5,6 +5,7 @@ import { times, range } from 'lodash';
import { formatDate, formatDateTimeForAPI } from 'shared/utils/dateTime';
import Icon from 'shared/components/Icon';
import {
DateSection,
YearSelect,
@@ -24,11 +25,11 @@ const propTypes = {
const defaultProps = {
withTime: true,
value: null,
value: undefined,
};
const DatePickerDateSection = ({ withTime, value, onChange, setDropdownOpen }) => {
const [selectedMonth, setSelectedMonth] = useState(moment(value || undefined).startOf('month'));
const [selectedMonth, setSelectedMonth] = useState(moment(value).startOf('month'));
const handleYearChange = year => {
setSelectedMonth(moment(selectedMonth).set({ year: Number(year) }));
@@ -53,47 +54,31 @@ const DatePickerDateSection = ({ withTime, value, onChange, setDropdownOpen }) =
}
};
const generateYears = () => times(50, i => ({ label: `${i + 2010}`, value: `${i + 2010}` }));
const generateWeekDayNames = () => moment.weekdaysMin(true);
const generateFillerDaysBeforeMonthStart = () => {
const count = selectedMonth.diff(moment(selectedMonth).startOf('week'), 'days');
return range(count);
};
const generateMonthDays = () =>
times(selectedMonth.daysInMonth()).map(i => moment(selectedMonth).add(i, 'days'));
const generateFillerDaysAfterMonthEnd = () => {
const selectedMonthEnd = moment(selectedMonth).endOf('month');
const weekEnd = moment(selectedMonthEnd).endOf('week');
const count = weekEnd.diff(selectedMonthEnd, 'days');
return range(count);
};
return (
<DateSection>
<SelectedMonthYear>{formatDate(selectedMonth, 'MMM YYYY')}</SelectedMonthYear>
<YearSelect onChange={event => handleYearChange(event.target.value)}>
{[{ label: 'Year', value: '' }, ...generateYears()].map(option => (
{generateYearOptions().map(option => (
<option key={option.label} value={option.value}>
{option.label}
</option>
))}
</YearSelect>
<PrevNextIcons>
<Icon type="arrow-left" onClick={() => handleMonthChange('subtract')} />
<Icon type="arrow-right" onClick={() => handleMonthChange('add')} />
</PrevNextIcons>
<Grid>
{generateWeekDayNames().map(name => (
<DayName key={name}>{name}</DayName>
))}
{generateFillerDaysBeforeMonthStart().map(i => (
{generateFillerDaysBeforeMonthStart(selectedMonth).map(i => (
<Day key={`before-${i}`} isFiller />
))}
{generateMonthDays().map(date => (
{generateMonthDays(selectedMonth).map(date => (
<Day
key={date}
isToday={moment().isSame(date, 'day')}
@@ -103,7 +88,7 @@ const DatePickerDateSection = ({ withTime, value, onChange, setDropdownOpen }) =
{formatDate(date, 'D')}
</Day>
))}
{generateFillerDaysAfterMonthEnd().map(i => (
{generateFillerDaysAfterMonthEnd(selectedMonth).map(i => (
<Day key={`after-${i}`} isFiller />
))}
</Grid>
@@ -111,6 +96,30 @@ const DatePickerDateSection = ({ withTime, value, onChange, setDropdownOpen }) =
);
};
const currentYear = moment().year();
const generateYearOptions = () => [
{ label: 'Year', value: '' },
...times(50, i => ({ label: `${i + currentYear - 10}`, value: `${i + currentYear - 10}` })),
];
const generateWeekDayNames = () => moment.weekdaysMin(true);
const generateFillerDaysBeforeMonthStart = selectedMonth => {
const count = selectedMonth.diff(moment(selectedMonth).startOf('week'), 'days');
return range(count);
};
const generateMonthDays = selectedMonth =>
times(selectedMonth.daysInMonth()).map(i => moment(selectedMonth).add(i, 'days'));
const generateFillerDaysAfterMonthEnd = selectedMonth => {
const selectedMonthEnd = moment(selectedMonth).endOf('month');
const weekEnd = moment(selectedMonthEnd).endOf('week');
const count = weekEnd.diff(selectedMonthEnd, 'days');
return range(count);
};
DatePickerDateSection.propTypes = propTypes;
DatePickerDateSection.defaultProps = defaultProps;

View File

@@ -1,4 +1,4 @@
import styled from 'styled-components';
import styled, { css } from 'styled-components';
import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
@@ -15,12 +15,12 @@ export const Dropdown = styled.div`
border-radius: 3px;
background: #fff;
${mixin.boxShadowDropdown}
${props => (props.withTime ? withTimeStyles : '')}
`;
const withTimeStyles = `
width: 360px;
padding-right: 90px;
${props =>
props.withTime &&
css`
width: 360px;
padding-right: 90px;
`}
`;
export const DateSection = styled.div`
@@ -78,9 +78,9 @@ export const Day = styled.div`
line-height: 30px;
border-radius: 3px;
${font.size(15)}
${props => (!props.isFiller ? hoverStyles : '')}
${props => (props.isToday ? font.bold : '')}
${props => (props.isSelected ? selectedStyles : '')}
${props => !props.isFiller && hoverStyles}
${props => props.isToday && font.bold}
${props => props.isSelected && selectedStyles}
`;
export const TimeSection = styled.div`
@@ -97,20 +97,21 @@ export const TimeSection = styled.div`
export const Time = styled.div`
padding: 5px 0 5px 20px;
${font.size(14)}
${props => (!props.isFiller ? hoverStyles : '')}
${props => (props.isSelected ? selectedStyles : '')}
${props => !props.isFiller && hoverStyles}
${props => props.isSelected && selectedStyles}
`;
const hoverStyles = `
const hoverStyles = css`
${mixin.clickable}
&:hover {
background: ${color.backgroundMedium};
}
`;
const selectedStyles = `
const selectedStyles = css`
color: #fff;
&:hover, & {
&:hover,
& {
background: ${color.primary};
}
`;

View File

@@ -4,6 +4,7 @@ import moment from 'moment';
import { range } from 'lodash';
import { formatDate, formatDateTimeForAPI } from 'shared/utils/dateTime';
import { TimeSection, Time } from './Styles';
const propTypes = {
@@ -13,32 +14,20 @@ const propTypes = {
};
const defaultProps = {
value: null,
value: undefined,
};
const DatePickerTimeSection = ({ value, onChange, setDropdownOpen }) => {
const $sectionRef = useRef();
const formattedTimeValue = formatDate(value, 'HH:mm');
useLayoutEffect(() => {
const scrollToSelectedTime = () => {
if (!$sectionRef.current) return;
const $selectedTime = $sectionRef.current.querySelector(
`[data-time="${formattedTimeValue}"]`,
);
if (!$selectedTime) return;
$sectionRef.current.scrollTop = $selectedTime.offsetTop - 80;
};
scrollToSelectedTime();
}, [formattedTimeValue]);
scrollToSelectedTime($sectionRef.current, value);
}, [value]);
const handleTimeChange = newTime => {
const [newHour, newMinute] = newTime.split(':');
const existingDate = moment(value || undefined);
const existingDateWithNewTime = existingDate.set({
const existingDateWithNewTime = moment(value).set({
hour: Number(newHour),
minute: Number(newMinute),
});
@@ -46,21 +35,13 @@ const DatePickerTimeSection = ({ value, onChange, setDropdownOpen }) => {
setDropdownOpen(false);
};
const generateTimes = () =>
range(48).map(i => {
const hour = `${Math.floor(i / 2)}`;
const paddedHour = hour.length < 2 ? `0${hour}` : hour;
const minute = i % 2 === 0 ? '00' : '30';
return `${paddedHour}:${minute}`;
});
return (
<TimeSection ref={$sectionRef}>
{generateTimes().map(time => (
<Time
key={time}
data-time={time}
isSelected={time === formattedTimeValue}
isSelected={time === formatTime(value)}
onClick={() => handleTimeChange(time)}
>
{time}
@@ -70,6 +51,25 @@ const DatePickerTimeSection = ({ value, onChange, setDropdownOpen }) => {
);
};
const formatTime = value => formatDate(value, 'HH:mm');
const scrollToSelectedTime = ($scrollCont, value) => {
if (!$scrollCont) return;
const $selectedTime = $scrollCont.querySelector(`[data-time="${formatTime(value)}"]`);
if (!$selectedTime) return;
$scrollCont.scrollTop = $selectedTime.offsetTop - 80;
};
const generateTimes = () =>
range(48).map(i => {
const hour = `${Math.floor(i / 2)}`;
const paddedHour = hour.length < 2 ? `0${hour}` : hour;
const minute = i % 2 === 0 ? '00' : '30';
return `${paddedHour}:${minute}`;
});
DatePickerTimeSection.propTypes = propTypes;
DatePickerTimeSection.defaultProps = defaultProps;

View File

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import { formatDate, formatDateTime } from 'shared/utils/dateTime';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import Input from 'shared/components/Input';
import DateSection from './DateSection';
import TimeSection from './TimeSection';
import { StyledDatePicker, Dropdown } from './Styles';
@@ -18,7 +19,7 @@ const propTypes = {
const defaultProps = {
className: undefined,
withTime: true,
value: null,
value: undefined,
};
const DatePicker = ({ className, withTime, value, onChange, ...inputProps }) => {
@@ -27,11 +28,6 @@ const DatePicker = ({ className, withTime, value, onChange, ...inputProps }) =>
useOnOutsideClick($containerRef, isDropdownOpen, () => setDropdownOpen(false));
const formatValueForInput = () => {
if (!value) return '';
return withTime ? formatDateTime(value) : formatDate(value);
};
return (
<StyledDatePicker ref={$containerRef}>
<Input
@@ -39,7 +35,7 @@ const DatePicker = ({ className, withTime, value, onChange, ...inputProps }) =>
{...inputProps}
className={className}
autoComplete="off"
value={formatValueForInput()}
value={getFormattedInputValue(value, withTime)}
onClick={() => setDropdownOpen(true)}
/>
{isDropdownOpen && (
@@ -59,6 +55,11 @@ const DatePicker = ({ className, withTime, value, onChange, ...inputProps }) =>
);
};
const getFormattedInputValue = (value, withTime) => {
if (!value) return '';
return withTime ? formatDateTime(value) : formatDate(value);
};
DatePicker.propTypes = propTypes;
DatePicker.defaultProps = defaultProps;

View File

@@ -7,6 +7,7 @@ import Select from 'shared/components/Select';
import Textarea from 'shared/components/Textarea';
import TextEditor from 'shared/components/TextEditor';
import DatePicker from 'shared/components/DatePicker';
import { StyledField, FieldLabel, FieldTip, FieldError } from './Styles';
const propTypes = {
@@ -24,12 +25,12 @@ const defaultProps = {
};
const generateField = FormComponent => {
const FieldComponent = ({ className, label, tip, error, ...props }) => {
const FieldComponent = ({ className, label, tip, error, ...otherProps }) => {
const fieldId = uniqueId('form-field-');
return (
<StyledField className={className} hasLabel={!!label}>
{label && <FieldLabel htmlFor={fieldId}>{label}</FieldLabel>}
<FormComponent id={fieldId} invalid={!!error} {...props} />
<FormComponent id={fieldId} invalid={!!error} {...otherProps} />
{tip && <FieldTip>{tip}</FieldTip>}
{error && <FieldError>{error}</FieldError>}
</StyledField>

View File

@@ -4,6 +4,7 @@ import { Formik, Form as FormikForm, Field as FormikField } from 'formik';
import { get, mapValues } from 'lodash';
import { is, generateErrors } from 'shared/utils/validation';
import Field from './Field';
const propTypes = {

View File

@@ -1,6 +1,6 @@
import styled from 'styled-components';
export default styled.i`
export const StyledIcon = styled.i`
display: inline-block;
font-size: ${props => `${props.size}px`};
${props =>

View File

@@ -1,9 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import StyledIcon from './Styles';
import { StyledIcon } from './Styles';
const codes = {
const fontIconCodes = {
[`bug`]: '\\e90f',
[`stopwatch`]: '\\e914',
[`task`]: '\\e910',
@@ -33,11 +33,14 @@ const codes = {
[`component`]: '\\e91a',
[`reports`]: '\\e91b',
[`page`]: '\\e919',
[`calendar`]: '\\e91d',
[`arrow-left`]: '\\e91e',
[`arrow-right`]: '\\e91f',
};
const propTypes = {
type: PropTypes.oneOf(Object.keys(codes)).isRequired,
className: PropTypes.string,
type: PropTypes.oneOf(Object.keys(fontIconCodes)).isRequired,
size: PropTypes.number,
left: PropTypes.number,
top: PropTypes.number,
@@ -50,7 +53,7 @@ const defaultProps = {
top: 0,
};
const Icon = ({ type, ...iconProps }) => <StyledIcon {...iconProps} code={codes[type]} />;
const Icon = ({ type, ...iconProps }) => <StyledIcon {...iconProps} code={fontIconCodes[type]} />;
Icon.propTypes = propTypes;
Icon.defaultProps = defaultProps;

View File

@@ -1,46 +1,49 @@
import styled, { css } from 'styled-components';
import { color, font } from 'shared/utils/styles';
import Icon from 'shared/components/Icon';
export default styled.div`
export const StyledInput = styled.div`
position: relative;
display: inline-block;
height: 32px;
width: 100%;
input {
height: 100%;
width: 100%;
padding: 0 7px;
border-radius: 3px;
border: 1px solid ${color.borderLightest};
background: ${color.backgroundLightest};
transition: background 0.1s;
${font.regular}
${font.size(15)}
&:hover {
background: ${color.backgroundLight};
}
&:focus {
background: #fff;
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
}
${props => props.icon && 'padding-left: 32px;'}
${props =>
props.invalid &&
css`
&,
&:focus {
border: 1px solid ${color.danger};
box-shadow: none;
}
`}
}
i {
position: absolute;
top: 8px;
left: 8px;
pointer-events: none;
color: ${color.textMedium};
}
`;
export const InputElement = styled.input`
height: 100%;
width: 100%;
padding: 0 7px;
border-radius: 3px;
border: 1px solid ${color.borderLightest};
background: ${color.backgroundLightest};
transition: background 0.1s;
${font.regular}
${font.size(15)}
${props => props.hasIcon && 'padding-left: 32px;'}
&:hover {
background: ${color.backgroundLight};
}
&:focus {
background: #fff;
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
}
${props =>
props.invalid &&
css`
&,
&:focus {
border: 1px solid ${color.danger};
box-shadow: none;
}
`}
`;
export const StyledIcon = styled(Icon)`
position: absolute;
top: 8px;
left: 8px;
pointer-events: none;
color: ${color.textMedium};
`;

View File

@@ -1,37 +1,37 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import Icon from 'shared/components/Icon';
import StyledInput from './Styles';
import { StyledInput, InputElement, StyledIcon } from './Styles';
const propTypes = {
icon: PropTypes.string,
className: PropTypes.string,
invalid: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
icon: PropTypes.string,
invalid: PropTypes.bool,
filter: PropTypes.instanceOf(RegExp),
onChange: PropTypes.func,
};
const defaultProps = {
icon: undefined,
className: undefined,
invalid: false,
value: undefined,
icon: undefined,
invalid: false,
filter: undefined,
onChange: () => {},
};
const Input = forwardRef(({ icon, className, invalid, filter, onChange, ...inputProps }, ref) => {
const Input = forwardRef(({ icon, className, filter, onChange, ...inputProps }, ref) => {
const handleChange = event => {
if (!filter || filter.test(event.target.value)) {
onChange(event.target.value, event);
}
};
return (
<StyledInput className={className} icon={icon} invalid={invalid}>
{icon && <Icon type={icon} size={15} />}
<input {...inputProps} onChange={handleChange} ref={ref} />
<StyledInput className={className}>
{icon && <StyledIcon type={icon} size={15} />}
<InputElement {...inputProps} onChange={handleChange} hasIcon={!!icon} ref={ref} />
</StyledInput>
);
});

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
import { Icon } from 'shared/components';
import { issuePriorityColors } from 'shared/utils/styles';
import { Icon } from 'shared/components';
export const PriorityIcon = styled(Icon)`
font-size: 18px;

View File

@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { IssuePriority } from 'shared/constants/issues';
import { PriorityIcon } from './Styles';
const propTypes = {

View File

@@ -1,7 +1,7 @@
import styled, { css } from 'styled-components';
import Icon from 'shared/components/Icon';
import { color, mixin, zIndexValues } from 'shared/utils/styles';
import Icon from 'shared/components/Icon';
export const ScrollOverlay = styled.div`
z-index: ${zIndexValues.modal};

View File

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import { ScrollOverlay, ClickableOverlay, StyledModal, CloseIcon } from './Styles';
const propTypes = {

View File

@@ -1,7 +1,8 @@
import styled from 'styled-components';
import { Icon } from 'shared/components';
import { color, font, mixin } from 'shared/utils/styles';
import { Icon } from 'shared/components';
import imageBackground from './assets/background-forest.jpg';
export const ErrorPage = styled.div`

View File

@@ -1,6 +1,7 @@
import React from 'react';
import Spinner from 'shared/components/Spinner';
import StyledPageLoader from './Styles';
const PageLoader = () => (

View File

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { uniq } from 'lodash';
import { KeyCodes } from 'shared/constants/keyCodes';
import { ClearIcon, Dropdown, DropdownInput, Options, Option, OptionsNoResults } from './Styles';
const propTypes = {
@@ -86,6 +87,7 @@ const SelectDropdown = ({
const handleInputEnterKeyDown = event => {
event.preventDefault();
const $active = getActiveOptionNode();
if (!$active) return;
@@ -156,25 +158,20 @@ const SelectDropdown = ({
? removeSelectedOptionsMulti(optionsFilteredBySearchValue)
: removeSelectedOptionsSingle(optionsFilteredBySearchValue);
const searchValueNotInOptions = !options.map(option => option.label).includes(searchValue);
const isOptionCreatable = onCreate && searchValue && searchValueNotInOptions;
const isSearchValueInOptions = options.map(option => option.label).includes(searchValue);
const isOptionCreatable = onCreate && searchValue && !isSearchValueInOptions;
const renderSelectableOption = (option, i) => {
const optionProps = {
key: option.value,
value: option.value,
label: option.label,
className: i === 0 ? activeOptionClass : undefined,
'data-select-option-value': option.value,
onMouseEnter: handleOptionMouseEnter,
onClick: () => selectOptionValue(option.value),
};
return (
<Option {...optionProps}>
{propsRenderOption ? propsRenderOption(option) : option.label}
</Option>
);
};
const renderSelectableOption = (option, i) => (
<Option
key={option.value}
className={i === 0 ? activeOptionClass : undefined}
data-select-option-value={option.value}
onMouseEnter={handleOptionMouseEnter}
onClick={() => selectOptionValue(option.value)}
>
{propsRenderOption ? propsRenderOption(option) : option.label}
</Option>
);
const renderCreatableOption = () => (
<Option

View File

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { KeyCodes } from 'shared/constants/keyCodes';
import Icon from 'shared/components/Icon';
import Dropdown from './Dropdown';
import {
StyledSelect,

View File

@@ -1,11 +1,10 @@
/* eslint-disable react/no-danger */
import React from 'react';
import PropTypes from 'prop-types';
import 'quill/dist/quill.snow.css';
import { Content } from './Styles';
import('quill/dist/quill.snow.css');
const propTypes = {
content: PropTypes.string.isRequired,
};

View File

@@ -1,11 +1,10 @@
import React, { useLayoutEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
import { EditorCont } from './Styles';
import('quill/dist/quill.snow.css');
const propTypes = {
className: PropTypes.string,
placeholder: PropTypes.string,
@@ -37,29 +36,26 @@ const TextEditor = ({
}) => {
const $editorContRef = useRef();
const $editorRef = useRef();
const quillRef = useRef();
const initialValueRef = useRef(defaultValue || alsoDefaultValue || '');
useLayoutEffect(() => {
const setupQuill = () => {
quillRef.current = new Quill($editorRef.current, { placeholder, ...quillConfig });
};
let quill = new Quill($editorRef.current, { placeholder, ...quillConfig });
const insertInitialValue = () => {
quillRef.current.clipboard.dangerouslyPasteHTML(0, initialValueRef.current);
quill.clipboard.dangerouslyPasteHTML(0, initialValueRef.current);
};
const handleContentsChange = () => {
onChange(getHTMLValue());
};
const getHTMLValue = () => $editorContRef.current.querySelector('.ql-editor').innerHTML;
setupQuill();
insertInitialValue();
getEditor({ getValue: getHTMLValue });
quillRef.current.on('text-change', handleContentsChange);
quill.on('text-change', handleContentsChange);
return () => {
quillRef.current.off('text-change', handleContentsChange);
quillRef.current = null;
quill.off('text-change', handleContentsChange);
quill = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View File

@@ -1,8 +1,8 @@
import styled from 'styled-components';
import styled, { css } from 'styled-components';
import { color, font } from 'shared/utils/styles';
export default styled.div`
export const StyledTextarea = styled.div`
display: inline-block;
width: 100%;
textarea {
@@ -19,6 +19,13 @@ export default styled.div`
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
}
${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
${props =>
props.invalid &&
css`
&,
&:focus {
border: 1px solid ${color.danger};
}
`}
}
`;

View File

@@ -2,7 +2,7 @@ import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import TextareaAutoSize from 'react-textarea-autosize';
import StyledTextarea from './Styles';
import { StyledTextarea } from './Styles';
const propTypes = {
className: PropTypes.string,

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { StyledTooltip } from './Styles';
const propTypes = {

View File

@@ -1,90 +0,0 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import api from 'shared/utils/api';
import useDeepCompareMemoize from './deepCompareMemoize';
const useApi = (method, url, variables = {}, { lazy = false } = {}) => {
const isCalledAutomatically = method === 'get' && !lazy;
const [state, setState] = useState({
data: null,
error: null,
isWorking: isCalledAutomatically,
additionalVariables: {},
});
const setStateMerge = newState => setState(currentState => ({ ...currentState, ...newState }));
const wasCalledRef = useRef(false);
const variablesMemoized = useDeepCompareMemoize(variables);
const stateRef = useRef();
stateRef.current = state;
const makeRequest = useCallback(
(newVariables = {}) =>
new Promise((resolve, reject) => {
const additionalVariables = { ...stateRef.current.additionalVariables, ...newVariables };
if (!isCalledAutomatically || wasCalledRef.current) {
setStateMerge({ additionalVariables, isWorking: true });
}
api[method](url, { ...variablesMemoized, ...additionalVariables }).then(
data => {
resolve(data);
setStateMerge({ data, error: null, isWorking: false });
},
error => {
reject(error);
setStateMerge({ error, data: null, isWorking: false });
},
);
wasCalledRef.current = true;
}),
[method, variablesMemoized, isCalledAutomatically, url],
);
useEffect(() => {
if (isCalledAutomatically) {
makeRequest();
}
}, [makeRequest, isCalledAutomatically]);
const setLocalData = useCallback(
getUpdatedData =>
setState(currentState => ({ ...currentState, data: getUpdatedData(currentState.data) })),
[],
);
const result = [
{
...state,
[isWorkingAlias[method]]: state.isWorking,
wasCalled: wasCalledRef.current,
variables: { ...variablesMemoized, ...state.additionalVariables },
setLocalData,
},
makeRequest,
];
return result;
};
const isWorkingAlias = {
get: 'isLoading',
post: 'isCreating',
put: 'isUpdating',
patch: 'isUpdating',
delete: 'isDeleting',
};
/* eslint-disable react-hooks/rules-of-hooks */
export default {
get: (...args) => useApi('get', ...args),
post: (...args) => useApi('post', ...args),
put: (...args) => useApi('put', ...args),
patch: (...args) => useApi('patch', ...args),
delete: (...args) => useApi('delete', ...args),
};

View File

@@ -0,0 +1,11 @@
import useQuery from './query';
import useMutation from './mutation';
/* eslint-disable react-hooks/rules-of-hooks */
export default {
get: (...args) => useQuery(...args),
post: (...args) => useMutation('post', ...args),
put: (...args) => useMutation('put', ...args),
patch: (...args) => useMutation('patch', ...args),
delete: (...args) => useMutation('delete', ...args),
};

View File

@@ -0,0 +1,42 @@
import { useCallback } from 'react';
import api from 'shared/utils/api';
import useMergeState from 'shared/hooks/mergeState';
const useMutation = (method, url) => {
const [state, mergeState] = useMergeState({
data: null,
error: null,
isWorking: false,
});
const makeRequest = useCallback(
(variables = {}) =>
new Promise((resolve, reject) => {
mergeState({ isWorking: true });
api[method](url, variables).then(
data => {
resolve(data);
mergeState({ data, error: null, isWorking: false });
},
error => {
reject(error);
mergeState({ error, data: null, isWorking: false });
},
);
}),
[method, url, mergeState],
);
return [{ ...state, [isWorkingAlias[method]]: state.isWorking }, makeRequest];
};
const isWorkingAlias = {
post: 'isCreating',
put: 'isUpdating',
patch: 'isUpdating',
delete: 'isDeleting',
};
export default useMutation;

View File

@@ -0,0 +1,92 @@
import { useRef, useCallback, useEffect } from 'react';
import { isEqual } from 'lodash';
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 [state, mergeState] = useMergeState({
data: null,
error: null,
isLoading: !lazy,
wasCalled: !lazy,
variables: {},
});
const wasCalledRef = useRef(false);
const propsVariablesMemoized = useDeepCompareMemoize(propsVariables);
const stateRef = useRef();
stateRef.current = state;
const makeRequest = useCallback(
(newVariables = {}) => {
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;
if (isCacheAvailableAndPermitted) {
mergeState({ data: cache[url].data, error: null, isLoading: false, variables });
if (cachePolicy === CachePolicy.CACHE_ONLY) {
return;
}
}
if (!isCacheAvailableAndPermitted && (lazy || wasCalledRef.current)) {
mergeState({ isLoading: true, variables });
}
api.get(url, apiVariables).then(
data => {
cache[url] = { apiVariables, data };
mergeState({ data, error: null, isLoading: false });
},
error => {
mergeState({ error, data: null, isLoading: false });
},
);
wasCalledRef.current = true;
},
[propsVariablesMemoized, cachePolicy, url, lazy, mergeState],
);
useEffect(() => {
if (!lazy || wasCalledRef.current) {
makeRequest();
}
}, [lazy, makeRequest]);
const setLocalData = useCallback(
getUpdatedData => mergeState(({ data }) => ({ data: getUpdatedData(data) })),
[mergeState],
);
return [
{
...state,
wasCalled: wasCalledRef.current,
variables: { ...propsVariablesMemoized, ...state.variables },
setLocalData,
},
makeRequest,
];
};
const cache = {};
const CachePolicy = {
CACHE_ONLY: 'cache-only',
CACHE_FIRST: 'cache-first',
NO_CACHE: 'no-cache',
};
export default useQuery;

View File

@@ -0,0 +1,18 @@
import { useState, useCallback } from 'react';
import { isFunction } from 'lodash';
const useMergeState = initialState => {
const [state, setState] = useState(initialState || {});
const mergeState = useCallback(newState => {
if (isFunction(newState)) {
setState(currentState => ({ ...currentState, ...newState(currentState) }));
} else {
setState(currentState => ({ ...currentState, ...newState }));
}
}, []);
return [state, mergeState];
};
export default useMergeState;

View File

@@ -9,9 +9,11 @@ const useOnEscapeKeyDown = (isListening, onEscapeKeyDown) => {
onEscapeKeyDown();
}
};
if (isListening) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};

View File

@@ -6,7 +6,7 @@ const useOnOutsideClick = (
$ignoredElementRefs,
shouldListen,
onOutsideClick,
$listeningElementRef = {},
$listeningElementRef,
) => {
const $mouseDownTargetRef = useRef();
const $ignoredElementRefsMemoized = useDeepCompareMemoize([$ignoredElementRefs].flat());
@@ -15,21 +15,25 @@ const useOnOutsideClick = (
const handleMouseDown = event => {
$mouseDownTargetRef.current = event.target;
};
const handleMouseUp = event => {
const noIgnoredElementsContainTarget = $ignoredElementRefsMemoized.every(
const isAnyIgnoredElementParentOfTarget = $ignoredElementRefsMemoized.some(
$elementRef =>
!$elementRef.current.contains($mouseDownTargetRef.current) &&
!$elementRef.current.contains(event.target),
$elementRef.current.contains($mouseDownTargetRef.current) ||
$elementRef.current.contains(event.target),
);
if (event.button === 0 && noIgnoredElementsContainTarget) {
if (event.button === 0 && !isAnyIgnoredElementParentOfTarget) {
onOutsideClick();
}
};
const $listeningElement = $listeningElementRef.current || document;
const $listeningElement = ($listeningElementRef || {}).current || document;
if (shouldListen) {
$listeningElement.addEventListener('mousedown', handleMouseDown);
$listeningElement.addEventListener('mouseup', handleMouseUp);
}
return () => {
$listeningElement.removeEventListener('mousedown', handleMouseDown);
$listeningElement.removeEventListener('mouseup', handleMouseUp);

View File

@@ -1,3 +1,5 @@
export const getStoredAuthToken = () => localStorage.getItem('authToken');
export const storeAuthToken = token => localStorage.setItem('authToken', token);
export const removeStoredAuthToken = () => localStorage.removeItem('authToken');

View File

@@ -0,0 +1,18 @@
export const getTextContentsFromHtmlString = html => {
const el = document.createElement('div');
el.innerHTML = html;
return el.textContent;
};
export const copyToClipboard = value => {
const $textarea = document.createElement('textarea');
$textarea.value = value;
document.body.appendChild($textarea);
$textarea.select();
document.execCommand('copy');
document.body.removeChild($textarea);
};
export const isFocusedElementEditable = () =>
!!document.activeElement.getAttribute('contenteditable') ||
['TEXTAREA', 'INPUT'].includes(document.activeElement.tagName);

View File

@@ -1,8 +0,0 @@
export const copyToClipboard = value => {
const $textarea = document.createElement('textarea');
$textarea.value = value;
document.body.appendChild($textarea);
$textarea.select();
document.execCommand('copy');
document.body.removeChild($textarea);
};

View File

@@ -1,3 +0,0 @@
export const isFocusedElementEditable = () =>
!!document.activeElement.getAttribute('contenteditable') ||
['TEXTAREA', 'INPUT'].includes(document.activeElement.tagName);

View File

@@ -1,5 +0,0 @@
export const getTextContentsFromHtmlString = html => {
const el = document.createElement('div');
el.innerHTML = html;
return el.textContent;
};

View File

@@ -1,4 +1,6 @@
import { css } from 'styled-components';
import Color from 'color';
import { IssueType, IssueStatus, IssuePriority } from 'shared/constants/issues';
export const color = {
@@ -87,43 +89,32 @@ export const mixin = {
Color(colorValue)
.alpha(opacity)
.string(),
boxShadowMedium: `
box-shadow: 0 5px 10px 0 rgba(0,0,0,0.1);
boxShadowMedium: css`
box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.1);
`,
boxShadowDropdown: `
boxShadowDropdown: css`
box-shadow: rgba(9, 30, 66, 0.25) 0px 4px 8px -2px, rgba(9, 30, 66, 0.31) 0px 0px 1px;
`,
truncateText: `
truncateText: css`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`,
clickable: `
clickable: css`
cursor: pointer;
user-select: none;
`,
hardwareAccelerate: `
hardwareAccelerate: css`
transform: translateZ(0);
`,
clearfix: `
*zoom: 1;
&:before,
&:after {
content: " ";
display: table;
}
&:after {
clear: both;
}
`,
cover: `
cover: css`
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
`,
placeholderColor: colorValue => `
placeholderColor: colorValue => css`
::-webkit-input-placeholder {
color: ${colorValue} !important;
opacity: 1 !important;
@@ -141,12 +132,15 @@ export const mixin = {
opacity: 1 !important;
}
`,
scrollableY: `
scrollableY: css`
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
`,
customScrollbar: ({ width = 8, background = mixin.darken(color.backgroundMedium, 0.2) } = {}) => `
customScrollbar: ({
width = 8,
background = mixin.darken(color.backgroundMedium, 0.2),
} = {}) => css`
&::-webkit-scrollbar {
width: ${width}px;
}
@@ -158,14 +152,14 @@ export const mixin = {
background: ${background};
}
`,
backgroundImage: imageURL => `
backgroundImage: imageURL => css`
background-image: url("${imageURL}");
background-position: 50% 50%;
background-repeat: no-repeat;
background-size: cover;
background-color: ${color.backgroundLight};
`,
link: (colorValue = color.textLink) => `
link: (colorValue = color.textLink) => css`
cursor: pointer;
color: ${colorValue};
${font.medium}
@@ -176,7 +170,7 @@ export const mixin = {
text-decoration: underline;
}
`,
tag: (background = color.backgroundMedium, colorValue = color.textDarkest) => `
tag: (background = color.backgroundMedium, colorValue = color.textDarkest) => css`
display: inline-flex;
align-items: center;
height: 24px;

View File

@@ -14,8 +14,4 @@ const error = err => {
});
};
export default {
show,
error,
success,
};
export default { show, error, success };