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,8 +1,6 @@
import express from 'express'; import express from 'express';
import { User } from 'entities';
import { catchErrors } from 'errors'; import { catchErrors } from 'errors';
import { createEntity } from 'utils/typeorm';
import { signToken } from 'utils/authToken'; import { signToken } from 'utils/authToken';
import seedGuestUserEntities from 'database/seeds/guestUser'; import seedGuestUserEntities from 'database/seeds/guestUser';
@@ -10,9 +8,8 @@ const router = express.Router();
router.post( router.post(
'/authentication/guest', '/authentication/guest',
catchErrors(async (req, res) => { catchErrors(async (_req, res) => {
const user = await createEntity(User, req.body); const user = await seedGuestUserEntities();
await seedGuestUserEntities(user);
res.respond({ res.respond({
authToken: signToken({ sub: user.id }), authToken: signToken({ sub: user.id }),
}); });

View File

@@ -2,36 +2,20 @@ import express from 'express';
import { Project } from 'entities'; import { Project } from 'entities';
import { catchErrors } from 'errors'; import { catchErrors } from 'errors';
import { findEntityOrThrow, updateEntity, deleteEntity, createEntity } from 'utils/typeorm'; import { findEntityOrThrow, updateEntity } from 'utils/typeorm';
const router = express.Router(); const router = express.Router();
router.get( router.get(
'/projects', '/project',
catchErrors(async (_req, res) => {
const projects = await Project.find();
res.respond({ projects });
}),
);
router.get(
'/projects/:projectId',
catchErrors(async (req, res) => { catchErrors(async (req, res) => {
const project = await findEntityOrThrow(Project, req.params.projectId, { const project = await findEntityOrThrow(Project, req.currentUser.projectId, {
relations: ['users', 'issues', 'issues.comments'], relations: ['users', 'issues', 'issues.comments'],
}); });
res.respond({ project }); res.respond({ project });
}), }),
); );
router.post(
'/projects',
catchErrors(async (req, res) => {
const project = await createEntity(Project, req.body);
res.respond({ project });
}),
);
router.put( router.put(
'/projects/:projectId', '/projects/:projectId',
catchErrors(async (req, res) => { catchErrors(async (req, res) => {
@@ -40,12 +24,4 @@ router.put(
}), }),
); );
router.delete(
'/projects/:projectId',
catchErrors(async (req, res) => {
const project = await deleteEntity(Project, req.params.projectId);
res.respond({ project });
}),
);
export default router; export default router;

View File

@@ -0,0 +1,14 @@
import express from 'express';
import { catchErrors } from 'errors';
const router = express.Router();
router.get(
'/currentUser',
catchErrors((req, res) => {
res.respond({ currentUser: req.currentUser });
}),
);
export default router;

View File

@@ -4,6 +4,7 @@ import User from 'entities/User';
const generateUser = (data: Partial<User> = {}): Partial<User> => ({ const generateUser = (data: Partial<User> = {}): Partial<User> => ({
name: faker.company.companyName(), name: faker.company.companyName(),
avatarUrl: faker.image.avatar(),
email: faker.internet.email(), email: faker.internet.email(),
...data, ...data,
}); });

View File

@@ -1,20 +1,39 @@
import faker from 'faker';
import { sample } from 'lodash';
import { Comment, Issue, Project, User } from 'entities'; import { Comment, Issue, Project, User } from 'entities';
import { ProjectCategory } from 'constants/projects'; import { ProjectCategory } from 'constants/projects';
import { IssueType, IssueStatus, IssuePriority } from 'constants/issues'; import { IssueType, IssueStatus, IssuePriority } from 'constants/issues';
import { createEntity } from 'utils/typeorm'; import { createEntity } from 'utils/typeorm';
const seedProject = (user: User): Promise<Project> => const seedUsers = (): Promise<User[]> => {
const users = [
createEntity(User, {
email: 'greg@jira.guest',
name: 'Greg the Egg',
avatarUrl: faker.image.avatar(),
}),
createEntity(User, {
email: 'yoda@jira.guest',
name: 'Baby Yoda',
avatarUrl: faker.image.avatar(),
}),
];
return Promise.all(users);
};
const seedProject = (users: User[]): Promise<Project> =>
createEntity(Project, { createEntity(Project, {
name: 'Project: Hello World', name: 'singularity 1.0',
url: 'https://www.atlassian.com/software/jira', url: 'https://www.atlassian.com/software/jira',
description: description:
'Plan, track, and manage your agile and software development projects in Jira. Customize your workflow, collaborate, and release great software.', 'Plan, track, and manage your agile and software development projects in Jira. Customize your workflow, collaborate, and release great software.',
category: ProjectCategory.SOFTWARE, category: ProjectCategory.SOFTWARE,
users: [user], users,
}); });
const seedIssues = (project: Project): Promise<Issue[]> => { const seedIssues = (project: Project): Promise<Issue[]> => {
const user = project.users[0]; const getRandomUser = (): User => sample(project.users) as User;
const issues = [ const issues = [
createEntity(Issue, { createEntity(Issue, {
title: 'This is an issue of type: Task.', title: 'This is an issue of type: Task.',
@@ -22,9 +41,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
status: IssueStatus.BACKLOG, status: IssueStatus.BACKLOG,
priority: IssuePriority.LOWEST, priority: IssuePriority.LOWEST,
estimate: 8, estimate: 8,
reporterId: user.id, reporterId: getRandomUser().id,
project, project,
users: [user], users: [getRandomUser()],
}), }),
createEntity(Issue, { createEntity(Issue, {
title: "Click on an issue to see what's behind it.", title: "Click on an issue to see what's behind it.",
@@ -33,7 +52,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
priority: IssuePriority.LOW, priority: IssuePriority.LOW,
description: 'Nothing in particular.', description: 'Nothing in particular.',
estimate: 40, estimate: 40,
reporterId: user.id, reporterId: getRandomUser().id,
project, project,
}), }),
createEntity(Issue, { createEntity(Issue, {
@@ -42,9 +61,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
status: IssueStatus.BACKLOG, status: IssueStatus.BACKLOG,
priority: IssuePriority.MEDIUM, priority: IssuePriority.MEDIUM,
estimate: 15, estimate: 15,
reporterId: user.id, reporterId: getRandomUser().id,
project, project,
users: [user], users: [getRandomUser()],
}), }),
createEntity(Issue, { createEntity(Issue, {
title: 'You can use markdown for issue descriptions.', title: 'You can use markdown for issue descriptions.',
@@ -54,9 +73,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
description: description:
"#### Colons can be used to align columns.\n\n| Tables | Are | Cool |\n| ------------- |:-------------:| -----:|\n| col 3 is | right-aligned | |\n| col 2 is | centered | |\n| zebra stripes | are neat | |\n\nThe outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.\n\nMarkdown | Less | Pretty\n--- | --- | ---\n*Still* | `renders` | **nicely**\n1 | 2 | 3", "#### Colons can be used to align columns.\n\n| Tables | Are | Cool |\n| ------------- |:-------------:| -----:|\n| col 3 is | right-aligned | |\n| col 2 is | centered | |\n| zebra stripes | are neat | |\n\nThe outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.\n\nMarkdown | Less | Pretty\n--- | --- | ---\n*Still* | `renders` | **nicely**\n1 | 2 | 3",
estimate: 4, estimate: 4,
reporterId: user.id, reporterId: getRandomUser().id,
project, project,
users: [user], users: [getRandomUser()],
}), }),
createEntity(Issue, { createEntity(Issue, {
title: 'You must assign priority from lowest to highest to all issues.', title: 'You must assign priority from lowest to highest to all issues.',
@@ -64,7 +83,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
status: IssueStatus.SELECTED, status: IssueStatus.SELECTED,
priority: IssuePriority.HIGHEST, priority: IssuePriority.HIGHEST,
estimate: 15, estimate: 15,
reporterId: user.id, reporterId: getRandomUser().id,
project, project,
}), }),
createEntity(Issue, { createEntity(Issue, {
@@ -73,9 +92,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
status: IssueStatus.SELECTED, status: IssueStatus.SELECTED,
priority: IssuePriority.MEDIUM, priority: IssuePriority.MEDIUM,
estimate: 55, estimate: 55,
reporterId: user.id, reporterId: getRandomUser().id,
project, project,
users: [user], users: [getRandomUser()],
}), }),
createEntity(Issue, { createEntity(Issue, {
title: 'Try leaving a comment on this issue.', title: 'Try leaving a comment on this issue.',
@@ -83,7 +102,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
status: IssueStatus.SELECTED, status: IssueStatus.SELECTED,
priority: IssuePriority.MEDIUM, priority: IssuePriority.MEDIUM,
estimate: 12, estimate: 12,
reporterId: user.id, reporterId: getRandomUser().id,
project, project,
}), }),
]; ];
@@ -97,10 +116,12 @@ const seedComments = (issue: Issue, user: User): Promise<Comment> =>
user, user,
}); });
const seedGuestUserEntities = async (user: User): Promise<void> => { const seedGuestUserEntities = async (): Promise<User> => {
const project = await seedProject(user); const users = await seedUsers();
const project = await seedProject(users);
const issues = await seedIssues(project); const issues = await seedIssues(project);
await seedComments(issues[issues.length - 1], project.users[0]); await seedComments(issues[issues.length - 1], project.users[0]);
return users[0];
}; };
export default seedGuestUserEntities; export default seedGuestUserEntities;

View File

@@ -41,16 +41,16 @@ class Issue extends BaseEntity {
@Column('varchar') @Column('varchar')
priority: IssuePriority; priority: IssuePriority;
@Column({ type: 'text', nullable: true }) @Column('text', { nullable: true })
description: string | null; description: string | null;
@Column({ type: 'integer', nullable: true }) @Column('integer', { nullable: true })
estimate: number | null; estimate: number | null;
@Column({ type: 'integer', nullable: true }) @Column('integer', { nullable: true })
timeSpent: number | null; timeSpent: number | null;
@Column({ type: 'integer', nullable: true }) @Column('integer', { nullable: true })
timeRemaining: number | null; timeRemaining: number | null;
@CreateDateColumn({ type: 'timestamp' }) @CreateDateColumn({ type: 'timestamp' })

View File

@@ -6,8 +6,6 @@ import {
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
OneToMany, OneToMany,
ManyToMany,
JoinTable,
} from 'typeorm'; } from 'typeorm';
import is from 'utils/validation'; import is from 'utils/validation';
@@ -32,7 +30,7 @@ class Project extends BaseEntity {
@Column('varchar', { nullable: true }) @Column('varchar', { nullable: true })
url: string | null; url: string | null;
@Column({ type: 'text', nullable: true }) @Column('text', { nullable: true })
description: string | null; description: string | null;
@Column('varchar') @Column('varchar')
@@ -50,11 +48,10 @@ class Project extends BaseEntity {
) )
issues: Issue[]; issues: Issue[];
@ManyToMany( @OneToMany(
() => User, () => User,
user => user.projects, user => user.project,
) )
@JoinTable()
users: User[]; users: User[];
} }

View File

@@ -7,6 +7,8 @@ import {
UpdateDateColumn, UpdateDateColumn,
OneToMany, OneToMany,
ManyToMany, ManyToMany,
ManyToOne,
RelationId,
} from 'typeorm'; } from 'typeorm';
import is from 'utils/validation'; import is from 'utils/validation';
@@ -28,6 +30,9 @@ class User extends BaseEntity {
@Column('varchar') @Column('varchar')
email: string; email: string;
@Column('varchar', { length: 2000 })
avatarUrl: string;
@CreateDateColumn({ type: 'timestamp' }) @CreateDateColumn({ type: 'timestamp' })
createdAt: Date; createdAt: Date;
@@ -46,11 +51,14 @@ class User extends BaseEntity {
) )
issues: Issue[]; issues: Issue[];
@ManyToMany( @ManyToOne(
() => Project, () => Project,
project => project.users, project => project.users,
) )
projects: Project[]; project: Project;
@RelationId((user: User) => user.project)
projectId: number;
} }
export default User; export default User;

