Implemented kanban board page with lists of issues

This commit is contained in:
ireic
2019-12-12 17:26:57 +01:00
parent 3143f66a0f
commit 73b4ff97b2
73 changed files with 1343 additions and 561 deletions

View File

@@ -1,10 +1,10 @@
import React from 'react';
import Toast from './Toast';
import Routes from './Routes';
import NormalizeStyles from './NormalizeStyles';
import FontStyles from './FontStyles';
import BaseStyles from './BaseStyles';
import Toast from './Toast';
import Routes from './Routes';
const App = () => (
<>

View File

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

View File

@@ -0,0 +1,29 @@
import React, { useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import useApi from 'shared/hooks/api';
import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken';
import { PageLoader } from 'shared/components';
const Authenticate = () => {
const [{ data }, createGuestAccount] = useApi.post('/authentication/guest');
const history = useHistory();
useEffect(() => {
if (!getStoredAuthToken()) {
createGuestAccount();
}
}, [createGuestAccount]);
useEffect(() => {
if (data && data.authToken) {
storeAuthToken(data.authToken);
history.push('/');
}
}, [data, history]);
return <PageLoader />;
};
export default Authenticate;

View File

@@ -85,7 +85,7 @@ export default createGlobalStyle`
}
p {
line-height: 1.6;
line-height: 1.4285;
a {
${mixin.link()}
}
@@ -104,5 +104,5 @@ export default createGlobalStyle`
touch-action: manipulation;
}
${mixin.placeholderColor(color.textLightBlue)}
${mixin.placeholderColor(color.textLight)}
`;

View File

@@ -6,17 +6,17 @@ import Logo from 'shared/components/Logo';
export const NavLeft = styled.aside`
z-index: ${zIndexValues.navLeft};
position: absolute;
position: fixed;
top: 0;
left: 0;
overflow-x: hidden;
height: 100%;
height: 100vh;
width: ${sizes.appNavBarLeftWidth}px;
background: ${color.primary};
background: ${color.backgroundDarkPrimary};
transition: all 0.1s;
${mixin.hardwareAccelerate}
&:hover {
width: 260px;
width: 180px;
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.6);
}
`;
@@ -25,71 +25,43 @@ export const LogoLink = styled(NavLink)`
display: block;
position: relative;
left: 0;
margin: 40px 0 40px;
margin: 20px 0 10px;
transition: left 0.1s;
&:before {
display: inline-block;
content: '';
position: absolute;
top: 0;
right: 0;
height: 50px;
width: 20px;
background: ${color.primary};
}
${NavLeft}:hover & {
left: 3px;
&:before {
display: none;
}
}
`;
export const StyledLogo = styled(Logo)`
display: inline-block;
margin-left: 13px;
margin-left: 8px;
padding: 10px;
${mixin.clickable}
`;
export const IconLink = styled(NavLink)`
display: block;
export const Bottom = styled.div`
position: absolute;
bottom: 20px;
left: 0;
width: 100%;
`;
export const Item = styled.div`
position: relative;
width: 100%;
height: 60px;
line-height: 60px;
height: 42px;
line-height: 42px;
padding-left: 67px;
color: rgba(255, 255, 255, 0.75);
color: #deebff;
transition: color 0.1s;
${mixin.clickable}
&:before {
content: '';
display: none;
position: absolute;
top: 5px;
right: 0;
height: 50px;
width: 5px;
background: #fff;
border-radius: 6px 0 0 6px;
}
&.active,
&:hover {
color: #fff;
}
&.active:before {
display: inline-block;
}
&:hover {
background: rgba(255, 255, 255, 0.1);
}
i {
position: absolute;
left: 27px;
left: 18px;
}
`;
export const LinkText = styled.div`
export const ItemText = styled.div`
position: relative;
right: 12px;
visibility: hidden;

View File

@@ -1,25 +1,27 @@
import React from 'react';
import { Icon } from 'shared/components';
import { NavLeft, LogoLink, StyledLogo, IconLink, LinkText } from './Styles';
import { NavLeft, LogoLink, StyledLogo, Bottom, Item, ItemText } from './Styles';
const NavbarLeft = () => (
<NavLeft>
<LogoLink to="/">
<StyledLogo color="#fff" />
</LogoLink>
<IconLink to="/projects">
<Icon type="archive" size={16} />
<LinkText>Projects</LinkText>
</IconLink>
<IconLink to="/subcontractors">
<Icon type="briefcase" size={16} />
<LinkText>Subcontractors</LinkText>
</IconLink>
<IconLink to="/bids">
<Icon type="file-text" size={20} left={-2} />
<LinkText>Bids</LinkText>
</IconLink>
<Item>
<Icon type="search" size={22} top={1} left={3} />
<ItemText>Search</ItemText>
</Item>
<Item>
<Icon type="plus" size={27} />
<ItemText>Create</ItemText>
</Item>
<Bottom>
<Item>
<Icon type="help" size={25} />
<ItemText>Help</ItemText>
</Item>
</Bottom>
</NavLeft>
);

View File

