Implemented kanban board page with lists of issues
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
import express from 'express';
|
||||
|
||||
import { User } from 'entities';
|
||||
import { catchErrors } from 'errors';
|
||||
import { createEntity } from 'utils/typeorm';
|
||||
import { signToken } from 'utils/authToken';
|
||||
import seedGuestUserEntities from 'database/seeds/guestUser';
|
||||
|
||||
@@ -10,9 +8,8 @@ const router = express.Router();
|
||||
|
||||
router.post(
|
||||
'/authentication/guest',
|
||||
catchErrors(async (req, res) => {
|
||||
const user = await createEntity(User, req.body);
|
||||
await seedGuestUserEntities(user);
|
||||
catchErrors(async (_req, res) => {
|
||||
const user = await seedGuestUserEntities();
|
||||
res.respond({
|
||||
authToken: signToken({ sub: user.id }),
|
||||
});
|
||||
|
||||
@@ -2,36 +2,20 @@ import express from 'express';
|
||||
|
||||
import { Project } from 'entities';
|
||||
import { catchErrors } from 'errors';
|
||||
import { findEntityOrThrow, updateEntity, deleteEntity, createEntity } from 'utils/typeorm';
|
||||
import { findEntityOrThrow, updateEntity } from 'utils/typeorm';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/projects',
|
||||
catchErrors(async (_req, res) => {
|
||||
const projects = await Project.find();
|
||||
res.respond({ projects });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/projects/:projectId',
|
||||
'/project',
|
||||
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'],
|
||||
});
|
||||
res.respond({ project });
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/projects',
|
||||
catchErrors(async (req, res) => {
|
||||
const project = await createEntity(Project, req.body);
|
||||
res.respond({ project });
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/projects/:projectId',
|
||||
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;
|
||||
|
||||
14
api/src/controllers/users.ts
Normal file
14
api/src/controllers/users.ts
Normal 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;
|
||||
@@ -4,6 +4,7 @@ import User from 'entities/User';
|
||||
|
||||
const generateUser = (data: Partial<User> = {}): Partial<User> => ({
|
||||
name: faker.company.companyName(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
email: faker.internet.email(),
|
||||
...data,
|
||||
});
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import faker from 'faker';
|
||||
import { sample } from 'lodash';
|
||||
|
||||
import { Comment, Issue, Project, User } from 'entities';
|
||||
import { ProjectCategory } from 'constants/projects';
|
||||
import { IssueType, IssueStatus, IssuePriority } from 'constants/issues';
|
||||
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, {
|
||||
name: 'Project: Hello World',
|
||||
name: 'singularity 1.0',
|
||||
url: 'https://www.atlassian.com/software/jira',
|
||||
description:
|
||||
'Plan, track, and manage your agile and software development projects in Jira. Customize your workflow, collaborate, and release great software.',
|
||||
category: ProjectCategory.SOFTWARE,
|
||||
users: [user],
|
||||
users,
|
||||
});
|
||||
|
||||
const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
const user = project.users[0];
|
||||
const getRandomUser = (): User => sample(project.users) as User;
|
||||
const issues = [
|
||||
createEntity(Issue, {
|
||||
title: 'This is an issue of type: Task.',
|
||||
@@ -22,9 +41,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
status: IssueStatus.BACKLOG,
|
||||
priority: IssuePriority.LOWEST,
|
||||
estimate: 8,
|
||||
reporterId: user.id,
|
||||
reporterId: getRandomUser().id,
|
||||
project,
|
||||
users: [user],
|
||||
users: [getRandomUser()],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: "Click on an issue to see what's behind it.",
|
||||
@@ -33,7 +52,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
priority: IssuePriority.LOW,
|
||||
description: 'Nothing in particular.',
|
||||
estimate: 40,
|
||||
reporterId: user.id,
|
||||
reporterId: getRandomUser().id,
|
||||
project,
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
@@ -42,9 +61,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
status: IssueStatus.BACKLOG,
|
||||
priority: IssuePriority.MEDIUM,
|
||||
estimate: 15,
|
||||
reporterId: user.id,
|
||||
reporterId: getRandomUser().id,
|
||||
project,
|
||||
users: [user],
|
||||
users: [getRandomUser()],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: 'You can use markdown for issue descriptions.',
|
||||
@@ -54,9 +73,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
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",
|
||||
estimate: 4,
|
||||
reporterId: user.id,
|
||||
reporterId: getRandomUser().id,
|
||||
project,
|
||||
users: [user],
|
||||
users: [getRandomUser()],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
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,
|
||||
priority: IssuePriority.HIGHEST,
|
||||
estimate: 15,
|
||||
reporterId: user.id,
|
||||
reporterId: getRandomUser().id,
|
||||
project,
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
@@ -73,9 +92,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
status: IssueStatus.SELECTED,
|
||||
priority: IssuePriority.MEDIUM,
|
||||
estimate: 55,
|
||||
reporterId: user.id,
|
||||
reporterId: getRandomUser().id,
|
||||
project,
|
||||
users: [user],
|
||||
users: [getRandomUser()],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: 'Try leaving a comment on this issue.',
|
||||
@@ -83,7 +102,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
status: IssueStatus.SELECTED,
|
||||
priority: IssuePriority.MEDIUM,
|
||||
estimate: 12,
|
||||
reporterId: user.id,
|
||||
reporterId: getRandomUser().id,
|
||||
project,
|
||||
}),
|
||||
];
|
||||
@@ -97,10 +116,12 @@ const seedComments = (issue: Issue, user: User): Promise<Comment> =>
|
||||
user,
|
||||
});
|
||||
|
||||
const seedGuestUserEntities = async (user: User): Promise<void> => {
|
||||
const project = await seedProject(user);
|
||||
const seedGuestUserEntities = async (): Promise<User> => {
|
||||
const users = await seedUsers();
|
||||
const project = await seedProject(users);
|
||||
const issues = await seedIssues(project);
|
||||
await seedComments(issues[issues.length - 1], project.users[0]);
|
||||
return users[0];
|
||||
};
|
||||
|
||||
export default seedGuestUserEntities;
|
||||
|
||||
@@ -41,16 +41,16 @@ class Issue extends BaseEntity {
|
||||
@Column('varchar')
|
||||
priority: IssuePriority;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
@Column('text', { nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
@Column('integer', { nullable: true })
|
||||
estimate: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
@Column('integer', { nullable: true })
|
||||
timeSpent: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
@Column('integer', { nullable: true })
|
||||
timeRemaining: number | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
ManyToMany,
|
||||
JoinTable,
|
||||
} from 'typeorm';
|
||||
|
||||
import is from 'utils/validation';
|
||||
@@ -32,7 +30,7 @@ class Project extends BaseEntity {
|
||||
@Column('varchar', { nullable: true })
|
||||
url: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
@Column('text', { nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column('varchar')
|
||||
@@ -50,11 +48,10 @@ class Project extends BaseEntity {
|
||||
)
|
||||
issues: Issue[];
|
||||
|
||||
@ManyToMany(
|
||||
@OneToMany(
|
||||
() => User,
|
||||
user => user.projects,
|
||||
user => user.project,
|
||||
)
|
||||
@JoinTable()
|
||||
users: User[];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
RelationId,
|
||||
} from 'typeorm';
|
||||
|
||||
import is from 'utils/validation';
|
||||
@@ -28,6 +30,9 @@ class User extends BaseEntity {
|
||||
@Column('varchar')
|
||||
email: string;
|
||||
|
||||
@Column('varchar', { length: 2000 })
|
||||
avatarUrl: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@@ -46,11 +51,14 @@ class User extends BaseEntity {
|
||||
)
|
||||
issues: Issue[];
|
||||
|
||||
@ManyToMany(
|
||||
@ManyToOne(
|
||||
() => Project,
|
||||
project => project.users,
|
||||
)
|
||||
projects: Project[];
|
||||
project: Project;
|
||||
|
||||
@RelationId((user: User) => user.project)
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
export default User;
|
||||
|
||||
@@ -17,5 +17,5 @@ export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||
data: {},
|
||||
};
|
||||
|
||||
res.status(errorData.status).send({ errors: [errorData] });
|
||||
res.status(errorData.status).send({ error: errorData });
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { authenticateUser } from 'middleware/authentication';
|
||||
import authenticationRoutes from 'controllers/authentication';
|
||||
import projectsRoutes from 'controllers/projects';
|
||||
import issuesRoutes from 'controllers/issues';
|
||||
import usersRoutes from 'controllers/users';
|
||||
import { RouteNotFoundError } from 'errors';
|
||||
import { errorHandler } from 'errors/errorHandler';
|
||||
|
||||
@@ -30,7 +31,7 @@ const initializeExpress = (): void => {
|
||||
|
||||
app.use((_req, res, next) => {
|
||||
res.respond = (data): void => {
|
||||
res.status(200).send({ data });
|
||||
res.status(200).send(data);
|
||||
};
|
||||
next();
|
||||
});
|
||||
@@ -41,6 +42,7 @@ const initializeExpress = (): void => {
|
||||
|
||||
app.use('/', projectsRoutes);
|
||||
app.use('/', issuesRoutes);
|
||||
app.use('/', usersRoutes);
|
||||
|
||||
app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl)));
|
||||
app.use(errorHandler);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
import { verifyToken } from 'utils/authToken';
|
||||
import { findEntityOrThrow } from 'utils/typeorm';
|
||||
import { catchErrors, InvalidTokenError } from 'errors';
|
||||
import { User } from 'entities';
|
||||
|
||||
@@ -20,6 +19,10 @@ export const authenticateUser = catchErrors(async (req, _res, next) => {
|
||||
if (!userId) {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { InvalidTokenError } from 'errors';
|
||||
|
||||
export const signToken = (payload: object, options?: SignOptions): string =>
|
||||
jwt.sign(payload, process.env.JWT_SECRET, {
|
||||
expiresIn: '7 days',
|
||||
expiresIn: '180 days',
|
||||
...options,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = () => (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
29
client/src/components/App/Authenticate.jsx
Normal file
29
client/src/components/App/Authenticate.jsx
Normal 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;
|
||||
@@ -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)}
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
`;
|
||||
@@ -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;
|
||||
55
client/src/components/Project/Board/Filters/Styles.js
Normal file
55
client/src/components/Project/Board/Filters/Styles.js
Normal 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};
|
||||
}
|
||||
`;
|
||||
91
client/src/components/Project/Board/Filters/index.jsx
Normal file
91
client/src/components/Project/Board/Filters/index.jsx
Normal 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;
|
||||
26
client/src/components/Project/Board/Header/Styles.js
Normal file
26
client/src/components/Project/Board/Header/Styles.js
Normal 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}
|
||||
`;
|
||||
42
client/src/components/Project/Board/Header/index.jsx
Normal file
42
client/src/components/Project/Board/Header/index.jsx
Normal 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;
|
||||
80
client/src/components/Project/Board/Lists/Styles.js
Normal file
80
client/src/components/Project/Board/Lists/Styles.js
Normal 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;
|
||||
`;
|
||||
92
client/src/components/Project/Board/Lists/index.jsx
Normal file
92
client/src/components/Project/Board/Lists/index.jsx
Normal 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;
|
||||
32
client/src/components/Project/Board/index.jsx
Normal file
32
client/src/components/Project/Board/index.jsx
Normal 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;
|
||||
56
client/src/components/Project/Sidebar/Styles.js
Normal file
56
client/src/components/Project/Sidebar/Styles.js
Normal 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)};
|
||||
`;
|
||||
45
client/src/components/Project/Sidebar/index.jsx
Normal file
45
client/src/components/Project/Sidebar/index.jsx
Normal 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;
|
||||
7
client/src/components/Project/Styles.js
Normal file
7
client/src/components/Project/Styles.js
Normal 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;
|
||||
`;
|
||||
24
client/src/components/Project/index.jsx
Normal file
24
client/src/components/Project/index.jsx
Normal 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 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ const ConfirmModal = ({
|
||||
{confirmInput && (
|
||||
<>
|
||||
<InputLabel>{`Type ${confirmInput} below to confirm.`}</InputLabel>
|
||||
<StyledInput onChange={(event, value) => handleConfirmInputChange(value)} />
|
||||
<StyledInput onChange={handleConfirmInputChange} />
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 : '')}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]}
|
||||
`;
|
||||
|
||||
|
||||
44
client/src/shared/components/PageError/Styles.js
Normal file
44
client/src/shared/components/PageError/Styles.js
Normal 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 |
21
client/src/shared/components/PageError/index.jsx
Normal file
21
client/src/shared/components/PageError/index.jsx
Normal 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>There’s been a glitch…</Title>
|
||||
<p>
|
||||
{'We’re 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;
|
||||
@@ -2,6 +2,6 @@ import styled from 'styled-components';
|
||||
|
||||
export default styled.div`
|
||||
width: 100%;
|
||||
padding: 100px;
|
||||
padding-top: 200px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
120
client/src/shared/components/ProjectAvatar.jsx
Normal file
120
client/src/shared/components/ProjectAvatar.jsx
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}; }` : '')}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
20
client/src/shared/constants/issues.js
Normal file
20
client/src/shared/constants/issues.js
Normal 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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
71
client/src/shared/hooks/api.js
Normal file
71
client/src/shared/hooks/api.js
Normal 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),
|
||||
};
|
||||
19
client/src/shared/hooks/debounceValue.js
Normal file
19
client/src/shared/hooks/debounceValue.js
Normal 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;
|
||||
14
client/src/shared/hooks/deepCompareMemoize.js
Normal file
14
client/src/shared/hooks/deepCompareMemoize.js
Normal 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;
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
54
client/src/shared/utils/api.js
Normal file
54
client/src/shared/utils/api.js
Normal 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),
|
||||
};
|
||||
3
client/src/shared/utils/authToken.js
Normal file
3
client/src/shared/utils/authToken.js
Normal 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');
|
||||
8
client/src/shared/utils/clipboard.js
Normal file
8
client/src/shared/utils/clipboard.js
Normal 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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user