View File

@@ -17,5 +17,5 @@ export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
data: {}, data: {},
}; };
res.status(errorData.status).send({ errors: [errorData] }); res.status(errorData.status).send({ error: errorData });
}; };

View File

@@ -9,6 +9,7 @@ import { authenticateUser } from 'middleware/authentication';
import authenticationRoutes from 'controllers/authentication'; import authenticationRoutes from 'controllers/authentication';
import projectsRoutes from 'controllers/projects'; import projectsRoutes from 'controllers/projects';
import issuesRoutes from 'controllers/issues'; import issuesRoutes from 'controllers/issues';
import usersRoutes from 'controllers/users';
import { RouteNotFoundError } from 'errors'; import { RouteNotFoundError } from 'errors';
import { errorHandler } from 'errors/errorHandler'; import { errorHandler } from 'errors/errorHandler';
@@ -30,7 +31,7 @@ const initializeExpress = (): void => {
app.use((_req, res, next) => { app.use((_req, res, next) => {
res.respond = (data): void => { res.respond = (data): void => {
res.status(200).send({ data }); res.status(200).send(data);
}; };
next(); next();
}); });
@@ -41,6 +42,7 @@ const initializeExpress = (): void => {
app.use('/', projectsRoutes); app.use('/', projectsRoutes);
app.use('/', issuesRoutes); app.use('/', issuesRoutes);
app.use('/', usersRoutes);
app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl))); app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl)));
app.use(errorHandler); app.use(errorHandler);