@@ -1,10 +1,13 @@
import React from 'react';
import { Router, Switch, Route, Redirect } from 'react-router-dom';
import history from 'browserHistory';
import { Router, Switch, Route } from 'react-router-dom';
import PageNotFound from 'components/PageNotFound';
import PageError from 'shared/components/PageError';
import Project from 'components/Project';
import NavbarLeft from './NavbarLeft';
import Authenticate from './Authenticate';
import { Main } from './AppStyles';
const Routes = () => (
@@ -12,7 +15,10 @@ const Routes = () => (
<Main>
<NavbarLeft />
<Switch>
<Route component={PageNotFound} />
<Redirect exact from="/" to="/project" />
<Route path="/authenticate" component={Authenticate} />
<Route path="/project" component={Project} />
<Route component={PageError} />
</Switch>
</Main>
</Router>

View File

@@ -14,7 +14,7 @@ export const StyledToast = styled.div`
margin-bottom: 5px;
width: 300px;
padding: 15px 20px;
border-radius: 4px;
border-radius: 3px;
color: #fff;
background: ${props => color[props.type]};
cursor: pointer;

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React, { useState, useEffect } from 'react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import pubsub from 'sweet-pubsub';
import { uniqueId } from 'lodash';
@@ -6,57 +6,44 @@ import { uniqueId } from 'lodash';
import { Icon } from 'shared/components';
import { Container, StyledToast, Title, Message } from './Styles';
class Toast extends Component {
state = { toasts: [] };
const Toast = () => {
const [toasts, setToasts] = useState([]);
componentDidMount() {
pubsub.on('toast', this.addToast);
}
useEffect(() => {
const addToast = ({ type = 'success', title, message, duration = 5 }) => {
const id = uniqueId();
componentWillUnmount() {
pubsub.off('toast', this.addToast);
}
setToasts(currentToasts => [...currentToasts, { id, type, title, message }]);
addToast = ({ type = 'success', title, message, duration = 5 }) => {
const id = uniqueId('toast-');
if (duration) {
setTimeout(() => removeToast(id), duration * 1000);
}
};
pubsub.on('toast', addToast);
return () => {
pubsub.off('toast', addToast);
};
}, []);
this.setState(state => ({
toasts: [...state.toasts, { id, type, title, message }],
}));
if (duration) {
setTimeout(() => this.removeToast(id), duration * 1000);
}
const removeToast = id => {
setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id));
};
removeToast = id => {
this.setState(state => ({
toasts: state.toasts.filter(toast => toast.id !== id),
}));
};
render() {
const { toasts } = this.state;
return (
<Container>
<TransitionGroup>
{toasts.map(toast => (
<CSSTransition key={toast.id} classNames="jira-toast" timeout={200}>
<StyledToast
key={toast.id}
type={toast.type}
onClick={() => this.removeToast(toast.id)}
>
<Icon type="close" />
{toast.title && <Title>{toast.title}</Title>}
{toast.message && <Message>{toast.message}</Message>}
</StyledToast>
</CSSTransition>
))}
</TransitionGroup>
</Container>
);
}
}
return (
<Container>
<TransitionGroup>
{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" />
{toast.title && <Title>{toast.title}</Title>}
{toast.message && <Message>{toast.message}</Message>}
</StyledToast>
</CSSTransition>
))}
</TransitionGroup>
</Container>
);
};
export default Toast;

View File

@@ -1,23 +0,0 @@
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const Wrapper = styled.div`
margin: 50px auto 0;
max-width: 500px;
padding: 50px 50px 60px;
text-align: center;
border-radius: 4px;
background: ${color.backgroundLight};
`;
export const Heading = styled.h1`
${font.size(60)}
`;
export const Message = styled.p`
color: ${color.textDark};
padding: 10px 0 30px;
line-height: 1.35;
${font.size(20)}
`;

View File

@@ -1,104 +0,0 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Button,
ConfirmModal,
Avatar,
DatePicker,
Input,
Modal,
Select,
Textarea,
Spinner,
} from 'shared/components';
import { Wrapper, Heading, Message } from './Styles';
const PageNotFound = () => {
const [dateValue, setDateValue] = useState(null);
const [inputValue, setInputValue] = useState('');
const [isModalOpen, setModalOpen] = useState(false);
const [selectValue, setSelectValue] = useState('');
const [selectOptions, setSelectOptions] = useState([
{ label: 'one', value: '1' },
{ label: 'two', value: '2' },
{ label: 'three', value: '3' },
{ label: 'four', value: '4' },
{ label: 'five', value: '5' },
{ label: 'six', value: '6' },
{ label: 'seven', value: '7' },
{ label: 'eight', value: '8' },
{ label: 'nine', value: '9' },
{ label: 'ten', value: '10' },
]);
console.log('ha');
return (
<Wrapper>
<Heading>404</Heading>
<Message>We cannot find the page you are looking for.</Message>
<div style={{ textAlign: 'left' }}>
<Avatar name="Ivor Reic" size={40} />
<ConfirmModal
renderLink={modal => <Button onClick={modal.open}>Yo</Button>}
confirmInput="YAY"
onConfirm={modal => {
console.log('CONFIRMED!');
modal.close();
}}
/>
<DatePicker placeholder="Select date" value={dateValue} onChange={setDateValue} />
<Input
placeholder="Write anything mon"
value={inputValue}
onChange={(event, value) => setInputValue(value)}
/>
<Textarea
placeholder="Write anything mon"
value={inputValue}
onChange={(event, value) => setInputValue(value)}
/>
<Button onClick={() => setModalOpen(true)}>OPEN MODAL CONTROLLED</Button>
<Modal
// renderLink={modal => <Button onClick={modal.open}>OPEN MODAL</Button>}
isOpen={isModalOpen}
onClose={() => setModalOpen(false)}
renderContent={modal => (
<>
<h1>Nice modal bro</h1>
<h1>Nice modal bro</h1>
<Button onClick={modal.close}>Close</Button>
<Modal
renderLink={innerModal => <Button onClick={innerModal.open}>Open Modal</Button>}
renderContent={innerModal => (
<>
<h1>Nice innerModal bro</h1>
<Button onClick={innerModal.close}>Close</Button>
</>
)}
/>
</>
)}
/>
<Select
isMulti
value={selectValue}
onChange={setSelectValue}
placeholder="Type to search"
onCreate={(newOptionName, selectOptionValue) => {
setTimeout(() => {
setSelectOptions([...selectOptions, { label: newOptionName, value: newOptionName }]);
selectOptionValue(newOptionName);
}, 1000);
}}
options={selectOptions}
/>
<Spinner />
</div>
<Link to="/">
<Button>Home</Button>
</Link>
</Wrapper>
);
};
export default PageNotFound;

View File

@@ -0,0 +1,55 @@
import styled from 'styled-components';
import { Input, Avatar, Button } from 'shared/components';
import { color, font, mixin } from 'shared/utils/styles';
export const Filters = styled.div`
display: flex;
align-items: center;
margin-top: 24px;
`;
export const SearchInput = styled(Input)`
margin-right: 18px;
width: 160px;
`;
export const Avatars = styled.div`
display: flex;
flex-direction: row-reverse;
margin: 0 12px 0 2px;
`;
export const AvatarIsActiveBorder = styled.div`
display: inline-flex;
margin-left: -2px;
border-radius: 50%;
transition: transform 0.1s;
${mixin.clickable};
${props => props.isActive && `box-shadow: 0 0 0 4px ${color.primary}`}
&:hover {
transform: translateY(-5px);
}
`;
export const StyledAvatar = styled(Avatar)`
box-shadow: 0 0 0 2px #fff;
`;
export const StyledButton = styled(Button)`
margin-left: 6px;
`;
export const ClearAll = styled.div`
height: 32px;
line-height: 32px;
margin-left: 15px;
padding-left: 12px;
border-left: 1px solid ${color.borderLightest};
color: ${color.textDark};
${font.size(14.5)}
${mixin.clickable}
&:hover {
color: ${color.textMedium};
}
`;

View File

@@ -0,0 +1,91 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import { intersection, xor } from 'lodash';
import useDebounceValue from 'shared/hooks/debounceValue';
import {
Filters,
SearchInput,
Avatars,
AvatarIsActiveBorder,
StyledAvatar,
StyledButton,
ClearAll,
} from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
currentUser: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
};
const ProjectBoardFilters = ({ project, currentUser, onChange }) => {
const [searchQuery, setSearchQuery] = useState('');
const [userIds, setUserIds] = useState([]);
const [myOnly, setMyOnly] = useState(false);
const [recent, setRecent] = useState(false);
const debouncedSearchQuery = useDebounceValue(searchQuery, 500);
const clearFilters = () => {
setSearchQuery('');
setUserIds([]);
setMyOnly(false);
setRecent(false);
};
const areFiltersCleared = !searchQuery && userIds.length === 0 && !myOnly && !recent;
useEffect(() => {
const getFilteredIssues = () => {
let { issues } = project;
if (debouncedSearchQuery) {
issues = issues.filter(issue =>
issue.title.toLowerCase().includes(debouncedSearchQuery.toLowerCase()),
);
}
if (userIds.length > 0) {
issues = issues.filter(issue => intersection(issue.userIds, userIds).length > 0);
}
if (myOnly) {
issues = issues.filter(issue => issue.userIds.includes(currentUser.id));
}
if (recent) {
issues = issues.filter(issue =>
moment(issue.updatedAt).isAfter(moment().subtract(3, 'days')),
);
}
return issues;
};
onChange(getFilteredIssues());
}, [project, currentUser, onChange, debouncedSearchQuery, userIds, myOnly, recent]);
return (
<Filters>
<SearchInput icon="search" value={searchQuery} onChange={setSearchQuery} />
<Avatars>
{project.users.map(user => (
<AvatarIsActiveBorder key={user.id} isActive={userIds.includes(user.id)}>
<StyledAvatar
avatarUrl={user.avatarUrl}
name={user.name}
onClick={() => setUserIds(value => xor(value, [user.id]))}
/>
</AvatarIsActiveBorder>
))}
</Avatars>
<StyledButton color="empty" isActive={myOnly} onClick={() => setMyOnly(!myOnly)}>
Only My Issues
</StyledButton>
<StyledButton color="empty" isActive={recent} onClick={() => setRecent(!recent)}>
Recently Updated
</StyledButton>
{!areFiltersCleared && <ClearAll onClick={clearFilters}>Clear all</ClearAll>}
</Filters>
);
};
ProjectBoardFilters.propTypes = propTypes;
export default ProjectBoardFilters;

View File

@@ -0,0 +1,26 @@
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const Breadcrumbs = styled.div`
color: ${color.textMedium};
${font.size(15)};
`;
export const Divider = styled.span`
position: relative;
top: 2px;
margin: 0 10px;
${font.size(18)};
`;
export const Header = styled.div`
margin-top: 6px;
display: flex;
justify-content: space-between;
`;
export const BoardName = styled.div`
${font.size(24)}
${font.medium}
`;

View File

@@ -0,0 +1,42 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { copyToClipboard } from 'shared/utils/clipboard';
import { Button } from 'shared/components';
import { Breadcrumbs, Divider, Header, BoardName } from './Styles';
const propTypes = {
projectName: PropTypes.string.isRequired,
};
const ProjectBoardHeader = ({ projectName }) => {
const [isLinkCopied, setLinkCopied] = useState(false);
return (
<>
<Breadcrumbs>
Projects
<Divider>/</Divider>
{projectName}
<Divider>/</Divider>
Kanban Board
</Breadcrumbs>
<Header>
<BoardName>Kanban board</BoardName>
<Button
icon="link"
onClick={() => {
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
copyToClipboard(window.location.href);
}}
>
{isLinkCopied ? 'Link Copied' : 'Copy link'}
</Button>
</Header>
</>
);
};
ProjectBoardHeader.propTypes = propTypes;
export default ProjectBoardHeader;

View File

@@ -0,0 +1,80 @@
import styled from 'styled-components';
import { Avatar, Icon } from 'shared/components';
import { color, issueTypeColors, issuePriorityColors, font, mixin } from 'shared/utils/styles';
export const Lists = styled.div`
display: flex;
margin: 26px -5px 0;
`;
export const List = styled.div`
margin: 0 5px;
width: 25%;
border-radius: 3px;
background: ${color.backgroundLightest};
`;
export const ListTitle = styled.div`
padding: 13px 10px 17px;
text-transform: uppercase;
color: ${color.textMedium};
${font.size(12.5)};
`;
export const ListIssuesCount = styled.span`
text-transform: lowercase;
${font.size(13)};
`;
export const Issues = styled.div`
padding: 0 5px;
`;
export const Issue = styled.div`
margin-bottom: 5px;
padding: 10px;
border-radius: 3px;
background: #fff;
box-shadow: 0px 1px 2px 0px rgba(9, 30, 66, 0.25);
transition: background 0.1s;
${mixin.clickable}
&:hover {
background: ${color.backgroundLight};
}
`;
export const IssueTitle = styled.p`
padding-bottom: 11px;
${font.size(15)}
`;
export const IssueBottom = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
export const IssueTypeIcon = styled(Icon)`
font-size: 19px;
color: ${props => issueTypeColors[props.color]};
`;
export const IssuePriorityIcon = styled(Icon)`
position: relative;
top: -1px;
margin-left: 4px;
font-size: 18px;
color: ${props => issuePriorityColors[props.color]};
`;
export const IssueAssignees = styled.div`
display: flex;
flex-direction: row-reverse;
margin-left: 2px;
`;
export const IssueAssigneeAvatar = styled(Avatar)`
margin-left: -2px;
box-shadow: 0 0 0 2px #fff;
`;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IssueStatus, IssuePriority } from 'shared/constants/issues';
import {
Lists,
List,
ListTitle,
ListIssuesCount,
Issues,
Issue,
IssueTitle,
IssueBottom,
IssueTypeIcon,
IssuePriorityIcon,
IssueAssignees,
IssueAssigneeAvatar,
} from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
filteredIssues: PropTypes.array.isRequired,
};
const ProjectBoardLists = ({ project, filteredIssues }) => {
const renderList = status => {
const getListIssues = issues => issues.filter(issue => issue.status === status);
const allListIssues = getListIssues(project.issues);
const filteredListIssues = getListIssues(filteredIssues);
const issuesCount =
allListIssues.length !== filteredListIssues.length
? `${filteredListIssues.length} of ${allListIssues.length}`
: allListIssues.length;
return (
<List key={status}>
<ListTitle>
{`${issueStatusCopy[status]} `}
<ListIssuesCount>{issuesCount}</ListIssuesCount>
</ListTitle>
<Issues>{filteredListIssues.map(renderIssue)}</Issues>
</List>
);
};
const renderIssue = issue => {
const getUserById = userId => project.users.find(user => user.id === userId);
const assignees = issue.userIds.map(getUserById);
return (
<Issue key={issue.id}>
<IssueTitle>{issue.title}</IssueTitle>
<IssueBottom>
<div>
<IssueTypeIcon type={issue.type} color={issue.type} />
<IssuePriorityIcon
type={
[IssuePriority.LOW || IssuePriority.LOWEST].includes(issue.priority)
? 'arrow-down'
: 'arrow-up'
}
color={issue.priority}
/>
</div>
<IssueAssignees>
{assignees.map(user => (
<IssueAssigneeAvatar
key={user.id}
size={24}
avatarUrl={user.avatarUrl}
name={user.name}
/>
))}
</IssueAssignees>
</IssueBottom>
</Issue>
);
};
return <Lists>{Object.values(IssueStatus).map(renderList)}</Lists>;
};
const issueStatusCopy = {
[IssueStatus.BACKLOG]: 'Backlog',
[IssueStatus.SELECTED]: 'Selected for development',
[IssueStatus.INPROGRESS]: 'In progress',
[IssueStatus.DONE]: 'Done',
};
ProjectBoardLists.propTypes = propTypes;
export default ProjectBoardLists;

View File

@@ -0,0 +1,32 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import useApi from 'shared/hooks/api';
import Header from './Header';
import Filters from './Filters';
import Lists from './Lists';
const propTypes = {
project: PropTypes.object.isRequired,
};
const ProjectBoard = ({ project }) => {
const [filteredIssues, setFilteredIssues] = useState([]);
const [{ data }] = useApi.get('/currentUser');
const { currentUser } = data || {};
return (
<>
<Header projectName={project.name} />
{currentUser && (
<Filters project={project} currentUser={currentUser} onChange={setFilteredIssues} />
)}
<Lists project={project} filteredIssues={filteredIssues} />
</>
);
};
ProjectBoard.propTypes = propTypes;
export default ProjectBoard;

View File

@@ -0,0 +1,56 @@
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { color, sizes, font, mixin } from 'shared/utils/styles';
export const Sidebar = styled.div`
position: absolute;
top: 0;
left: ${sizes.appNavBarLeftWidth}px;
height: 100vh;
width: 240px;
padding: 0 16px;
background: ${color.backgroundLightest};
border-right: 1px solid ${color.borderLightest};
`;
export const ProjectInfo = styled.div`
display: flex;
padding: 24px 4px;
`;
export const ProjectTexts = styled.div`
padding: 3px 0 0 10px;
`;
export const ProjectName = styled.div`
color: ${color.textDark};
${font.size(15)};
${font.medium};
`;
export const ProjectCategory = styled.div`
color: ${color.textMedium};
${font.size(13)};
`;
export const LinkItem = styled(Link)`
display: flex;
padding: 8px 12px;
border-radius: 3px;
color: ${color.textDark};
${mixin.clickable}
&:hover {
background: ${color.backgroundLight};
}
i {
margin-right: 15px;
font-size: 20px;
color: ${color.textDarkest};
}
`;
export const LinkText = styled.div`
padding-top: 2px;
${font.size(15)};
`;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Icon, ProjectAvatar } from 'shared/components';
import {
Sidebar,
ProjectInfo,
ProjectTexts,
ProjectName,
ProjectCategory,
LinkItem,
LinkText,
} from './Styles';
const propTypes = {
projectName: PropTypes.string.isRequired,
};
const ProjectSidebar = ({ projectName }) => (
<Sidebar>
<ProjectInfo>
<ProjectAvatar />
<ProjectTexts>
<ProjectName>{projectName}</ProjectName>
<ProjectCategory>Software project</ProjectCategory>
</ProjectTexts>
</ProjectInfo>
<LinkItem to="/project/board">
<Icon type="board" />
<LinkText>Kanban Board</LinkText>
</LinkItem>
<LinkItem to="/project/issues">
<Icon type="issues" />
<LinkText>Issues and filters</LinkText>
</LinkItem>
<LinkItem to="/project/settings">
<Icon type="settings" />
<LinkText>Project settings</LinkText>
</LinkItem>
</Sidebar>
);
ProjectSidebar.propTypes = propTypes;
export default ProjectSidebar;

View File

@@ -0,0 +1,7 @@
import styled from 'styled-components';
import { sizes } from 'shared/utils/styles';
export const ProjectPage = styled.div`
padding: 25px 32px 0 ${sizes.secondarySideBarWidth + 40}px;
`;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import useApi from 'shared/hooks/api';
import { PageLoader, PageError } from 'shared/components';
import Sidebar from './Sidebar';
import Board from './Board';
import { ProjectPage } from './Styles';
const Project = () => {
const [{ data, error, isLoading }] = useApi.get('/project');
if (isLoading) return <PageLoader />;
if (error) return <PageError />;
const { project } = data;
return (
<ProjectPage>
<Sidebar projectName={project.name} />
<Board project={project} />
</ProjectPage>
);
};
export default Project;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 97 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -7,8 +7,7 @@ export const Image = styled.div`
width: ${props => props.size}px;
height: ${props => props.size}px;
border-radius: 100%;
background-image: url('${props => props.avatarUrl}');
${mixin.backgroundImage}
${props => mixin.backgroundImage(props.avatarUrl)}
`;
export const Letter = styled.div`

View File

@@ -14,7 +14,7 @@ const defaultProps = {
className: undefined,
avatarUrl: null,
name: '',
size: 24,
size: 32,
};
const colors = [
@@ -30,12 +30,12 @@ const colors = [
const getColorFromName = name => colors[name.toLocaleLowerCase().charCodeAt(0) % colors.length];
const Avatar = ({ className, avatarUrl, name, size }) => {
const Avatar = ({ className, avatarUrl, name, size, ...otherProps }) => {
if (avatarUrl) {
return <Image className={className} size={size} avatarUrl={avatarUrl} />;
return <Image className={className} size={size} avatarUrl={avatarUrl} {...otherProps} />;
}
return (
<Letter className={className} size={size} color={getColorFromName(name)}>
<Letter className={className} size={size} color={getColorFromName(name)} {...otherProps}>
<span>{name.charAt(0)}</span>
</Letter>
);

View File

@@ -4,83 +4,79 @@ import Spinner from 'shared/components/Spinner';
import { color, font, mixin } from 'shared/utils/styles';
export const StyledButton = styled.button`
display: inline-block;
height: 36px;
line-height: 34px;
padding: 0 18px;
vertical-align: middle;
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
line-height: 1;
padding: 0 ${props => (props.iconOnly ? 9 : 12)}px;
white-space: nowrap;
text-align: center;
border-radius: 4px;
border-radius: 3px;
transition: all 0.1s;
appearance: none !important;
appearance: none;
${mixin.clickable}
${font.bold}
${font.size(14)}
${props => (props.hollow ? hollowStyles : filledStyles)}
${font.size(14.5)}
${props => buttonColors[props.color]}
&:disabled {
opacity: 0.6;
cursor: default;
}
i {
position: relative;
top: -1px;
right: 4px;
margin-right: 7px;
display: inline-block;
vertical-align: middle;
line-height: 1;
font-size: 16px;
}
${props => (props.iconOnly ? iconOnlyStyles : '')}
`;
const filledStyles = props => css`
color: #fff;
background: ${color[props.color]};
border: 1px solid ${color[props.color]};
${!props.disabled &&
css`
&:hover,
&:focus {
background: ${mixin.darken(color[props.color], 0.15)};
border: 1px solid ${mixin.darken(color[props.color], 0.15)};
}
&:active {
background: ${mixin.lighten(color[props.color], 0.1)};
border: 1px solid ${mixin.lighten(color[props.color], 0.1)};
}
`}
`;
const hollowStyles = props => css`
color: ${color.textMediumBlue};
background: #fff;
border: 1px solid ${color.borderBlue};
${!props.disabled &&
css`
&:hover,
&:focus {
border: 1px solid ${mixin.darken(color.borderBlue, 0.15)};
}
&:active {
border: 1px solid ${color.borderBlue};
}
`}
`;
const iconOnlyStyles = css`
padding: 0 12px;
i {
right: 0;
margin-right: 0;
margin-right: ${props => (props.iconOnly ? 0 : 7)}px;
}
`;
const secondaryAndEmptyShared = css`
color: ${color.textDark};
${font.regular}
&:not(:disabled) {
&:hover {
background: ${color.backgroundLight};
}
&:active {
color: ${color.primary};
background: ${mixin.rgba(color.primary, 0.15)};
}
${props =>
props.isActive &&
`
color: ${color.primary};
background: ${mixin.rgba(color.primary, 0.15)} !important;
`}
}
`;
const buttonColors = {
primary: css`
color: #fff;
background: ${color.primary};
${font.medium}
&:not(:disabled) {
&:hover {
background: ${mixin.lighten(color.primary, 0.15)};
}
&:active {
background: ${mixin.darken(color.primary, 0.1)};
}
${props =>
props.isActive &&
`
background: ${mixin.darken(color.primary, 0.1)} !important;
`}
}
`,
secondary: css`
background: ${color.secondary};
${secondaryAndEmptyShared};
`,
empty: css`
background: #fff;
${secondaryAndEmptyShared};
`,
};
export const StyledSpinner = styled(Spinner)`
position: relative;
right: 8px;
display: inline-block;
vertical-align: middle;
line-height: 1;
top: 1px;
margin-right: ${props => (props.iconOnly ? 0 : 7)}px;
`;

View File

@@ -8,9 +8,7 @@ import { StyledButton, StyledSpinner } from './Styles';
const propTypes = {
className: PropTypes.string,
children: PropTypes.node,
type: PropTypes.string,
hollow: PropTypes.bool,
color: PropTypes.oneOf(['primary', 'success', 'danger']),
color: PropTypes.oneOf(['primary', 'secondary', 'empty']),
icon: PropTypes.string,
iconSize: PropTypes.number,
disabled: PropTypes.bool,
@@ -21,11 +19,9 @@ const propTypes = {
const defaultProps = {
className: undefined,
children: undefined,
type: 'button',
hollow: false,
color: 'primary',
color: 'secondary',
icon: undefined,
iconSize: undefined,
iconSize: 18,
disabled: false,
working: false,
onClick: () => {},
@@ -33,7 +29,7 @@ const defaultProps = {
const Button = ({
children,
hollow,
color: propsColor,
icon,
iconSize,
disabled,
@@ -43,21 +39,31 @@ const Button = ({
}) => (
<StyledButton
{...buttonProps}
hollow={hollow}
onClick={() => {
if (!disabled && !working) {
onClick();
}
}}
color={propsColor}
disabled={disabled || working}
working={working}
iconOnly={!children}
>
{working && <StyledSpinner size={26} color={hollow ? color.textMediumBlue : '#fff'} />}
{!working && icon && (
<Icon type={icon} size={iconSize} color={hollow ? color.textMediumBlue : '#fff'} />
{working && (
<StyledSpinner
iconOnly={!children}
size={26}
color={propsColor === 'primary' ? '#fff' : color.textDark}
/>
)}
{children}
{!working && icon && (
<Icon
type={icon}
size={iconSize}
color={propsColor === 'primary' ? '#fff' : color.textDark}
/>
)}
<div>{children}</div>
</StyledButton>
);

View File

@@ -72,7 +72,7 @@ const ConfirmModal = ({
{confirmInput && (
<>
<InputLabel>{`Type ${confirmInput} below to confirm.`}</InputLabel>
<StyledInput onChange={(event, value) => handleConfirmInputChange(value)} />
<StyledInput onChange={handleConfirmInputChange} />
<br />
</>
)}

View File

@@ -12,7 +12,7 @@ export const Dropdown = styled.div`
top: 130%;
right: 0;
width: 270px;
border-radius: 4px;
border-radius: 3px;
background: #fff;
${mixin.boxShadowBorderMedium}
${props => (props.withTime ? withTimeStyles : '')}
@@ -76,7 +76,7 @@ export const Day = styled.div`
width: 14.28%;
height: 30px;
line-height: 30px;
border-radius: 4px;
border-radius: 3px;
${font.size(15)}
${props => (!props.isFiller ? hoverStyles : '')}
${props => (props.isToday ? font.bold : '')}

View File

@@ -4,42 +4,30 @@ import PropTypes from 'prop-types';
import StyledIcon from './Styles';
const codes = {
[`check-circle`]: '\\e86c',
[`check-fat`]: '\\f00c',
[`arrow-left`]: '\\e900',
[`arrow-right`]: '\\e912',
[`upload-thin`]: '\\e91f',
[`bell`]: '\\e901',
[`calendar`]: '\\e903',
[`check`]: '\\e904',
[`chevron-down`]: '\\e905',
[`chevron-left`]: '\\e906',
[`chevron-right`]: '\\e907',
[`chevron-up`]: '\\e908',
[`clock`]: '\\e909',
[`download`]: '\\e90a',
[`plus`]: '\\e90c',
[`refresh`]: '\\e90d',
[`search`]: '\\e90e',
[`upload`]: '\\e90f',
[`close`]: '\\e910',
[`archive`]: '\\e915',
[`briefcase`]: '\\e916',
[`settings`]: '\\e902',
[`email`]: '\\e914',
[`lock`]: '\\e913',
[`dashboard`]: '\\e917',
[`alert`]: '\\e911',
[`edit`]: '\\e918',
[`delete`]: '\\e919',
[`sort`]: '\\f0dc',
[`sort-up`]: '\\f0d8',
[`sort-down`]: '\\f0d7',
[`euro`]: '\\f153',
[`folder-plus`]: '\\e921',
[`folder-minus`]: '\\e920',
[`file`]: '\\e90b',
[`file-text`]: '\\e924',
[`bug`]: '\\e90f',
[`stopwatch`]: '\\e914',
[`task`]: '\\e910',
[`story`]: '\\e911',
[`arrow-down`]: '\\e90a',
[`arrow-left-circle`]: '\\e917',
[`arrow-up`]: '\\e90b',
[`chevron-down`]: '\\e900',
[`chevron-left`]: '\\e901',
[`chevron-right`]: '\\e902',
[`chevron-up`]: '\\e903',
[`board`]: '\\e904',
[`help`]: '\\e905',
[`link`]: '\\e90c',
[`menu`]: '\\e916',
[`more`]: '\\e90e',
[`attach`]: '\\e90d',
[`plus`]: '\\e906',
[`search`]: '\\e907',
[`issues`]: '\\e908',
[`settings`]: '\\e909',
[`close`]: '\\e913',
[`help-filled`]: '\\e912',
[`feedback`]: '\\e915',
};
const propTypes = {

View File

@@ -5,29 +5,29 @@ import { color, font } from 'shared/utils/styles';
export default styled.div`
position: relative;
display: inline-block;
height: 40px;
height: 32px;
width: 100%;
input {
height: 100%;
width: 100%;
padding: 0 15px;
border-radius: 4px;
border: 1px solid ${color.borderLight};
box-shadow: inset 0 0 1px 0 rgba(0, 0, 0, 0.03);
background: #fff;
padding: 0 7px;
border-radius: 3px;
border: 1px solid ${color.borderLightest};
background: ${color.backgroundLightest};
${font.regular}
${font.size(14)}
${font.size(15)}
&:focus {
border: 1px solid ${color.borderMedium};
background: #fff;
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
}
${props => (props.icon ? 'padding-left: 40px;' : '')}
${props => (props.icon ? 'padding-left: 32px;' : '')}
${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
}
i {
position: absolute;
top: 12px;
left: 14px;
font-size: 16px;
top: 8px;
left: 8px;
pointer-events: none;
color: ${color.textMedium};
}

View File

@@ -25,12 +25,12 @@ const defaultProps = {
const Input = forwardRef(({ icon, className, invalid, filter, onChange, ...inputProps }, ref) => {
const handleChange = event => {
if (!filter || filter.test(event.target.value)) {
onChange(event, event.target.value);
onChange(event.target.value, event);
}
};
return (
<StyledInput className={className} icon={icon} invalid={invalid}>
{icon && <Icon type={icon} />}
{icon && <Icon type={icon} size={15} />}
<input {...inputProps} onChange={handleChange} ref={ref} />
</StyledInput>
);

View File

@@ -3,17 +3,17 @@ import PropTypes from 'prop-types';
const propTypes = {
className: PropTypes.string,
width: PropTypes.number,
size: PropTypes.number,
};
const defaultProps = {
className: undefined,
width: 28,
size: 28,
};
const Logo = ({ className, width }) => (
const Logo = ({ className, size }) => (
<span className={className}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.76 75.76" width={width}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.76 75.76" width={size}>
<defs>
<linearGradient
id="linear-gradient"

View File

@@ -15,7 +15,7 @@ export const ScrollOverlay = styled.div`
export const ClickableOverlay = styled.div`
min-height: 100%;
background: ${mixin.rgba(color.textLightBlue, 0.7)};
background: ${mixin.rgba(color.textLight, 0.7)};
${props => clickOverlayStyles[props.variant]}
`;

View File

@@ -0,0 +1,44 @@
import styled from 'styled-components';
import { Icon } from 'shared/components';
import { color, font, mixin } from 'shared/utils/styles';
import imageBackground from './assets/background-forest.jpg';
export const ErrorPage = styled.div`
padding: 64px;
`;
export const ErrorPageInner = styled.div`
margin: 0 auto;
max-width: 1440px;
padding: 200px 0;
${mixin.backgroundImage(imageBackground)}
@media (max-height: 680px) {
padding: 140px 0;
}
`;
export const ErrorBox = styled.div`
position: relative;
margin: 0 auto;
max-width: 480px;
padding: 32px;
border-radius: 3px;
border: 1px solid ${color.borderLight};
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
background: rgba(255, 255, 255, 0.9);
`;
export const StyledIcon = styled(Icon)`
position: absolute;
top: 32px;
left: 32px;
font-size: 30px;
color: ${color.primary};
`;
export const Title = styled.h1`
margin-bottom: 16px;
padding-left: 42px;
${font.size(29)}
`;

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { ErrorPage, ErrorPageInner, ErrorBox, StyledIcon, Title } from './Styles';
const PageError = () => (
<ErrorPage>
<ErrorPageInner>
<ErrorBox>
<StyledIcon type="bug" />
<Title>Theres been a glitch</Title>
<p>
{'Were not quite sure what went wrong. Please contact us or try looking on our '}
<a href="https://support.atlassian.com/jira-software-cloud/">Help Center</a>
{' if you need a hand.'}
</p>
</ErrorBox>
</ErrorPageInner>
</ErrorPage>
);
export default PageError;

View File

@@ -2,6 +2,6 @@ import styled from 'styled-components';
export default styled.div`
width: 100%;
padding: 100px;
padding-top: 200px;
text-align: center;
`;

View File

@@ -0,0 +1,120 @@
import React from 'react';
import PropTypes from 'prop-types';
const propTypes = {
className: PropTypes.string,
size: PropTypes.number,
};
const defaultProps = {
className: undefined,
size: 40,
};
const Logo = ({ className, size }) => (
<span className={className}>
<svg
width={size}
height={size}
style={{ borderRadius: 3 }}
viewBox="0 0 128 128"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<rect id="path-1" x="0" y="0" width="128" height="128" />
</defs>
<g id="Page-1" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g id="project_avatar_settings">
<g>
<mask id="mask-2" fill="white">
<use xlinkHref="#path-1" />
</mask>
<use id="Rectangle" fill="#FF5630" xlinkHref="#path-1" />
<g id="Settings" fillRule="nonzero">
<g transform="translate(20.000000, 17.000000)">
<path
d="M74.578,84.289 L72.42,84.289 C70.625,84.289 69.157,82.821 69.157,81.026 L69.157,16.537 C69.157,14.742 70.625,13.274 72.42,13.274 L74.578,13.274 C76.373,13.274 77.841,14.742 77.841,16.537 L77.841,81.026 C77.842,82.82 76.373,84.289 74.578,84.289 Z"
id="Shape"
fill="#2A5083"
/>
<path
d="M14.252,84.289 L12.094,84.289 C10.299,84.289 8.831,82.821 8.831,81.026 L8.831,16.537 C8.831,14.742 10.299,13.274 12.094,13.274 L14.252,13.274 C16.047,13.274 17.515,14.742 17.515,16.537 L17.515,81.026 C17.515,82.82 16.047,84.289 14.252,84.289 Z"
id="Shape"
fill="#2A5083"
/>
<rect
id="Rectangle-path"
fill="#153A56"
x="8.83"
y="51.311"
width="8.685"
height="7.763"
/>
<path
d="M13.173,53.776 L13.173,53.776 C6.342,53.776 0.753,48.187 0.753,41.356 L0.753,41.356 C0.753,34.525 6.342,28.936 13.173,28.936 L13.173,28.936 C20.004,28.936 25.593,34.525 25.593,41.356 L25.593,41.356 C25.593,48.187 20.004,53.776 13.173,53.776 Z"
id="Shape"
fill="#FFFFFF"
/>
<path
d="M18.021,43.881 L8.324,43.881 C7.453,43.881 6.741,43.169 6.741,42.298 L6.741,41.25 C6.741,40.379 7.453,39.667 8.324,39.667 L18.021,39.667 C18.892,39.667 19.604,40.379 19.604,41.25 L19.604,42.297 C19.605,43.168 18.892,43.881 18.021,43.881 Z"
id="Shape"
fill="#2A5083"
opacity="0.2"
/>
<rect
id="Rectangle-path"
fill="#153A56"
x="69.157"
y="68.307"
width="8.685"
height="7.763"
/>
<path
d="M73.499,70.773 L73.499,70.773 C66.668,70.773 61.079,65.184 61.079,58.353 L61.079,58.353 C61.079,51.522 66.668,45.933 73.499,45.933 L73.499,45.933 C80.33,45.933 85.919,51.522 85.919,58.353 L85.919,58.353 C85.919,65.183 80.33,70.773 73.499,70.773 Z"
id="Shape"
fill="#FFFFFF"
/>
<path
d="M78.348,60.877 L68.651,60.877 C67.78,60.877 67.068,60.165 67.068,59.294 L67.068,58.247 C67.068,57.376 67.781,56.664 68.651,56.664 L78.348,56.664 C79.219,56.664 79.931,57.377 79.931,58.247 L79.931,59.294 C79.931,60.165 79.219,60.877 78.348,60.877 Z"
id="Shape"
fill="#2A5083"
opacity="0.2"
/>
<path
d="M44.415,84.289 L42.257,84.289 C40.462,84.289 38.994,82.821 38.994,81.026 L38.994,16.537 C38.994,14.742 40.462,13.274 42.257,13.274 L44.415,13.274 C46.21,13.274 47.678,14.742 47.678,16.537 L47.678,81.026 C47.678,82.82 46.21,84.289 44.415,84.289 Z"
id="Shape"
fill="#2A5083"
/>
<rect
id="Rectangle-path"
fill="#153A56"
x="38.974"
y="23.055"
width="8.685"
height="7.763"
/>
<path
d="M43.316,25.521 L43.316,25.521 C36.485,25.521 30.896,19.932 30.896,13.101 L30.896,13.101 C30.896,6.27 36.485,0.681 43.316,0.681 L43.316,0.681 C50.147,0.681 55.736,6.27 55.736,13.101 L55.736,13.101 C55.736,19.932 50.147,25.521 43.316,25.521 Z"
id="Shape"
fill="#FFFFFF"
/>
<path
d="M48.165,15.626 L38.468,15.626 C37.597,15.626 36.885,14.914 36.885,14.043 L36.885,12.996 C36.885,12.125 37.597,11.413 38.468,11.413 L48.165,11.413 C49.036,11.413 49.748,12.125 49.748,12.996 L49.748,14.043 C49.748,14.913 49.036,15.626 48.165,15.626 Z"
id="Shape"
fill="#2A5083"
opacity="0.2"
/>
</g>
</g>
</g>
</g>
</g>
</svg>
</span>
);
Logo.propTypes = propTypes;
Logo.defaultProps = defaultProps;
export default Logo;

View File

@@ -64,11 +64,11 @@ const SelectDropdown = ({
};
const handleInputKeyDown = event => {
if (event.keyCode === KeyCodes.escape) {
if (event.keyCode === KeyCodes.ESCAPE) {
handleInputEscapeKeyDown(event);
} else if (event.keyCode === KeyCodes.enter) {
} else if (event.keyCode === KeyCodes.ENTER) {
handleInputEnterKeyDown(event);
} else if (event.keyCode === KeyCodes.arrowDown || event.keyCode === KeyCodes.arrowUp) {
} else if (event.keyCode === KeyCodes.ARROW_DOWN || event.keyCode === KeyCodes.ARROW_UP) {
handleInputArrowUpOrDownKeyDown(event);
}
};
@@ -101,7 +101,7 @@ const SelectDropdown = ({
const $optionsHeight = $options.getBoundingClientRect().height;
const $activeHeight = $active.getBoundingClientRect().height;
if (event.keyCode === KeyCodes.arrowDown) {
if (event.keyCode === KeyCodes.ARROW_DOWN) {
if ($options.lastElementChild === $active) {
$active.classList.remove(activeOptionClass);
$options.firstElementChild.classList.add(activeOptionClass);
@@ -113,7 +113,7 @@ const SelectDropdown = ({
$options.scrollTop += $activeHeight;
}
}
} else if (event.keyCode === KeyCodes.arrowUp) {
} else if (event.keyCode === KeyCodes.ARROW_UP) {
if ($options.firstElementChild === $active) {
$active.classList.remove(activeOptionClass);
$options.lastElementChild.classList.add(activeOptionClass);

View File

@@ -6,7 +6,7 @@ import Icon from 'shared/components/Icon';
export const StyledSelect = styled.div`
position: relative;
width: 100%;
border-radius: 4px;
border-radius: 3px;
border: 1px solid ${color.borderLight};
background: #fff;
${font.size(14)}
@@ -41,7 +41,7 @@ export const ChevronIcon = styled(Icon)`
export const Placeholder = styled.div`
padding: 11px 0 0 15px;
color: ${color.textLightBlue};
color: ${color.textLight};
`;
export const ValueSingle = styled.div`

View File

@@ -93,10 +93,10 @@ const Select = ({
const handleFocusedSelectKeydown = event => {
if (isDropdownOpen) return;
if (event.keyCode === KeyCodes.enter) {
if (event.keyCode === KeyCodes.ENTER) {
event.preventDefault();
}
if (event.keyCode !== KeyCodes.escape && event.keyCode !== KeyCodes.tab && !event.shiftKey) {
if (event.keyCode !== KeyCodes.ESCAPE && event.keyCode !== KeyCodes.TAB && !event.shiftKey) {
setDropdownOpen(true);
}
};

View File

@@ -6,17 +6,18 @@ export default styled.div`
display: inline-block;
width: 100%;
textarea {
width: 100%;
padding: 13px 15px 14px;
border-radius: 4px;
border: 1px solid ${color.borderLight};
box-shadow: inset 0 0 1px 0 rgba(0, 0, 0, 0.03);
background: #fff;
overflow-y: hidden;
width: 100%;
padding: 6px 7px 7px;
border-radius: 3px;
border: 1px solid ${color.borderLightest};
background: ${color.backgroundLightest};
${font.regular}
${font.size(14)}
${font.size(15)}
&:focus {
border: 1px solid ${color.borderMedium};
background: #fff;
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
}
${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
}

View File

@@ -24,7 +24,7 @@ const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps },
<StyledTextarea className={className} invalid={invalid}>
<TextareaAutoSize
{...textareaProps}
onChange={event => onChange(event, event.target.value)}
onChange={event => onChange(event.target.value, event)}
ref={ref}
/>
</StyledTextarea>

View File

@@ -6,7 +6,9 @@ export { default as Icon } from './Icon';
export { default as Input } from './Input';
export { default as Logo } from './Logo';
export { default as Modal } from './Modal';
export { default as PageError } from './PageError';
export { default as PageLoader } from './PageLoader';
export { default as ProjectAvatar } from './ProjectAvatar';
export { default as Select } from './Select';
export { default as Spinner } from './Spinner';
export { default as Textarea } from './Textarea';

View File

@@ -0,0 +1,20 @@
export const IssueType = {
TASK: 'task',
BUG: 'bug',
STORY: 'story',
};
export const IssueStatus = {
BACKLOG: 'backlog',
SELECTED: 'selected',
INPROGRESS: 'inprogress',
DONE: 'done',
};
export const IssuePriority = {
HIGHEST: '5',
HIGH: '4',
MEDIUM: '3',
LOW: '2',
LOWEST: '1',
};

View File

@@ -1,7 +1,7 @@
export const KeyCodes = {
escape: 27,
tab: 9,
enter: 13,
arrowUp: 38,
arrowDown: 40,
ESCAPE: 27,
TAB: 9,
ENTER: 13,
ARROW_UP: 38,
ARROW_DOWN: 40,
};

View File

@@ -0,0 +1,71 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useState, useRef, useCallback, useEffect } from 'react';
import api from 'shared/utils/api';
import useDeepCompareMemoize from './deepCompareMemoize';
const useApi = (method, url, paramsOrData = {}, { lazy = false } = {}) => {
const isCalledAutomatically = method === 'get' && !lazy;
const [state, setState] = useState({
data: null,
error: null,
isLoading: isCalledAutomatically,
variables: {},
});
const wasCalledRef = useRef(false);
const paramsOrDataMemoized = useDeepCompareMemoize(paramsOrData);
const stateRef = useRef();
stateRef.current = state;
const makeRequest = useCallback(
(newVariables = {}) =>
new Promise((resolve, reject) => {
const updateState = newState => setState({ ...stateRef.current, ...newState });
const variables = { ...stateRef.current.variables, ...newVariables };
if (!isCalledAutomatically || wasCalledRef.current) {
updateState({ variables, isLoading: true });
}
api[method](url, { ...paramsOrDataMemoized, ...variables }).then(
data => {
resolve(data);
updateState({ data, error: null, isLoading: false });
},
error => {
reject(error);
updateState({ error, data: null, isLoading: false });
},
);
wasCalledRef.current = true;
}),
[method, paramsOrDataMemoized, isCalledAutomatically, url],
);
useEffect(() => {
if (isCalledAutomatically) {
makeRequest();
}
}, [makeRequest, isCalledAutomatically]);
return [
{
...state,
wasCalled: wasCalledRef.current,
variables: { ...paramsOrDataMemoized, ...state.variables },
},
makeRequest,
];
};
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,19 @@
import { useState, useEffect } from 'react';
const useDebounceValue = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
export default useDebounceValue;

View File

@@ -0,0 +1,14 @@
import { useRef } from 'react';
import { isEqual } from 'lodash';
function useDeepCompareMemoize(value) {
const valueRef = useRef();
if (!isEqual(value, valueRef.current)) {
valueRef.current = value;
}
return valueRef.current;
}
export default useDeepCompareMemoize;

View File

@@ -5,7 +5,7 @@ import { KeyCodes } from 'shared/constants/keyCodes';
const useOnEscapeKeyDown = (isListening, onEscapeKeyDown) => {
useEffect(() => {
const handleKeyDown = event => {
if (event.keyCode === KeyCodes.escape) {
if (event.keyCode === KeyCodes.ESCAPE) {
onEscapeKeyDown();
}
};

View File

@@ -0,0 +1,54 @@
import axios from 'axios';
import history from 'browserHistory';
import { objectToQueryString } from 'shared/utils/url';
import { getStoredAuthToken, removeStoredAuthToken } from 'shared/utils/authToken';
const defaults = {
baseURL: 'http://localhost:3000',
headers: () => ({
'Content-Type': 'application/json',
Authorization: getStoredAuthToken() ? `Bearer ${getStoredAuthToken()}` : undefined,
}),
error: {
code: 'INTERNAL_ERROR',
message: 'Something went wrong. Please check your internet connection or contact our support.',
status: 503,
},
};
const api = (method, url, paramsOrData) =>
new Promise((resolve, reject) => {
axios({
url: `${defaults.baseURL}${url}`,
method,
headers: defaults.headers(),
params: method === 'get' ? paramsOrData : undefined,
data: method !== 'get' ? paramsOrData : undefined,
paramsSerializer: objectToQueryString,
}).then(
response => {
resolve(response.data);
},
error => {
if (error.response) {
if (error.response.data.error.code === 'INVALID_TOKEN') {
removeStoredAuthToken();
history.push('/authenticate');
} else {
reject(error.response.data.error);
}
} else {
reject(defaults.error);
}
},
);
});
export default {
get: (...args) => api('get', ...args),
post: (...args) => api('post', ...args),
put: (...args) => api('put', ...args),
patch: (...args) => api('patch', ...args),
delete: (...args) => api('delete', ...args),
};

View File

@@ -0,0 +1,3 @@
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,8 @@
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,33 +1,46 @@
import Color from 'color';
export const color = {
primary: '#2553B3', // blue
primary: '#0052cc', // Blue
success: '#29A638', // green
danger: '#E13C3C', // red
warning: '#F89C1C', // orange
accent: '#8A46D7', // purple
secondary: '#F4F5F7', // light grey
textDarkest: '#323232',
textDark: '#616161',
textMedium: '#75787D',
textMediumBlue: '#78869F',
textLight: '#959595',
textLightBlue: '#96A1B5',
textDarkest: '#172b4d',
textDark: '#42526E',
textMedium: '#5E6C84',
textLight: '#8993a4',
textLink: '#0052cc',
backgroundDark: '#8393AD',
backgroundMedium: '#D8DDE6',
backgroundLight: '#F7F9FB',
backgroundDarkPrimary: '#0747A6',
backgroundMedium: '#dfe1e6',
backgroundLight: '#ebecf0',
backgroundLightest: '#F4F5F7',
borderLightest: '#E1E6F0',
borderLight: '#D8DDE6',
borderMedium: '#B9BDC4',
borderBlue: '#C5D3EB',
borderLightest: '#dfe1e6',
borderLight: '#C1C7D0',
borderInputFocus: '#4c9aff',
};
export const issueTypeColors = {
story: '#65BA43', // green
bug: '#E44D42', // red
task: '#4FADE6', // blue
};
export const issuePriorityColors = {
'5': '#CD1317', // red
'4': '#E9494A', // orange
'3': '#E97F33', // orange
'2': '#2D8738', // green
'1': '#57A55A', // green
};
export const sizes = {
appNavBarLeftWidth: 75,
appNavBarLeftWidth: 64,
secondarySideBarWidth: 240,
minViewportWidth: 1000,
secondarySideBarWidth: 230,
};
export const zIndexValues = {
@@ -130,13 +143,14 @@ export const mixin = {
background: ${background};
}
`,
backgroundImage: `
backgroundImage: imageURL => `
background-image: url("${imageURL}");
background-position: 50% 50%;
background-repeat: no-repeat;
background-size: cover;
background-color: ${color.backgroundLight};
`,
link: (colorValue = color.primary) => `
link: (colorValue = color.textLink) => `
cursor: pointer;
color: ${colorValue};
${font.medium}
@@ -147,22 +161,4 @@ export const mixin = {
text-decoration: underline;
}
`,
tag: `
display: inline-block;
height: 24px;
line-height: 22px;
padding: 0 6px 0 8px;
border: 1px solid ${color.borderLight};
border-radius: 4px;
cursor: pointer;
user-select: none;
background: ${color.backgroundLight};
${font.medium}
${font.size(12)}
i {
margin-left: 4px;
vertical-align: middle;
font-size: 14px;
}
`,
};