View File

@@ -1,7 +1,6 @@
import { Request } from 'express'; import { Request } from 'express';
import { verifyToken } from 'utils/authToken'; import { verifyToken } from 'utils/authToken';
import { findEntityOrThrow } from 'utils/typeorm';
import { catchErrors, InvalidTokenError } from 'errors'; import { catchErrors, InvalidTokenError } from 'errors';
import { User } from 'entities'; import { User } from 'entities';
@@ -20,6 +19,10 @@ export const authenticateUser = catchErrors(async (req, _res, next) => {
if (!userId) { if (!userId) {
throw new InvalidTokenError('Authentication token is invalid.'); throw new InvalidTokenError('Authentication token is invalid.');
} }
req.currentUser = await findEntityOrThrow(User, userId); const user = await User.findOne(userId);
if (!user) {
throw new InvalidTokenError('Authentication token is invalid: User not found.');
}
req.currentUser = user;
next(); next();
}); });

View File

@@ -5,7 +5,7 @@ import { InvalidTokenError } from 'errors';
export const signToken = (payload: object, options?: SignOptions): string => export const signToken = (payload: object, options?: SignOptions): string =>
jwt.sign(payload, process.env.JWT_SECRET, { jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: '7 days', expiresIn: '180 days',
...options, ...options,
}); });

View File

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

View File

@@ -1,6 +1,8 @@
import styled from 'styled-components'; import styled from 'styled-components';
import { sizes } from 'shared/utils/styles';
export const Main = styled.main` export const Main = styled.main`
position: relative;
width: 100%; 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 { p {
line-height: 1.6; line-height: 1.4285;
a { a {
${mixin.link()} ${mixin.link()}
} }
@@ -104,5 +104,5 @@ export default createGlobalStyle`
touch-action: manipulation; 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` export const NavLeft = styled.aside`
z-index: ${zIndexValues.navLeft}; z-index: ${zIndexValues.navLeft};
position: absolute; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
overflow-x: hidden; overflow-x: hidden;
height: 100%; height: 100vh;
width: ${sizes.appNavBarLeftWidth}px; width: ${sizes.appNavBarLeftWidth}px;
background: ${color.primary}; background: ${color.backgroundDarkPrimary};
transition: all 0.1s; transition: all 0.1s;
${mixin.hardwareAccelerate} ${mixin.hardwareAccelerate}
&:hover { &:hover {
width: 260px; width: 180px;
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.6); box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.6);
} }
`; `;
@@ -25,71 +25,43 @@ export const LogoLink = styled(NavLink)`
display: block; display: block;
position: relative; position: relative;
left: 0; left: 0;
margin: 40px 0 40px; margin: 20px 0 10px;
transition: left 0.1s; 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)` export const StyledLogo = styled(Logo)`
display: inline-block; display: inline-block;
margin-left: 13px; margin-left: 8px;
padding: 10px; padding: 10px;
${mixin.clickable} ${mixin.clickable}
`; `;
export const IconLink = styled(NavLink)` export const Bottom = styled.div`
display: block; position: absolute;
bottom: 20px;
left: 0;
width: 100%;
`;
export const Item = styled.div`
position: relative; position: relative;
width: 100%; width: 100%;
height: 60px; height: 42px;
line-height: 60px; line-height: 42px;
padding-left: 67px; padding-left: 67px;
color: rgba(255, 255, 255, 0.75); color: #deebff;
transition: color 0.1s; transition: color 0.1s;
${mixin.clickable} ${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 { &:hover {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
i { i {
position: absolute; position: absolute;
left: 27px; left: 18px;
} }
`; `;
export const LinkText = styled.div` export const ItemText = styled.div`
position: relative; position: relative;
right: 12px; right: 12px;
visibility: hidden; visibility: hidden;

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ export const StyledToast = styled.div`
margin-bottom: 5px; margin-bottom: 5px;
width: 300px; width: 300px;
padding: 15px 20px; padding: 15px 20px;
border-radius: 4px; border-radius: 3px;
color: #fff; color: #fff;
background: ${props => color[props.type]}; background: ${props => color[props.type]};
cursor: pointer; 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 { CSSTransition, TransitionGroup } from 'react-transition-group';
import pubsub from 'sweet-pubsub'; import pubsub from 'sweet-pubsub';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
@@ -6,57 +6,44 @@ import { uniqueId } from 'lodash';
import { Icon } from 'shared/components'; import { Icon } from 'shared/components';
import { Container, StyledToast, Title, Message } from './Styles'; import { Container, StyledToast, Title, Message } from './Styles';
class Toast extends Component { const Toast = () => {
state = { toasts: [] }; const [toasts, setToasts] = useState([]);
componentDidMount() { useEffect(() => {
pubsub.on('toast', this.addToast); const addToast = ({ type = 'success', title, message, duration = 5 }) => {
} const id = uniqueId();
componentWillUnmount() { setToasts(currentToasts => [...currentToasts, { id, type, title, message }]);
pubsub.off('toast', this.addToast);
}
addToast = ({ type = 'success', title, message, duration = 5 }) => { if (duration) {
const id = uniqueId('toast-'); setTimeout(() => removeToast(id), duration * 1000);
}
};
pubsub.on('toast', addToast);
return () => {
pubsub.off('toast', addToast);
};
}, []);
this.setState(state => ({ const removeToast = id => {
toasts: [...state.toasts, { id, type, title, message }], setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id));
}));
if (duration) {
setTimeout(() => this.removeToast(id), duration * 1000);
}
}; };
removeToast = id => { return (
this.setState(state => ({ <Container>
toasts: state.toasts.filter(toast => toast.id !== id), <TransitionGroup>
})); {toasts.map(toast => (
}; <CSSTransition key={toast.id} classNames="jira-toast" timeout={200}>
<StyledToast key={toast.id} type={toast.type} onClick={() => removeToast(toast.id)}>
render() { <Icon type="close" />
const { toasts } = this.state; {toast.title && <Title>{toast.title}</Title>}
return ( {toast.message && <Message>{toast.message}</Message>}
<Container> </StyledToast>
<TransitionGroup> </CSSTransition>
{toasts.map(toast => ( ))}
<CSSTransition key={toast.id} classNames="jira-toast" timeout={200}> </TransitionGroup>
<StyledToast </Container>
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>
);
}
}
export default Toast; 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; width: ${props => props.size}px;
height: ${props => props.size}px; height: ${props => props.size}px;
border-radius: 100%; border-radius: 100%;
background-image: url('${props => props.avatarUrl}'); ${props => mixin.backgroundImage(props.avatarUrl)}
${mixin.backgroundImage}
`; `;
export const Letter = styled.div` export const Letter = styled.div`

View File

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

View File

@@ -4,83 +4,79 @@ import Spinner from 'shared/components/Spinner';
import { color, font, mixin } from 'shared/utils/styles'; import { color, font, mixin } from 'shared/utils/styles';
export const StyledButton = styled.button` export const StyledButton = styled.button`
display: inline-block; display: inline-flex;
height: 36px; align-items: center;
line-height: 34px; justify-content: center;
padding: 0 18px; height: 32px;
vertical-align: middle; line-height: 1;
padding: 0 ${props => (props.iconOnly ? 9 : 12)}px;
white-space: nowrap; white-space: nowrap;
text-align: center; border-radius: 3px;
border-radius: 4px;
transition: all 0.1s; transition: all 0.1s;
appearance: none !important; appearance: none;
${mixin.clickable} ${mixin.clickable}
${font.bold} ${font.size(14.5)}
${font.size(14)} ${props => buttonColors[props.color]}
${props => (props.hollow ? hollowStyles : filledStyles)}
&:disabled { &:disabled {
opacity: 0.6; opacity: 0.6;
cursor: default; cursor: default;
} }
i { i {
position: relative; margin-right: ${props => (props.iconOnly ? 0 : 7)}px;
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;
} }
`; `;
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)` export const StyledSpinner = styled(Spinner)`
position: relative; position: relative;
right: 8px; top: 1px;
display: inline-block; margin-right: ${props => (props.iconOnly ? 0 : 7)}px;
vertical-align: middle;
line-height: 1;
`; `;

View File

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

View File

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

View File

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

View File

@@ -4,42 +4,30 @@ import PropTypes from 'prop-types';
import StyledIcon from './Styles'; import StyledIcon from './Styles';
const codes = { const codes = {
[`check-circle`]: '\\e86c', [`bug`]: '\\e90f',
[`check-fat`]: '\\f00c', [`stopwatch`]: '\\e914',
[`arrow-left`]: '\\e900', [`task`]: '\\e910',
[`arrow-right`]: '\\e912', [`story`]: '\\e911',
[`upload-thin`]: '\\e91f', [`arrow-down`]: '\\e90a',
[`bell`]: '\\e901', [`arrow-left-circle`]: '\\e917',
[`calendar`]: '\\e903', [`arrow-up`]: '\\e90b',
[`check`]: '\\e904', [`chevron-down`]: '\\e900',
[`chevron-down`]: '\\e905', [`chevron-left`]: '\\e901',
[`chevron-left`]: '\\e906', [`chevron-right`]: '\\e902',
[`chevron-right`]: '\\e907', [`chevron-up`]: '\\e903',
[`chevron-up`]: '\\e908', [`board`]: '\\e904',
[`clock`]: '\\e909', [`help`]: '\\e905',
[`download`]: '\\e90a', [`link`]: '\\e90c',
[`plus`]: '\\e90c', [`menu`]: '\\e916',
[`refresh`]: '\\e90d', [`more`]: '\\e90e',
[`search`]: '\\e90e', [`attach`]: '\\e90d',
[`upload`]: '\\e90f', [`plus`]: '\\e906',
[`close`]: '\\e910', [`search`]: '\\e907',
[`archive`]: '\\e915', [`issues`]: '\\e908',
[`briefcase`]: '\\e916', [`settings`]: '\\e909',
[`settings`]: '\\e902', [`close`]: '\\e913',
[`email`]: '\\e914', [`help-filled`]: '\\e912',
[`lock`]: '\\e913', [`feedback`]: '\\e915',
[`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',
}; };
const propTypes = { const propTypes = {

View File

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

View File

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

View File

@@ -3,17 +3,17 @@ import PropTypes from 'prop-types';
const propTypes = { const propTypes = {
className: PropTypes.string, className: PropTypes.string,
width: PropTypes.number, size: PropTypes.number,
}; };
const defaultProps = { const defaultProps = {
className: undefined, className: undefined,
width: 28, size: 28,
}; };
const Logo = ({ className, width }) => ( const Logo = ({ className, size }) => (
<span className={className}> <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> <defs>
<linearGradient <linearGradient
id="linear-gradient" id="linear-gradient"

View File

@@ -15,7 +15,7 @@ export const ScrollOverlay = styled.div`
export const ClickableOverlay = styled.div` export const ClickableOverlay = styled.div`
min-height: 100%; min-height: 100%;
background: ${mixin.rgba(color.textLightBlue, 0.7)}; background: ${mixin.rgba(color.textLight, 0.7)};
${props => clickOverlayStyles[props.variant]} ${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` export default styled.div`
width: 100%; width: 100%;
padding: 100px; padding-top: 200px;
text-align: center; 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 => { const handleInputKeyDown = event => {
if (event.keyCode === KeyCodes.escape) { if (event.keyCode === KeyCodes.ESCAPE) {
handleInputEscapeKeyDown(event); handleInputEscapeKeyDown(event);
} else if (event.keyCode === KeyCodes.enter) { } else if (event.keyCode === KeyCodes.ENTER) {
handleInputEnterKeyDown(event); 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); handleInputArrowUpOrDownKeyDown(event);
} }
}; };
@@ -101,7 +101,7 @@ const SelectDropdown = ({
const $optionsHeight = $options.getBoundingClientRect().height; const $optionsHeight = $options.getBoundingClientRect().height;
const $activeHeight = $active.getBoundingClientRect().height; const $activeHeight = $active.getBoundingClientRect().height;
if (event.keyCode === KeyCodes.arrowDown) { if (event.keyCode === KeyCodes.ARROW_DOWN) {
if ($options.lastElementChild === $active) { if ($options.lastElementChild === $active) {
$active.classList.remove(activeOptionClass); $active.classList.remove(activeOptionClass);
$options.firstElementChild.classList.add(activeOptionClass); $options.firstElementChild.classList.add(activeOptionClass);
@@ -113,7 +113,7 @@ const SelectDropdown = ({
$options.scrollTop += $activeHeight; $options.scrollTop += $activeHeight;
} }
} }
} else if (event.keyCode === KeyCodes.arrowUp) { } else if (event.keyCode === KeyCodes.ARROW_UP) {
if ($options.firstElementChild === $active) { if ($options.firstElementChild === $active) {
$active.classList.remove(activeOptionClass); $active.classList.remove(activeOptionClass);
$options.lastElementChild.classList.add(activeOptionClass); $options.lastElementChild.classList.add(activeOptionClass);

View File

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

View File

@@ -93,10 +93,10 @@ const Select = ({
const handleFocusedSelectKeydown = event => { const handleFocusedSelectKeydown = event => {
if (isDropdownOpen) return; if (isDropdownOpen) return;
if (event.keyCode === KeyCodes.enter) { if (event.keyCode === KeyCodes.ENTER) {
event.preventDefault(); 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); setDropdownOpen(true);
} }
}; };

View File

@@ -6,17 +6,18 @@ export default styled.div`
display: inline-block; display: inline-block;
width: 100%; width: 100%;
textarea { 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; overflow-y: hidden;
width: 100%;
padding: 6px 7px 7px;
border-radius: 3px;
border: 1px solid ${color.borderLightest};
background: ${color.backgroundLightest};
${font.regular} ${font.regular}
${font.size(14)} ${font.size(15)}
&:focus { &: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}; }` : '')} ${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}> <StyledTextarea className={className} invalid={invalid}>
<TextareaAutoSize <TextareaAutoSize
{...textareaProps} {...textareaProps}
onChange={event => onChange(event, event.target.value)} onChange={event => onChange(event.target.value, event)}
ref={ref} ref={ref}
/> />
</StyledTextarea> </StyledTextarea>

View File

@@ -6,7 +6,9 @@ export { default as Icon } from './Icon';
export { default as Input } from './Input'; export { default as Input } from './Input';
export { default as Logo } from './Logo'; export { default as Logo } from './Logo';
export { default as Modal } from './Modal'; export { default as Modal } from './Modal';
export { default as PageError } from './PageError';
export { default as PageLoader } from './PageLoader'; export { default as PageLoader } from './PageLoader';
export { default as ProjectAvatar } from './ProjectAvatar';
export { default as Select } from './Select'; export { default as Select } from './Select';
export { default as Spinner } from './Spinner'; export { default as Spinner } from './Spinner';
export { default as Textarea } from './Textarea'; 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 = { export const KeyCodes = {
escape: 27, ESCAPE: 27,
tab: 9, TAB: 9,
enter: 13, ENTER: 13,
arrowUp: 38, ARROW_UP: 38,
arrowDown: 40, 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) => { const useOnEscapeKeyDown = (isListening, onEscapeKeyDown) => {
useEffect(() => { useEffect(() => {
const handleKeyDown = event => { const handleKeyDown = event => {
if (event.keyCode === KeyCodes.escape) { if (event.keyCode === KeyCodes.ESCAPE) {
onEscapeKeyDown(); 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'; import Color from 'color';
export const color = { export const color = {
primary: '#2553B3', // blue primary: '#0052cc', // Blue
success: '#29A638', // green success: '#29A638', // green
danger: '#E13C3C', // red danger: '#E13C3C', // red
warning: '#F89C1C', // orange warning: '#F89C1C', // orange
accent: '#8A46D7', // purple secondary: '#F4F5F7', // light grey
textDarkest: '#323232', textDarkest: '#172b4d',
textDark: '#616161', textDark: '#42526E',
textMedium: '#75787D', textMedium: '#5E6C84',
textMediumBlue: '#78869F', textLight: '#8993a4',
textLight: '#959595', textLink: '#0052cc',
textLightBlue: '#96A1B5',
backgroundDark: '#8393AD', backgroundDarkPrimary: '#0747A6',
backgroundMedium: '#D8DDE6', backgroundMedium: '#dfe1e6',
backgroundLight: '#F7F9FB', backgroundLight: '#ebecf0',
backgroundLightest: '#F4F5F7',
borderLightest: '#E1E6F0', borderLightest: '#dfe1e6',
borderLight: '#D8DDE6', borderLight: '#C1C7D0',
borderMedium: '#B9BDC4', borderInputFocus: '#4c9aff',
borderBlue: '#C5D3EB', };
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 = { export const sizes = {
appNavBarLeftWidth: 75, appNavBarLeftWidth: 64,
secondarySideBarWidth: 240,
minViewportWidth: 1000, minViewportWidth: 1000,
secondarySideBarWidth: 230,
}; };
export const zIndexValues = { export const zIndexValues = {
@@ -130,13 +143,14 @@ export const mixin = {
background: ${background}; background: ${background};
} }
`, `,
backgroundImage: ` backgroundImage: imageURL => `
background-image: url("${imageURL}");
background-position: 50% 50%; background-position: 50% 50%;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
background-color: ${color.backgroundLight}; background-color: ${color.backgroundLight};
`, `,
link: (colorValue = color.primary) => ` link: (colorValue = color.textLink) => `
cursor: pointer; cursor: pointer;
color: ${colorValue}; color: ${colorValue};
${font.medium} ${font.medium}
@@ -147,22 +161,4 @@ export const mixin = {
text-decoration: underline; 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;
}
`,
}; };