=> {
const { users } = project;
const issues = [
createEntity(Issue, {
title: 'This is an issue of type: Task.',
type: IssueType.TASK,
status: IssueStatus.BACKLOG,
priority: IssuePriority.HIGH,
listPosition: 1,
description: `Your teams can collaborate in Jira applications by breaking down pieces of work into issues. Issues can represent tasks, software bugs, feature requests or any other type of project work.
Jira Software (software projects) issue types:
Bug 🐞 A bug is a problem which impairs or prevents the functions of a product.
Story 📗 A user story is the smallest unit of work that needs to be done.
Task 🗳 A task represents work that needs to be done.
`,
estimate: 8,
timeSpent: 4,
reporterId: users[1].id,
project,
users: [users[0]],
}),
createEntity(Issue, {
title: "Click on an issue to see what's behind it.",
type: IssueType.TASK,
status: IssueStatus.BACKLOG,
priority: IssuePriority.LOW,
listPosition: 2,
description: `Key terms to know
Issues A Jira 'issue' refers to a single work item of any type or size that is tracked from creation to completion. For example, an issue could be a feature being developed by a software team, a to-do item for a marketing team, or a contract that needs to be written by a legal team.
Projects A project is, quite simply, a collection of issues that are held in common by purpose or context. Issues grouped into projects can be configured in a variety of ways, ranging from visibility restrictions to available workflows.
Workflows Workflows represent the sequential path an issues takes from creation to completion. A basic workflow might look something like this:
In this case, Open, Done, and the labels in between represent the status an issue can take, while the arrows represent potential transitions from one status to another.
Agile Agile is not a Jira Software-specific term. It's a work philosophy that originated in the software development field and has since expanded to a variety of other industries. While we won't belabor the definition here (there are great resources for that!), agile emphasizes an iterative approach to work informed by customer feedback where delivery occurs incrementally and continuously. The ideal agile team can move quickly and adapt to changing requirements without missing much of a beat.
Server With Jira Software Server, you host Jira Software on your own hardware and customize your setup however you'd like. This is generally the best option for teams who need to manage all the details, have stricter requirements for data governance, and don't mind the additional complexity of hosting themselves.
Data Center
With Jira Software Data Center, you can host Jira Software on your own hardware or with IaaS vendors like AWS and Azure . This is generally the best option for enterprise teams who need uninterrupted access to Jira Software and performance at scale.
`,
estimate: 5,
timeSpent: 2,
reporterId: users[2].id,
project,
users: [users[0]],
}),
createEntity(Issue, {
title: 'Try dragging issues to different columns to transition their status.',
type: IssueType.STORY,
status: IssueStatus.BACKLOG,
priority: IssuePriority.MEDIUM,
listPosition: 3,
description: `An issue's status indicates its current place in the project's workflow. Here's a list of the statuses that come with JIRA products, depending on what projects you've created on your site.
Jira software issue statuses:
Backlog The issue is waiting to be picked up in a future sprint.
Selected The issue is open and ready for the assignee to start work on it.
In Progress This issue is being actively worked on at the moment by the assignee.
Done Work has finished on the issue.
`,
estimate: 15,
timeSpent: 12,
reporterId: users[1].id,
project,
}),
createEntity(Issue, {
title: 'You can use rich text with images in issue descriptions.',
type: IssueType.STORY,
status: IssueStatus.BACKLOG,
priority: IssuePriority.LOWEST,
listPosition: 4,
description: `🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🍈 🍒 🍑 🍍 🥭 🥥 🥝 🍅 🍆 🥑 🥦 🥒 🥬 🌶 🌽 🥕 🥔 🍠 🥐 🍞 🥖 🥨 🥯 🧀 🥚 🍳 🥞 🥓 🥩 🍗 🍖 🌭 🍔 🍟 🍕 🥪 🥙 🌮 🌯 🥗 🥘 🥫 🍝 🍜 🍲 🍛 🍣 🍱 🥟 🍤 🍙 🍚 🍘 🍥 🥮 🥠 🍢 🍡 🍧 🍨 🍦 🥧 🍰 🎂 🍮 🍭 🍬 🍫 🍿 🧂 🍩 🍪 🌰 🥜 🍯 🥛 🍼 ☕️ 🍵 🥤 🍶 🍺 🍻 🥂 🍷 🥃 🍸 🍹 🍾 🥄 🍴 🍽 🥣 🥡 🥢 `,
estimate: 4,
timeSpent: 4,
reporterId: users[0].id,
project,
users: [users[2]],
}),
createEntity(Issue, {
title: 'Each issue can be assigned priority from lowest to highest.',
type: IssueType.TASK,
status: IssueStatus.SELECTED,
priority: IssuePriority.HIGHEST,
listPosition: 5,
description: `An issue's priority indicates its relative importance. The default priorities are listed below. Both the priorities and their meanings can be customized by your administrator to suit your organization. Learn more about configuring priorities and their descriptions .
Jira software issue priorities:
Highest ⬆️ This problem will block progress.
High ⬆️ Serious problem that could block progress.
Medium ⬆️ Has the potential to affect progress.
Low ⬇️ Minor problem or easily worked around.
Lowest ⬇️ Trivial problem with little or no impact on progress.
`,
estimate: 4,
timeSpent: 1,
reporterId: users[2].id,
project,
}),
createEntity(Issue, {
title: 'Each issue has a single reporter but can have multiple assignees.',
type: IssueType.STORY,
status: IssueStatus.SELECTED,
priority: IssuePriority.HIGH,
listPosition: 6,
description: `Try assigning Pickle Rick to this issue. 🥒 🥒 🥒
`,
estimate: 6,
timeSpent: 3,
reporterId: users[1].id,
project,
users: [users[1], users[2]],
}),
createEntity(Issue, {
title:
'You can track how many hours were spent working on an issue, and how many hours remain.',
type: IssueType.TASK,
status: IssueStatus.INPROGRESS,
priority: IssuePriority.LOWEST,
listPosition: 7,
description: `Before you start work on an issue, you can set a time or other type of estimate to calculate how much work you believe it'll take to resolve it. Once you've started to work on a specific issue, log time to keep a record of it.
Open the issue and select ••• > Time tracking Fill in the Time Spent field Fill in the Time Remaining field and click Save
That's it! 💯💯 `,
estimate: 12,
timeSpent: 11,
reporterId: users[0].id,
project,
}),
createEntity(Issue, {
title: 'Try leaving a comment on this issue.',
type: IssueType.TASK,
status: IssueStatus.DONE,
priority: IssuePriority.MEDIUM,
listPosition: 7,
description: `Adding comments to an issue is a useful way to record additional detail about an issue, and collaborate with team members. Comments are shown in the Comments section when you view an issue .
Open the issue on which to add your comment. Click the Add a comment button. Keyboard shortcut : m In the Comment text box, type your comment, using as many lines as you require. Click the Save button to save the comment. `,
estimate: 10,
timeSpent: 2,
reporterId: users[0].id,
project,
users: [users[1]],
}),
];
return Promise.all(issues);
};
const seedComments = (issues: Issue[], users: User[]): Promise => {
const comments = [
createEntity(Comment, {
body: 'An old silent pond...\nA frog jumps into the pond,\nsplash! Silence again.',
issueId: issues[0].id,
userId: users[2].id,
}),
createEntity(Comment, {
body: 'Autumn moonlight-\na worm digs silently\ninto the chestnut.',
issueId: issues[1].id,
userId: users[2].id,
}),
createEntity(Comment, {
body: 'In the twilight rain\nthese brilliant-hued hibiscus -\nA lovely sunset.',
issueId: issues[2].id,
userId: users[2].id,
}),
createEntity(Comment, {
body: 'A summer river being crossed\nhow pleasing\nwith sandals in my hands!',
issueId: issues[3].id,
userId: users[2].id,
}),
createEntity(Comment, {
body: "Light of the moon\nMoves west, flowers' shadows\nCreep eastward.",
issueId: issues[4].id,
userId: users[2].id,
}),
createEntity(Comment, {
body: 'In the moonlight,\nThe color and scent of the wisteria\nSeems far away.',
issueId: issues[5].id,
userId: users[2].id,
}),
createEntity(Comment, {
body: 'O snail\nClimb Mount Fuji,\nBut slowly, slowly!',
issueId: issues[6].id,
userId: users[2].id,
}),
createEntity(Comment, {
body: 'Everything I touch\nwith tenderness, alas,\npricks like a bramble.',
issueId: issues[7].id,
userId: users[2].id,
}),
];
return Promise.all(comments);
};
const createGuestAccount = async (): Promise => {
const users = await seedUsers();
const project = await seedProject(users);
const issues = await seedIssues(project);
await seedComments(issues, project.users);
return users[2];
};
export default createGuestAccount;
================================================
FILE: api/src/database/createTestAccount.ts
================================================
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 seedUsers = (): Promise => {
const users = [
createEntity(User, {
email: 'gaben@jira.test',
name: 'Gaben',
avatarUrl: 'https://i.ibb.co/6RJ5hq6/gaben.jpg',
}),
createEntity(User, {
email: 'yoda@jira.test',
name: 'Yoda',
avatarUrl: 'https://i.ibb.co/6n0hLML/baby-yoda.jpg',
}),
];
return Promise.all(users);
};
const seedProject = (users: User[]): Promise =>
createEntity(Project, {
name: 'Project name',
url: 'https://www.testurl.com',
description: 'Project description',
category: ProjectCategory.SOFTWARE,
users,
});
const seedIssues = (project: Project): Promise => {
const { users } = project;
const issues = [
createEntity(Issue, {
title: 'Issue title 1',
type: IssueType.TASK,
status: IssueStatus.BACKLOG,
priority: IssuePriority.LOWEST,
listPosition: 1,
reporterId: users[0].id,
project,
}),
createEntity(Issue, {
title: 'Issue title 2',
type: IssueType.TASK,
status: IssueStatus.BACKLOG,
priority: IssuePriority.MEDIUM,
listPosition: 2,
estimate: 5,
description: 'Issue description 2',
reporterId: users[0].id,
users: [users[0]],
project,
}),
createEntity(Issue, {
title: 'Issue title 3',
type: IssueType.STORY,
status: IssueStatus.SELECTED,
priority: IssuePriority.HIGH,
listPosition: 3,
estimate: 10,
description: 'Issue description 3',
reporterId: users[0].id,
users: [users[0], users[1]],
project,
}),
];
return Promise.all(issues);
};
const seedComments = (issue: Issue, user: User): Promise =>
createEntity(Comment, {
body: 'Comment body',
issueId: issue.id,
userId: user.id,
});
const createTestAccount = async (): Promise => {
const users = await seedUsers();
const project = await seedProject(users);
const issues = await seedIssues(project);
await seedComments(issues[0], project.users[0]);
return users[0];
};
export default createTestAccount;
================================================
FILE: api/src/database/resetDatabase.ts
================================================
import { getConnection } from 'typeorm';
const resetDatabase = async (): Promise => {
const connection = getConnection();
await connection.dropDatabase();
await connection.synchronize();
};
export default resetDatabase;
================================================
FILE: api/src/entities/Comment.ts
================================================
import {
BaseEntity,
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
} from 'typeorm';
import is from 'utils/validation';
import { Issue, User } from '.';
@Entity()
class Comment extends BaseEntity {
static validations = {
body: [is.required(), is.maxLength(50000)],
};
@PrimaryGeneratedColumn()
id: number;
@Column('text')
body: string;
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamp' })
updatedAt: Date;
@ManyToOne(
() => User,
user => user.comments,
)
user: User;
@Column('integer')
userId: number;
@ManyToOne(
() => Issue,
issue => issue.comments,
{ onDelete: 'CASCADE' },
)
issue: Issue;
@Column('integer')
issueId: number;
}
export default Comment;
================================================
FILE: api/src/entities/Issue.ts
================================================
import striptags from 'striptags';
import {
BaseEntity,
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
ManyToMany,
JoinTable,
RelationId,
BeforeUpdate,
BeforeInsert,
} from 'typeorm';
import is from 'utils/validation';
import { IssueType, IssueStatus, IssuePriority } from 'constants/issues';
import { Comment, Project, User } from '.';
@Entity()
class Issue extends BaseEntity {
static validations = {
title: [is.required(), is.maxLength(200)],
type: [is.required(), is.oneOf(Object.values(IssueType))],
status: [is.required(), is.oneOf(Object.values(IssueStatus))],
priority: [is.required(), is.oneOf(Object.values(IssuePriority))],
listPosition: is.required(),
reporterId: is.required(),
};
@PrimaryGeneratedColumn()
id: number;
@Column('varchar')
title: string;
@Column('varchar')
type: IssueType;
@Column('varchar')
status: IssueStatus;
@Column('varchar')
priority: IssuePriority;
@Column('double precision')
listPosition: number;
@Column('text', { nullable: true })
description: string | null;
@Column('text', { nullable: true })
descriptionText: string | null;
@Column('integer', { nullable: true })
estimate: number | null;
@Column('integer', { nullable: true })
timeSpent: number | null;
@Column('integer', { nullable: true })
timeRemaining: number | null;
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamp' })
updatedAt: Date;
@Column('integer')
reporterId: number;
@ManyToOne(
() => Project,
project => project.issues,
)
project: Project;
@Column('integer')
projectId: number;
@OneToMany(
() => Comment,
comment => comment.issue,
)
comments: Comment[];
@ManyToMany(
() => User,
user => user.issues,
)
@JoinTable()
users: User[];
@RelationId((issue: Issue) => issue.users)
userIds: number[];
@BeforeInsert()
@BeforeUpdate()
setDescriptionText = (): void => {
if (this.description) {
this.descriptionText = striptags(this.description);
}
};
}
export default Issue;
================================================
FILE: api/src/entities/Project.ts
================================================
import {
BaseEntity,
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import is from 'utils/validation';
import { ProjectCategory } from 'constants/projects';
import { Issue, User } from '.';
@Entity()
class Project extends BaseEntity {
static validations = {
name: [is.required(), is.maxLength(100)],
url: is.url(),
category: [is.required(), is.oneOf(Object.values(ProjectCategory))],
};
@PrimaryGeneratedColumn()
id: number;
@Column('varchar')
name: string;
@Column('varchar', { nullable: true })
url: string | null;
@Column('text', { nullable: true })
description: string | null;
@Column('varchar')
category: ProjectCategory;
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamp' })
updatedAt: Date;
@OneToMany(
() => Issue,
issue => issue.project,
)
issues: Issue[];
@OneToMany(
() => User,
user => user.project,
)
users: User[];
}
export default Project;
================================================
FILE: api/src/entities/User.ts
================================================
import {
BaseEntity,
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
ManyToMany,
ManyToOne,
RelationId,
} from 'typeorm';
import is from 'utils/validation';
import { Comment, Issue, Project } from '.';
@Entity()
class User extends BaseEntity {
static validations = {
name: [is.required(), is.maxLength(100)],
email: [is.required(), is.email(), is.maxLength(200)],
};
@PrimaryGeneratedColumn()
id: number;
@Column('varchar')
name: string;
@Column('varchar')
email: string;
@Column('varchar', { length: 2000 })
avatarUrl: string;
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamp' })
updatedAt: Date;
@OneToMany(
() => Comment,
comment => comment.user,
)
comments: Comment[];
@ManyToMany(
() => Issue,
issue => issue.users,
)
issues: Issue[];
@ManyToOne(
() => Project,
project => project.users,
)
project: Project;
@RelationId((user: User) => user.project)
projectId: number;
}
export default User;
================================================
FILE: api/src/entities/index.ts
================================================
export { default as Comment } from './Comment';
export { default as Issue } from './Issue';
export { default as Project } from './Project';
export { default as User } from './User';
================================================
FILE: api/src/errors/asyncCatch.ts
================================================
import { RequestHandler } from 'express';
export const catchErrors = (requestHandler: RequestHandler): RequestHandler => {
return async (req, res, next): Promise => {
try {
return await requestHandler(req, res, next);
} catch (error) {
next(error);
}
};
};
================================================
FILE: api/src/errors/customErrors.ts
================================================
/* eslint-disable max-classes-per-file */
type ErrorData = { [key: string]: any };
export class CustomError extends Error {
constructor(
public message: string,
public code: string | number = 'INTERNAL_ERROR',
public status: number = 500,
public data: ErrorData = {},
) {
super();
}
}
export class RouteNotFoundError extends CustomError {
constructor(originalUrl: string) {
super(`Route '${originalUrl}' does not exist.`, 'ROUTE_NOT_FOUND', 404);
}
}
export class EntityNotFoundError extends CustomError {
constructor(entityName: string) {
super(`${entityName} not found.`, 'ENTITY_NOT_FOUND', 404);
}
}
export class BadUserInputError extends CustomError {
constructor(errorData: ErrorData) {
super('There were validation errors.', 'BAD_USER_INPUT', 400, errorData);
}
}
export class InvalidTokenError extends CustomError {
constructor(message = 'Authentication token is invalid.') {
super(message, 'INVALID_TOKEN', 401);
}
}
================================================
FILE: api/src/errors/index.ts
================================================
export * from './customErrors';
export { catchErrors } from './asyncCatch';
================================================
FILE: api/src/index.ts
================================================
import 'module-alias/register';
import 'dotenv/config';
import 'reflect-metadata';
import express from 'express';
import cors from 'cors';
import createDatabaseConnection from 'database/createConnection';
import { addRespondToResponse } from 'middleware/response';
import { authenticateUser } from 'middleware/authentication';
import { handleError } from 'middleware/errors';
import { RouteNotFoundError } from 'errors';
import { attachPublicRoutes, attachPrivateRoutes } from './routes';
const establishDatabaseConnection = async (): Promise => {
try {
await createDatabaseConnection();
} catch (error) {
console.log(error);
}
};
const initializeExpress = (): void => {
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.urlencoded());
app.use(addRespondToResponse);
attachPublicRoutes(app);
app.use('/', authenticateUser);
attachPrivateRoutes(app);
app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl)));
app.use(handleError);
app.listen(process.env.PORT || 3000);
};
const initializeApp = async (): Promise => {
await establishDatabaseConnection();
initializeExpress();
};
initializeApp();
================================================
FILE: api/src/middleware/authentication.ts
================================================
import { Request } from 'express';
import { verifyToken } from 'utils/authToken';
import { catchErrors, InvalidTokenError } from 'errors';
import { User } from 'entities';
export const authenticateUser = catchErrors(async (req, _res, next) => {
const token = getAuthTokenFromRequest(req);
if (!token) {
throw new InvalidTokenError('Authentication token not found.');
}
const userId = verifyToken(token).sub;
if (!userId) {
throw new InvalidTokenError('Authentication token is invalid.');
}
const user = await User.findOne(userId);
if (!user) {
throw new InvalidTokenError('Authentication token is invalid: User not found.');
}
req.currentUser = user;
next();
});
const getAuthTokenFromRequest = (req: Request): string | null => {
const header = req.get('Authorization') || '';
const [bearer, token] = header.split(' ');
return bearer === 'Bearer' && token ? token : null;
};
================================================
FILE: api/src/middleware/errors.ts
================================================
import { ErrorRequestHandler } from 'express';
import { pick } from 'lodash';
import { CustomError } from 'errors';
export const handleError: ErrorRequestHandler = (error, _req, res, _next) => {
console.error(error);
const isErrorSafeForClient = error instanceof CustomError;
const clientError = isErrorSafeForClient
? pick(error, ['message', 'code', 'status', 'data'])
: {
message: 'Something went wrong, please contact our support.',
code: 'INTERNAL_ERROR',
status: 500,
data: {},
};
res.status(clientError.status).send({ error: clientError });
};
================================================
FILE: api/src/middleware/response.ts
================================================
import { RequestHandler } from 'express';
export const addRespondToResponse: RequestHandler = (_req, res, next) => {
res.respond = (data): void => {
res.status(200).send(data);
};
next();
};
================================================
FILE: api/src/routes.ts
================================================
import * as authentication from 'controllers/authentication';
import * as comments from 'controllers/comments';
import * as issues from 'controllers/issues';
import * as projects from 'controllers/projects';
import * as test from 'controllers/test';
import * as users from 'controllers/users';
export const attachPublicRoutes = (app: any): void => {
if (process.env.NODE_ENV === 'test') {
app.delete('/test/reset-database', test.resetDatabase);
app.post('/test/create-account', test.createAccount);
}
app.post('/authentication/guest', authentication.createGuestAccount);
};
export const attachPrivateRoutes = (app: any): void => {
app.post('/comments', comments.create);
app.put('/comments/:commentId', comments.update);
app.delete('/comments/:commentId', comments.remove);
app.get('/issues', issues.getProjectIssues);
app.get('/issues/:issueId', issues.getIssueWithUsersAndComments);
app.post('/issues', issues.create);
app.put('/issues/:issueId', issues.update);
app.delete('/issues/:issueId', issues.remove);
app.get('/project', projects.getProjectWithUsersAndIssues);
app.put('/project', projects.update);
app.get('/currentUser', users.getCurrentUser);
};
================================================
FILE: api/src/serializers/issues.ts
================================================
import { pick } from 'lodash';
import { Issue } from 'entities';
export const issuePartial = (issue: Issue): Partial =>
pick(issue, [
'id',
'title',
'type',
'status',
'priority',
'listPosition',
'createdAt',
'updatedAt',
'userIds',
]);
================================================
FILE: api/src/types/env.d.ts
================================================
declare namespace NodeJS {
export interface ProcessEnv {
DB_HOST: string;
DB_PORT: string;
DB_USERNAME: string;
DB_PASSWORD: string;
DB_DATABASE: string;
JWT_SECRET: string;
}
}
================================================
FILE: api/src/types/express.d.ts
================================================
declare namespace Express {
export interface Response {
respond: (data: any) => void;
}
export interface Request {
currentUser: import('entities').User;
}
}
================================================
FILE: api/src/utils/authToken.ts
================================================
import jwt, { SignOptions } from 'jsonwebtoken';
import { isPlainObject } from 'lodash';
import { InvalidTokenError } from 'errors';
export const signToken = (payload: object, options?: SignOptions): string =>
jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: '180 days',
...options,
});
export const verifyToken = (token: string): { [key: string]: any } => {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
if (isPlainObject(payload)) {
return payload as { [key: string]: any };
}
throw new Error();
} catch (error) {
throw new InvalidTokenError();
}
};
================================================
FILE: api/src/utils/typeorm.ts
================================================
import { FindOneOptions } from 'typeorm/find-options/FindOneOptions';
import { Project, User, Issue, Comment } from 'entities';
import { EntityNotFoundError, BadUserInputError } from 'errors';
import { generateErrors } from 'utils/validation';
type EntityConstructor = typeof Project | typeof User | typeof Issue | typeof Comment;
type EntityInstance = Project | User | Issue | Comment;
const entities: { [key: string]: EntityConstructor } = { Comment, Issue, Project, User };
export const findEntityOrThrow = async (
Constructor: T,
id: number | string,
options?: FindOneOptions,
): Promise> => {
const instance = await Constructor.findOne(id, options);
if (!instance) {
throw new EntityNotFoundError(Constructor.name);
}
return instance;
};
export const validateAndSaveEntity = async (instance: T): Promise => {
const Constructor = entities[instance.constructor.name];
if ('validations' in Constructor) {
const errorFields = generateErrors(instance, Constructor.validations);
if (Object.keys(errorFields).length > 0) {
throw new BadUserInputError({ fields: errorFields });
}
}
return instance.save() as Promise;
};
export const createEntity = async (
Constructor: T,
input: Partial>,
): Promise> => {
const instance = Constructor.create(input);
return validateAndSaveEntity(instance as InstanceType);
};
export const updateEntity = async (
Constructor: T,
id: number | string,
input: Partial>,
): Promise> => {
const instance = await findEntityOrThrow(Constructor, id);
Object.assign(instance, input);
return validateAndSaveEntity(instance);
};
export const deleteEntity = async (
Constructor: T,
id: number | string,
): Promise> => {
const instance = await findEntityOrThrow(Constructor, id);
await instance.remove();
return instance;
};
================================================
FILE: api/src/utils/validation.ts
================================================
type Value = any;
type ErrorMessage = false | string;
type FieldValues = { [key: string]: Value };
type Validator = (value: Value, fieldValues?: FieldValues) => ErrorMessage;
type FieldValidators = { [key: string]: Validator | Validator[] };
type FieldErrors = { [key: string]: string };
const is = {
match: (testFn: Function, message = '') => (
value: Value,
fieldValues: FieldValues,
): ErrorMessage => !testFn(value, fieldValues) && message,
required: () => (value: Value): ErrorMessage =>
isNilOrEmptyString(value) && 'This field is required',
minLength: (min: number) => (value: Value): ErrorMessage =>
!!value && value.length < min && `Must be at least ${min} characters`,
maxLength: (max: number) => (value: Value): ErrorMessage =>
!!value && value.length > max && `Must be at most ${max} characters`,
oneOf: (arr: any[]) => (value: Value): ErrorMessage =>
!!value && !arr.includes(value) && `Must be one of: ${arr.join(', ')}`,
notEmptyArray: () => (value: Value): ErrorMessage =>
Array.isArray(value) && value.length === 0 && 'Please add at least one item',
email: () => (value: Value): ErrorMessage =>
!!value && !/.+@.+\..+/.test(value) && 'Must be a valid email',
url: () => (value: Value): ErrorMessage =>
!!value &&
// eslint-disable-next-line no-useless-escape
!/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/.test(value) &&
'Must be a valid URL',
};
const isNilOrEmptyString = (value: Value): boolean =>
value === undefined || value === null || value === '';
export const generateErrors = (
fieldValues: FieldValues,
fieldValidators: FieldValidators,
): FieldErrors => {
const fieldErrors: FieldErrors = {};
Object.entries(fieldValidators).forEach(([fieldName, validators]) => {
[validators].flat().forEach(validator => {
const errorMessage = validator(fieldValues[fieldName], fieldValues);
if (errorMessage !== false && !fieldErrors[fieldName]) {
fieldErrors[fieldName] = errorMessage;
}
});
});
return fieldErrors;
};
export default is;
================================================
FILE: api/tsconfig-paths.js
================================================
const tsConfigPaths = require('tsconfig-paths');
const tsConfig = require('./tsconfig.json');
// Typescript compiler doesn't rewrite absolute paths back to relative
// when compiling production code to /build. Instead we have to use
// tsconfig-paths to do that job when we run our production start script.
// https://github.com/microsoft/TypeScript/issues/10866
tsConfigPaths.register({
baseUrl: tsConfig.compilerOptions.outDir,
paths: tsConfig.compilerOptions.paths,
});
================================================
FILE: api/tsconfig.json
================================================
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"lib": ["dom", "es6", "es2017", "es2019", "esnext.asynciterable"],
"sourceMap": true,
"outDir": "./build",
"removeComments": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": false,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"baseUrl": "src",
"paths": {
"*": ["./*"]
},
"types": ["node"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"forceConsistentCasingInFileNames": true
},
"exclude": ["node_modules"],
"include": ["./src/**/*.ts"]
}
================================================
FILE: client/.babelrc
================================================
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "entry",
"corejs": 3
}
],
"@babel/react"
],
"plugins": [
["@babel/plugin-proposal-decorators", { "legacy": true }],
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-syntax-dynamic-import",
["@babel/plugin-proposal-class-properties", { "loose": true }]
]
}
================================================
FILE: client/.eslintrc.json
================================================
{
"parser": "babel-eslint",
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 8,
"ecmaFeatures": {
"jsx": true
}
},
"env": {
"browser": true,
"jest": true
},
"extends": ["airbnb", "prettier", "prettier/react"],
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"radix": 0,
"no-restricted-syntax": 0,
"no-await-in-loop": 0,
"no-console": 0,
"consistent-return": 0,
"no-param-reassign": [2, { "props": false }],
"no-return-assign": [2, "except-parens"],
"no-use-before-define": 0,
"import/prefer-default-export": 0,
"import/no-cycle": 0,
"react/no-array-index-key": 0,
"react/forbid-prop-types": 0,
"react/prop-types": [2, { "skipUndeclared": true }],
"react/jsx-fragments": [2, "element"],
"react/state-in-constructor": 0,
"react/jsx-props-no-spreading": 0,
"jsx-a11y/click-events-have-key-events": 0
},
"settings": {
// Allows us to lint absolute imports within codebase
"import/resolver": {
"node": {
"moduleDirectory": ["node_modules", "src/"]
}
}
}
}
================================================
FILE: client/.prettierrc
================================================
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
}
================================================
FILE: client/README.md
================================================
# Project structure 🏗
I've used this architecture on multiple larger projects in the past and it performed really well.
There are two special root folders in `src`: `App` and `shared` (described below). All other root folders in `src` (in our case only two: `Auth` and `Project`) should follow the structure of the routes. We can call these folders modules.
The main rule to follow: **Files from one module can only import from ancestor folders within the same module or from `src/shared`.** This makes the codebase easier to understand, and if you're fiddling with code in one module, you will never introduce a bug in another module.
| File or folder | Description |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/index.jsx` | The entry file. This is where we import babel polyfills and render the App into the root DOM node. |
| `src/index.html` | The only HTML file in our App. All scripts and styles will be injected here by Webpack. |
| `src/App` | Main application routes, components that need to be mounted at all times regardless of current route, global css styles, fonts, etc. Basically anything considered global / ancestor of all modules. |
| `src/Auth` | Authentication module |
| `src/Project` | Project module |
| `src/shared` | Components, constants, utils, hooks, styles etc. that can be used anywhere in the codebase. Any module is allowed to import from shared. |
================================================
FILE: client/cypress/.eslintrc.json
================================================
{
"extends": ["plugin:cypress/recommended"],
"rules": {
"no-unused-expressions": 0 // chai assertions trigger this rule
}
}
================================================
FILE: client/cypress/integration/authentication.spec.js
================================================
import { testid } from '../support/utils';
describe('Authentication', () => {
beforeEach(() => {
cy.resetDatabase();
cy.visit('/');
});
it('creates guest account if user has no auth token', () => {
cy.window()
.its('localStorage.authToken')
.should('be.undefined');
cy.window()
.its('localStorage.authToken')
.should('be.a', 'string')
.and('not.be.empty');
cy.get(testid`list-issue`).should('have.length', 8);
});
});
================================================
FILE: client/cypress/integration/issueCreate.spec.js
================================================
import { testid } from '../support/utils';
describe('Issue create', () => {
beforeEach(() => {
cy.resetDatabase();
cy.createTestAccount();
cy.visit('/project/settings?modal-issue-create=true');
});
it('validates form and creates issue successfully', () => {
cy.get(testid`modal:issue-create`).within(() => {
cy.get('button[type="submit"]').click();
cy.get(testid`form-field:title`).should('contain', 'This field is required');
cy.selectOption('type', 'Story');
cy.get('input[name="title"]').type('TEST_TITLE');
cy.get('.ql-editor').type('TEST_DESCRIPTION');
cy.selectOption('reporterId', 'Yoda');
cy.selectOption('userIds', 'Gaben', 'Yoda');
cy.selectOption('priority', 'High');
cy.get('button[type="submit"]').click();
});
cy.get(testid`modal:issue-create`).should('not.exist');
cy.contains('Issue has been successfully created.').should('exist');
cy.location('pathname').should('equal', '/project/board');
cy.location('search').should('be.empty');
cy.contains(testid`list-issue`, 'TEST_TITLE')
.should('have.descendants', testid`avatar:Gaben`)
.and('have.descendants', testid`avatar:Yoda`)
.and('have.descendants', testid`icon:story`);
});
});
================================================
FILE: client/cypress/integration/issueDetails.spec.js
================================================
import { testid } from '../support/utils';
describe('Issue details', () => {
beforeEach(() => {
cy.resetDatabase();
cy.createTestAccount();
cy.visit('/project/board');
getListIssue().click(); // open issue details modal
});
it('updates type, status, assignees, reporter, priority successfully', () => {
getIssueDetailsModal().within(() => {
cy.selectOption('type', 'Story');
cy.selectOption('status', 'Done');
cy.selectOption('assignees', 'Gaben', 'Yoda');
cy.selectOption('reporter', 'Yoda');
cy.selectOption('priority', 'Medium');
});
cy.assertReloadAssert(() => {
getIssueDetailsModal().within(() => {
cy.selectShouldContain('type', 'Story');
cy.selectShouldContain('status', 'Done');
cy.selectShouldContain('assignees', 'Gaben', 'Yoda');
cy.selectShouldContain('reporter', 'Yoda');
cy.selectShouldContain('priority', 'Medium');
});
getListIssue()
.should('have.descendants', testid`avatar:Gaben`)
.and('have.descendants', testid`avatar:Yoda`)
.and('have.descendants', testid`icon:story`);
});
});
it('updates title, description successfully', () => {
getIssueDetailsModal().within(() => {
cy.get('textarea[placeholder="Short summary"]')
.clear()
.type('TEST_TITLE')
.blur();
cy.contains('Add a description...')
.click()
.should('not.exist');
cy.get('.ql-editor').type('TEST_DESCRIPTION');
cy.contains('button', 'Save')
.click()
.should('not.exist');
});
cy.assertReloadAssert(() => {
getIssueDetailsModal().within(() => {
cy.get('textarea[placeholder="Short summary"]').should('have.value', 'TEST_TITLE');
cy.get('.ql-editor').should('contain', 'TEST_DESCRIPTION');
});
cy.get(testid`list-issue`).should('contain', 'TEST_TITLE');
});
});
it('updates estimate, time tracking successfully', () => {
getIssueDetailsModal().within(() => {
getNumberInputAtIndex(0).debounced('type', '10');
cy.contains('10h estimated').click(); // open tracking modal
});
cy.get(testid`modal:tracking`).within(() => {
cy.contains('No time logged').should('exist');
getNumberInputAtIndex(0).debounced('type', 1);
cy.get('div[width="10"]').should('exist'); // tracking bar
getNumberInputAtIndex(1).debounced('type', 2);
cy.contains('button', 'Done')
.click()
.should('not.exist');
});
cy.assertReloadAssert(() => {
getIssueDetailsModal().within(() => {
getNumberInputAtIndex(0).should('have.value', '10');
cy.contains('1h logged').should('exist');
cy.contains('2h remaining').should('exist');
cy.get('div[width*="33.3333"]').should('exist');
});
});
});
it('deletes an issue successfully', () => {
getIssueDetailsModal()
.find(`button ${testid`icon:trash`}`)
.click();
cy.get(testid`modal:confirm`)
.contains('button', 'Delete issue')
.click();
cy.assertReloadAssert(() => {
getIssueDetailsModal().should('not.exist');
getListIssue().should('not.exist');
});
});
it('creates a comment successfully', () => {
getIssueDetailsModal().within(() => {
cy.contains('Add a comment...')
.click()
.should('not.exist');
cy.get('textarea[placeholder="Add a comment..."]').type('TEST_COMMENT');
cy.contains('button', 'Save')
.click()
.should('not.exist');
cy.contains('Add a comment...').should('exist');
cy.get(testid`issue-comment`).should('contain', 'TEST_COMMENT');
});
});
it('edits a comment successfully', () => {
getIssueDetailsModal().within(() => {
cy.get(testid`issue-comment`)
.contains('Edit')
.click()
.should('not.exist');
cy.get('textarea[placeholder="Add a comment..."]')
.should('have.value', 'Comment body')
.clear()
.type('TEST_COMMENT_EDITED');
cy.contains('button', 'Save')
.click()
.should('not.exist');
cy.get(testid`issue-comment`)
.should('contain', 'Edit')
.and('contain', 'TEST_COMMENT_EDITED');
});
});
it('deletes a comment successfully', () => {
getIssueDetailsModal()
.find(testid`issue-comment`)
.contains('Delete')
.click();
cy.get(testid`modal:confirm`)
.contains('button', 'Delete comment')
.click()
.should('not.exist');
getIssueDetailsModal()
.find(testid`issue-comment`)
.should('not.exist');
});
const getIssueDetailsModal = () => cy.get(testid`modal:issue-details`);
const getListIssue = () => cy.contains(testid`list-issue`, 'Issue title 1');
const getNumberInputAtIndex = index => cy.get('input[placeholder="Number"]').eq(index);
});
================================================
FILE: client/cypress/integration/issueFilters.spec.js
================================================
import { testid } from '../support/utils';
describe('Issue filters', () => {
beforeEach(() => {
cy.resetDatabase();
cy.createTestAccount();
cy.visit('/project/board');
});
it('filters issues', () => {
getSearchInput().debounced('type', 'Issue title 1');
assertIssuesCount(1);
getSearchInput().debounced('clear');
assertIssuesCount(3);
getUserAvatar().click();
assertIssuesCount(2);
getUserAvatar().click();
assertIssuesCount(3);
getMyOnlyButton().click();
assertIssuesCount(2);
getMyOnlyButton().click();
assertIssuesCount(3);
getRecentButton().click();
assertIssuesCount(3);
});
const getSearchInput = () => cy.get(testid`board-filters`).find('input');
const getUserAvatar = () => cy.get(testid`board-filters`).find(testid`avatar:Gaben`);
const getMyOnlyButton = () => cy.get(testid`board-filters`).contains('Only My Issues');
const getRecentButton = () => cy.get(testid`board-filters`).contains('Recently Updated');
const assertIssuesCount = count => cy.get(testid`list-issue`).should('have.length', count);
});
================================================
FILE: client/cypress/integration/issueSearch.spec.js
================================================
import { testid } from '../support/utils';
describe('Issue search', () => {
beforeEach(() => {
cy.resetDatabase();
cy.createTestAccount();
cy.visit('/project/board?modal-issue-search=true');
});
it('displays recent issues if search input is empty', () => {
getIssueSearchModal().within(() => {
cy.contains('Recent Issues').should('exist');
getIssues().should('have.length', 3);
cy.get('input').debounced('type', 'anything');
cy.contains('Recent Issues').should('not.exist');
cy.get('input').debounced('clear');
cy.contains('Recent Issues').should('exist');
getIssues().should('have.length', 3);
});
});
it('displays matching issues successfully', () => {
getIssueSearchModal().within(() => {
cy.get('input').debounced('type', 'Issue');
getIssues().should('have.length', 3);
cy.get('input').debounced('type', ' description');
getIssues().should('have.length', 2);
cy.get('input').debounced('type', ' 3');
getIssues().should('have.length', 1);
cy.contains('Matching Issues').should('exist');
});
});
it('displays message if no results were found', () => {
getIssueSearchModal().within(() => {
cy.get('input').debounced('type', 'gibberish');
getIssues().should('not.exist');
cy.contains("We couldn't find anything matching your search").should('exist');
});
});
const getIssueSearchModal = () => cy.get(testid`modal:issue-search`);
const getIssues = () => cy.get('a[href*="/project/board/issues/"]');
});
================================================
FILE: client/cypress/integration/issuesDragDrop.spec.js
================================================
import { KeyCodes } from 'shared/constants/keyCodes';
import { testid } from '../support/utils';
describe('Issues drag & drop', () => {
beforeEach(() => {
cy.resetDatabase();
cy.createTestAccount();
cy.visit('/project/board');
});
it('moves issue between different lists', () => {
cy.get(testid`board-list:backlog`).should('contain', firstIssueTitle);
cy.get(testid`board-list:selected`).should('not.contain', firstIssueTitle);
moveFirstIssue(KeyCodes.ARROW_RIGHT);
cy.assertReloadAssert(() => {
cy.get(testid`board-list:backlog`).should('not.contain', firstIssueTitle);
cy.get(testid`board-list:selected`).should('contain', firstIssueTitle);
});
});
it('moves issue within a single list', () => {
getIssueAtIndex(0).should('contain', firstIssueTitle);
getIssueAtIndex(1).should('contain', secondIssueTitle);
moveFirstIssue(KeyCodes.ARROW_DOWN);
cy.assertReloadAssert(() => {
getIssueAtIndex(0).should('contain', secondIssueTitle);
getIssueAtIndex(1).should('contain', firstIssueTitle);
});
});
const firstIssueTitle = 'Issue title 1';
const secondIssueTitle = 'Issue title 2';
const getIssueAtIndex = index => cy.get(testid`list-issue`).eq(index);
const moveFirstIssue = directionKeyCode => {
cy.waitForXHR('PUT', '/issues/**', () => {
getIssueAtIndex(0)
.focus()
.trigger('keydown', { keyCode: KeyCodes.SPACE })
.trigger('keydown', { keyCode: directionKeyCode, force: true })
.trigger('keydown', { keyCode: KeyCodes.SPACE, force: true });
});
};
});
================================================
FILE: client/cypress/integration/projectSettings.spec.js
================================================
import { testid } from '../support/utils';
describe('Project settings', () => {
beforeEach(() => {
cy.resetDatabase();
cy.createTestAccount();
cy.visit('/project/settings');
});
it('should display current values in form', () => {
cy.get('input[name="name"]').should('have.value', 'Project name');
cy.get('input[name="url"]').should('have.value', 'https://www.testurl.com');
cy.get('.ql-editor').should('contain', 'Project description');
cy.selectShouldContain('category', 'Software');
});
it('validates form and updates project successfully', () => {
cy.get('input[name="name"]').clear();
cy.get('button[type="submit"]').click();
cy.get(testid`form-field:name`).should('contain', 'This field is required');
cy.get('input[name="name"]').type('TEST_NAME');
cy.get(testid`form-field:name`).should('not.contain', 'This field is required');
cy.selectOption('category', 'Business');
cy.get('button[type="submit"]').click();
cy.contains('Changes have been saved successfully.').should('exist');
cy.reload();
cy.get('input[name="name"]').should('have.value', 'TEST_NAME');
cy.selectShouldContain('category', 'Business');
});
});
================================================
FILE: client/cypress/plugins/index.js
================================================
/* eslint-disable global-require */
/* eslint-disable import/no-extraneous-dependencies */
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
const webpack = require('@cypress/webpack-preprocessor');
const webpackOptions = require('../../webpack.config.js');
module.exports = on => {
on('file:preprocessor', webpack({ webpackOptions }));
};
================================================
FILE: client/cypress/support/commands.js
================================================
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import '@4tw/cypress-drag-drop';
import { objectToQueryString } from 'shared/utils/url';
import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken';
import { testid } from './utils';
Cypress.Commands.add('selectOption', (selectName, ...optionLabels) => {
optionLabels.forEach(optionLabel => {
cy.get(testid`select:${selectName}`).click('bottomRight');
cy.get(testid`select-option:${optionLabel}`).click();
});
});
Cypress.Commands.add('selectShouldContain', (selectName, ...optionLabels) => {
optionLabels.forEach(optionLabel => {
cy.get(testid`select:${selectName}`).should('contain', optionLabel);
});
});
// We don't want to waste time when running tests on cypress waiting for debounced
// inputs. We can use tick() to speed up time and trigger onChange immediately.
Cypress.Commands.add('debounced', { prevSubject: true }, (input, action, value) => {
cy.clock();
cy.wrap(input)[action](value);
cy.tick(1000);
});
// Sometimes cypress fails to properly wait for api requests to finish which results
// in flaky tests, and in those cases we need to explicitly tell it to wait
// https://docs.cypress.io/guides/guides/network-requests.html#Flake
Cypress.Commands.add('waitForXHR', (method, url, funcThatTriggersXHR) => {
const alias = method + url;
cy.server();
cy.route(method, url).as(alias);
funcThatTriggersXHR();
cy.wait(`@${alias}`);
});
// We're using optimistic updates (not waiting for API response before updating
// the local data and UI) in a lot of places in the app. That's why we want to assert
// both the immediate local UI change in the first assert, and if the change was
// successfully persisted by the API in the second assert after page reload
Cypress.Commands.add('assertReloadAssert', assertFunc => {
assertFunc();
cy.reload();
assertFunc();
});
Cypress.Commands.add('apiRequest', (method, url, variables = {}, options = {}) => {
cy.request({
method,
url: `${Cypress.env('apiBaseUrl')}${url}`,
qs: method === 'GET' ? objectToQueryString(variables) : undefined,
body: method !== 'GET' ? variables : undefined,
headers: {
'Content-Type': 'application/json',
Authorization: getStoredAuthToken() ? `Bearer ${getStoredAuthToken()}` : undefined,
},
...options,
});
});
Cypress.Commands.add('resetDatabase', () => {
cy.apiRequest('DELETE', '/test/reset-database');
});
Cypress.Commands.add('createTestAccount', () => {
cy.apiRequest('POST', '/test/create-account').then(response => {
storeAuthToken(response.body.authToken);
});
});
================================================
FILE: client/cypress/support/index.js
================================================
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
import './commands';
================================================
FILE: client/cypress/support/utils.js
================================================
export const testid = (strings, ...values) => {
const id = strings.map((str, index) => str + (values[index] || '')).join('');
return `[data-testid="${id}"]`;
};
================================================
FILE: client/cypress.json
================================================
{
"baseUrl": "http://localhost:8080",
"viewportHeight": 800,
"viewportWidth": 1440,
"env": {
"apiBaseUrl": "http://localhost:3000"
}
}
================================================
FILE: client/jest/fileMock.js
================================================
module.exports = 'test-file-stub';
================================================
FILE: client/jest/styleMock.js
================================================
module.exports = {};
================================================
FILE: client/jest.config.js
================================================
module.exports = {
moduleFileExtensions: ['*', 'js', 'jsx'],
moduleDirectories: ['src', 'node_modules'],
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'/jest/fileMock.js',
'\\.(css|scss|less)$': '/jest/styleMock.js',
},
};
================================================
FILE: client/jsconfig.json
================================================
// This config allows VSCode intellisense to work with absolute "src" imports and jsx files
{
"compilerOptions": {
"baseUrl": "./src",
"jsx": "react"
},
"include": ["src/**/*", "cypress/**/*.js", "./node_modules/cypress"]
}
================================================
FILE: client/package.json
================================================
{
"name": "jira_client",
"version": "1.0.0",
"author": "Ivor Reic",
"license": "MIT",
"scripts": {
"start": "webpack-dev-server",
"start:production": "pm2 start --name 'jira_client' server.js",
"test:jest": "jest",
"test:cypress": "node_modules/.bin/cypress open",
"build": "rm -rf build && webpack --config webpack.config.production.js --progress",
"pre-commit": "lint-staged"
},
"devDependencies": {
"@babel/core": "^7.7.4",
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-proposal-decorators": "^7.7.4",
"@babel/plugin-proposal-export-namespace-from": "^7.7.4",
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
"@babel/preset-env": "^7.7.4",
"@babel/preset-react": "^7.7.4",
"@cypress/webpack-preprocessor": "^4.1.1",
"babel-eslint": "^10.0.3",
"babel-loader": "^8.0.6",
"css-loader": "^3.3.2",
"cypress": "^3.8.1",
"eslint": "^6.1.0",
"eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.7.0",
"eslint-plugin-cypress": "^2.8.1",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.17.0",
"eslint-plugin-react-hooks": "^1.7.0",
"file-loader": "^5.0.2",
"html-webpack-plugin": "^3.2.0",
"jest": "^24.9.0",
"lint-staged": "^9.5.0",
"prettier": "^1.19.1",
"style-loader": "^1.0.1",
"url-loader": "^3.0.0",
"webpack": "^4.41.2",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.9.0"
},
"dependencies": {
"@4tw/cypress-drag-drop": "^1.3.0",
"axios": "^0.19.0",
"color": "^3.1.2",
"compression": "^1.7.4",
"core-js": "^3.4.7",
"express": "^4.17.1",
"express-history-api-fallback": "^2.2.1",
"formik": "^2.1.1",
"history": "^4.10.1",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.15",
"moment": "^2.24.0",
"prop-types": "^15.7.2",
"query-string": "^6.9.0",
"quill": "^1.3.7",
"react": "^16.12.0",
"react-beautiful-dnd": "^12.2.0",
"react-content-loader": "^4.3.3",
"react-dom": "^16.12.0",
"react-router-dom": "^5.1.2",
"react-textarea-autosize": "^7.1.2",
"react-transition-group": "^4.3.0",
"regenerator-runtime": "^0.13.3",
"styled-components": "^4.4.1",
"sweet-pubsub": "^1.1.2"
},
"lint-staged": {
"*.{js,jsx}": [
"eslint --fix",
"prettier --write",
"git add"
]
}
}
================================================
FILE: client/server.js
================================================
const express = require('express');
const fallback = require('express-history-api-fallback');
const compression = require('compression');
const app = express();
app.use(compression());
app.use(express.static(`${__dirname}/build`));
app.use(fallback(`${__dirname}/build/index.html`));
app.listen(process.env.PORT || 8081);
================================================
FILE: client/src/App/BaseStyles.js
================================================
import { createGlobalStyle } from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
export default createGlobalStyle`
html, body, #root {
height: 100%;
min-height: 100%;
min-width: 768px;
}
body {
color: ${color.textDarkest};
-webkit-tap-highlight-color: transparent;
line-height: 1.2;
${font.size(16)}
${font.regular}
}
#root {
display: flex;
flex-direction: column;
}
button,
input,
optgroup,
select,
textarea {
${font.regular}
}
*, *:after, *:before, input[type="search"] {
box-sizing: border-box;
}
a {
color: inherit;
text-decoration: none;
}
ul {
list-style: none;
}
ul, li, ol, dd, h1, h2, h3, h4, h5, h6, p {
padding: 0;
margin: 0;
}
h1, h2, h3, h4, h5, h6, strong {
${font.bold}
}
button {
background: none;
border: none;
}
/* Workaround for IE11 focus highlighting for select elements */
select::-ms-value {
background: none;
color: #42413d;
}
[role="button"], button, input, select, textarea {
outline: none;
&:focus {
outline: none;
}
&:disabled {
opacity: 1;
}
}
[role="button"], button, input, textarea {
appearance: none;
}
select:-moz-focusring {
color: transparent;
text-shadow: 0 0 0 #000;
}
select::-ms-expand {
display: none;
}
select option {
color: ${color.textDarkest};
}
p {
line-height: 1.4285;
a {
${mixin.link()}
}
}
textarea {
line-height: 1.4285;
}
body, select {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
touch-action: manipulation;
}
${mixin.placeholderColor(color.textLight)}
`;
================================================
FILE: client/src/App/NormalizeStyles.js
================================================
import { createGlobalStyle } from 'styled-components';
/** DO NOT ALTER THIS FILE. It is a copy of https://necolas.github.io/normalize.css/ */
export default createGlobalStyle`
html {
line-height: 1.15;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
}
main {
display: block;
}
h1 {
font-size: 2em;
margin: 0.67em 0;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
pre {
font-family: monospace, monospace;
font-size: 1em;
}
a {
background-color: transparent;
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
text-decoration: underline dotted;
}
b,
strong {
font-weight: bolder;
}
code,
kbd,
samp {
font-family: monospace, monospace;
font-size: 1em;
}
small {
font-size: 80%;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
img {
border-style: none;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
fieldset {
padding: 0.35em 0.75em 0.625em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
progress {
vertical-align: baseline;
}
textarea {
overflow: auto;
}
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
padding: 0;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
details {
display: block;
}
summary {
display: list-item;
}
template {
display: none;
}
[hidden] {
display: none;
}
`;
================================================
FILE: client/src/App/Routes.jsx
================================================
import React from 'react';
import { Router, Switch, Route, Redirect } from 'react-router-dom';
import history from 'browserHistory';
import Project from 'Project';
import Authenticate from 'Auth/Authenticate';
import PageError from 'shared/components/PageError';
const Routes = () => (
);
export default Routes;
================================================
FILE: client/src/App/Toast/Styles.js
================================================
import styled from 'styled-components';
import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
import { Icon } from 'shared/components';
export const Container = styled.div`
z-index: ${zIndexValues.modal + 1};
position: fixed;
right: 30px;
top: 50px;
`;
export const StyledToast = styled.div`
position: relative;
margin-bottom: 5px;
width: 300px;
padding: 15px 20px;
border-radius: 3px;
color: #fff;
background: ${props => color[props.type]};
cursor: pointer;
transition: all 0.15s;
${mixin.clearfix}
${mixin.hardwareAccelerate}
&.jira-toast-enter,
&.jira-toast-exit.jira-toast-exit-active {
opacity: 0;
right: -10px;
}
&.jira-toast-exit,
&.jira-toast-enter.jira-toast-enter-active {
opacity: 1;
right: 0;
}
`;
export const CloseIcon = styled(Icon)`
position: absolute;
top: 13px;
right: 14px;
font-size: 22px;
cursor: pointer;
color: #fff;
`;
export const Title = styled.div`
padding-right: 22px;
${font.size(15)}
${font.medium}
`;
export const Message = styled.div`
padding: 8px 10px 0 0;
white-space: pre-wrap;
${font.size(14)}
${font.medium}
`;
================================================
FILE: client/src/App/Toast/index.jsx
================================================
import React, { useState, useEffect } from 'react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import pubsub from 'sweet-pubsub';
import { uniqueId } from 'lodash';
import { Container, StyledToast, CloseIcon, Title, Message } from './Styles';
const Toast = () => {
const [toasts, setToasts] = useState([]);
useEffect(() => {
const addToast = ({ type = 'success', title, message, duration = 5 }) => {
const id = uniqueId('toast-');
setToasts(currentToasts => [...currentToasts, { id, type, title, message }]);
if (duration) {
setTimeout(() => removeToast(id), duration * 1000);
}
};
pubsub.on('toast', addToast);
return () => {
pubsub.off('toast', addToast);
};
}, []);
const removeToast = id => {
setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id));
};
return (
{toasts.map(toast => (
removeToast(toast.id)}>
{toast.title && {toast.title} }
{toast.message && {toast.message} }
))}
);
};
export default Toast;
================================================
FILE: client/src/App/fontStyles.css
================================================
@font-face {
font-family: 'CircularStdBlack';
src: url('./assets/fonts/CircularStd-Black.woff2') format('woff2'),
url('./assets/fonts/CircularStd-Black.woff') format('woff');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'CircularStdBold';
src: url('./assets/fonts/CircularStd-Bold.woff2') format('woff2'),
url('./assets/fonts/CircularStd-Bold.woff') format('woff');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'CircularStdMedium';
src: url('./assets/fonts/CircularStd-Medium.woff2') format('woff2'),
url('./assets/fonts/CircularStd-Medium.woff') format('woff');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'CircularStdBook';
src: url('./assets/fonts/CircularStd-Book.woff2') format('woff2'),
url('./assets/fonts/CircularStd-Book.woff') format('woff');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'jira';
src: url('./assets/fonts/jira.woff') format('truetype'),
url('./assets/fonts/jira.ttf') format('woff'), url('./assets/fonts/jira.svg#jira') format('svg');
font-weight: normal;
font-style: normal;
}
================================================
FILE: client/src/App/index.jsx
================================================
import React, { Fragment } from 'react';
import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles';
import Toast from './Toast';
import Routes from './Routes';
// We're importing .css because @font-face in styled-components causes font files
// to be constantly re-requested from the server (which causes screen flicker)
// https://github.com/styled-components/styled-components/issues/1593
import './fontStyles.css';
const App = () => (
);
export default App;
================================================
FILE: client/src/Auth/Authenticate.jsx
================================================
import React, { useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import api from 'shared/utils/api';
import toast from 'shared/utils/toast';
import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken';
import { PageLoader } from 'shared/components';
const Authenticate = () => {
const history = useHistory();
useEffect(() => {
const createGuestAccount = async () => {
try {
const { authToken } = await api.post('/authentication/guest');
storeAuthToken(authToken);
history.push('/');
} catch (error) {
toast.error(error);
}
};
if (!getStoredAuthToken()) {
createGuestAccount();
}
}, [history]);
return ;
};
export default Authenticate;
================================================
FILE: client/src/Project/Board/Filters/Styles.js
================================================
import styled from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
import { InputDebounced, Avatar, Button } from 'shared/components';
export const Filters = styled.div`
display: flex;
align-items: center;
margin-top: 24px;
`;
export const SearchInput = styled(InputDebounced)`
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};
}
`;
================================================
FILE: client/src/Project/Board/Filters/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { xor } from 'lodash';
import {
Filters,
SearchInput,
Avatars,
AvatarIsActiveBorder,
StyledAvatar,
StyledButton,
ClearAll,
} from './Styles';
const propTypes = {
projectUsers: PropTypes.array.isRequired,
defaultFilters: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
mergeFilters: PropTypes.func.isRequired,
};
const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, mergeFilters }) => {
const { searchTerm, userIds, myOnly, recent } = filters;
const areFiltersCleared = !searchTerm && userIds.length === 0 && !myOnly && !recent;
return (
mergeFilters({ searchTerm: value })}
/>
{projectUsers.map(user => (
mergeFilters({ userIds: xor(userIds, [user.id]) })}
/>
))}
mergeFilters({ myOnly: !myOnly })}
>
Only My Issues
mergeFilters({ recent: !recent })}
>
Recently Updated
{!areFiltersCleared && (
mergeFilters(defaultFilters)}>Clear all
)}
);
};
ProjectBoardFilters.propTypes = propTypes;
export default ProjectBoardFilters;
================================================
FILE: client/src/Project/Board/Header/Styles.js
================================================
import styled from 'styled-components';
import { font } from 'shared/utils/styles';
export const Header = styled.div`
margin-top: 6px;
display: flex;
justify-content: space-between;
`;
export const BoardName = styled.div`
${font.size(24)}
${font.medium}
`;
================================================
FILE: client/src/Project/Board/Header/index.jsx
================================================
import React from 'react';
import { Button } from 'shared/components';
import { Header, BoardName } from './Styles';
const ProjectBoardHeader = () => (
);
export default ProjectBoardHeader;
================================================
FILE: client/src/Project/Board/IssueDetails/AssigneesReporter/Styles.js
================================================
import styled, { css } from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
export const User = styled.div`
display: flex;
align-items: center;
${mixin.clickable}
${props =>
props.isSelectValue &&
css`
margin: 0 10px ${props.withBottomMargin ? 5 : 0}px 0;
padding: 4px 8px;
border-radius: 4px;
background: ${color.backgroundLight};
transition: background 0.1s;
&:hover {
background: ${color.backgroundMedium};
}
`}
`;
export const Username = styled.div`
padding: 0 3px 0 8px;
${font.size(14.5)}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/AssigneesReporter/index.jsx
================================================
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Avatar, Select, Icon } from 'shared/components';
import { SectionTitle } from '../Styles';
import { User, Username } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
updateIssue: PropTypes.func.isRequired,
projectUsers: PropTypes.array.isRequired,
};
const ProjectBoardIssueDetailsAssigneesReporter = ({ issue, updateIssue, projectUsers }) => {
const getUserById = userId => projectUsers.find(user => user.id === userId);
const userOptions = projectUsers.map(user => ({ value: user.id, label: user.name }));
return (
Assignees
{
updateIssue({ userIds, users: userIds.map(getUserById) });
}}
renderValue={({ value: userId, removeOptionValue }) =>
renderUser(getUserById(userId), true, removeOptionValue)
}
renderOption={({ value: userId }) => renderUser(getUserById(userId), false)}
/>
Reporter
updateIssue({ reporterId: userId })}
renderValue={({ value: userId }) => renderUser(getUserById(userId), true)}
renderOption={({ value: userId }) => renderUser(getUserById(userId))}
/>
);
};
const renderUser = (user, isSelectValue, removeOptionValue) => (
removeOptionValue && removeOptionValue()}
>
{user.name}
{removeOptionValue && }
);
ProjectBoardIssueDetailsAssigneesReporter.propTypes = propTypes;
export default ProjectBoardIssueDetailsAssigneesReporter;
================================================
FILE: client/src/Project/Board/IssueDetails/Comments/BodyForm/Styles.js
================================================
import styled from 'styled-components';
import { Button } from 'shared/components';
export const Actions = styled.div`
display: flex;
padding-top: 10px;
`;
export const FormButton = styled(Button)`
margin-right: 6px;
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Comments/BodyForm/index.jsx
================================================
import React, { Fragment, useRef } from 'react';
import PropTypes from 'prop-types';
import { Textarea } from 'shared/components';
import { Actions, FormButton } from './Styles';
const propTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
isWorking: PropTypes.bool.isRequired,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsCommentsBodyForm = ({
value,
onChange,
isWorking,
onSubmit,
onCancel,
}) => {
const $textareaRef = useRef();
const handleSubmit = () => {
if ($textareaRef.current.value.trim()) {
onSubmit();
}
};
return (
Save
Cancel
);
};
ProjectBoardIssueDetailsCommentsBodyForm.propTypes = propTypes;
export default ProjectBoardIssueDetailsCommentsBodyForm;
================================================
FILE: client/src/Project/Board/IssueDetails/Comments/Comment/Styles.js
================================================
import styled, { css } from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
import { Avatar } from 'shared/components';
export const Comment = styled.div`
position: relative;
margin-top: 25px;
${font.size(15)}
`;
export const UserAvatar = styled(Avatar)`
position: absolute;
top: 0;
left: 0;
`;
export const Content = styled.div`
padding-left: 44px;
`;
export const Username = styled.div`
display: inline-block;
padding-right: 12px;
padding-bottom: 10px;
color: ${color.textDark};
${font.medium}
`;
export const CreatedAt = styled.div`
display: inline-block;
padding-bottom: 10px;
color: ${color.textDark};
${font.size(14.5)}
`;
export const Body = styled.p`
padding-bottom: 10px;
white-space: pre-wrap;
`;
const actionLinkStyles = css`
display: inline-block;
padding: 2px 0;
color: ${color.textMedium};
${font.size(14.5)}
${mixin.clickable}
&:hover {
text-decoration: underline;
}
`;
export const EditLink = styled.div`
margin-right: 12px;
${actionLinkStyles}
`;
export const DeleteLink = styled.div`
${actionLinkStyles}
&:before {
position: relative;
right: 6px;
content: '·';
display: inline-block;
}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Comments/Comment/index.jsx
================================================
import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types';
import api from 'shared/utils/api';
import toast from 'shared/utils/toast';
import { formatDateTimeConversational } from 'shared/utils/dateTime';
import { ConfirmModal } from 'shared/components';
import BodyForm from '../BodyForm';
import {
Comment,
UserAvatar,
Content,
Username,
CreatedAt,
Body,
EditLink,
DeleteLink,
} from './Styles';
const propTypes = {
comment: PropTypes.object.isRequired,
fetchIssue: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsComment = ({ comment, fetchIssue }) => {
const [isFormOpen, setFormOpen] = useState(false);
const [isUpdating, setUpdating] = useState(false);
const [body, setBody] = useState(comment.body);
const handleCommentDelete = async () => {
try {
await api.delete(`/comments/${comment.id}`);
await fetchIssue();
} catch (error) {
toast.error(error);
}
};
const handleCommentUpdate = async () => {
try {
setUpdating(true);
await api.put(`/comments/${comment.id}`, { body });
await fetchIssue();
setUpdating(false);
setFormOpen(false);
} catch (error) {
toast.error(error);
}
};
return (
{comment.user.name}
{formatDateTimeConversational(comment.createdAt)}
{isFormOpen ? (
setFormOpen(false)}
/>
) : (
{comment.body}
setFormOpen(true)}>Edit
Delete }
/>
)}
);
};
ProjectBoardIssueDetailsComment.propTypes = propTypes;
export default ProjectBoardIssueDetailsComment;
================================================
FILE: client/src/Project/Board/IssueDetails/Comments/Create/ProTip/Styles.js
================================================
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const Tip = styled.div`
display: flex;
align-items: center;
padding-top: 8px;
color: ${color.textMedium};
${font.size(13)}
strong {
padding-right: 4px;
}
`;
export const TipLetter = styled.span`
position: relative;
top: 1px;
display: inline-block;
margin: 0 4px;
padding: 0 4px;
border-radius: 2px;
color: ${color.textDarkest};
background: ${color.backgroundMedium};
${font.bold}
${font.size(12)}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Comments/Create/ProTip/index.jsx
================================================
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { KeyCodes } from 'shared/constants/keyCodes';
import { isFocusedElementEditable } from 'shared/utils/browser';
import { Tip, TipLetter } from './Styles';
const propTypes = {
setFormOpen: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsCommentsCreateProTip = ({ setFormOpen }) => {
useEffect(() => {
const handleKeyDown = event => {
if (!isFocusedElementEditable() && event.keyCode === KeyCodes.M) {
event.preventDefault();
setFormOpen(true);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [setFormOpen]);
return (
Pro tip: pressM to comment
);
};
ProjectBoardIssueDetailsCommentsCreateProTip.propTypes = propTypes;
export default ProjectBoardIssueDetailsCommentsCreateProTip;
================================================
FILE: client/src/Project/Board/IssueDetails/Comments/Create/Styles.js
================================================
import styled from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
import { Avatar } from 'shared/components';
export const Create = styled.div`
position: relative;
margin-top: 25px;
${font.size(15)}
`;
export const UserAvatar = styled(Avatar)`
position: absolute;
top: 0;
left: 0;
`;
export const Right = styled.div`
padding-left: 44px;
`;
export const FakeTextarea = styled.div`
padding: 12px 16px;
border-radius: 4px;
border: 1px solid ${color.borderLightest};
color: ${color.textLight};
${mixin.clickable}
&:hover {
border: 1px solid ${color.borderLight};
}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Comments/Create/index.jsx
================================================
import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types';
import api from 'shared/utils/api';
import useCurrentUser from 'shared/hooks/currentUser';
import toast from 'shared/utils/toast';
import BodyForm from '../BodyForm';
import ProTip from './ProTip';
import { Create, UserAvatar, Right, FakeTextarea } from './Styles';
const propTypes = {
issueId: PropTypes.number.isRequired,
fetchIssue: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsCommentsCreate = ({ issueId, fetchIssue }) => {
const [isFormOpen, setFormOpen] = useState(false);
const [isCreating, setCreating] = useState(false);
const [body, setBody] = useState('');
const { currentUser } = useCurrentUser();
const handleCommentCreate = async () => {
try {
setCreating(true);
await api.post(`/comments`, { body, issueId, userId: currentUser.id });
await fetchIssue();
setFormOpen(false);
setCreating(false);
setBody('');
} catch (error) {
toast.error(error);
}
};
return (
{currentUser && }
{isFormOpen ? (
setFormOpen(false)}
/>
) : (
setFormOpen(true)}>Add a comment...
)}
);
};
ProjectBoardIssueDetailsCommentsCreate.propTypes = propTypes;
export default ProjectBoardIssueDetailsCommentsCreate;
================================================
FILE: client/src/Project/Board/IssueDetails/Comments/Styles.js
================================================
import styled from 'styled-components';
import { font } from 'shared/utils/styles';
export const Comments = styled.div`
padding-top: 40px;
`;
export const Title = styled.div`
${font.medium}
${font.size(15)}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Comments/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { sortByNewest } from 'shared/utils/javascript';
import Create from './Create';
import Comment from './Comment';
import { Comments, Title } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
fetchIssue: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsComments = ({ issue, fetchIssue }) => (
Comments
{sortByNewest(issue.comments, 'createdAt').map(comment => (
))}
);
ProjectBoardIssueDetailsComments.propTypes = propTypes;
export default ProjectBoardIssueDetailsComments;
================================================
FILE: client/src/Project/Board/IssueDetails/Dates/Styles.js
================================================
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const Dates = styled.div`
margin-top: 11px;
padding-top: 13px;
line-height: 22px;
border-top: 1px solid ${color.borderLightest};
color: ${color.textMedium};
${font.size(13)}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Dates/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { formatDateTimeConversational } from 'shared/utils/dateTime';
import { Dates } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
};
const ProjectBoardIssueDetailsDates = ({ issue }) => (
Created at {formatDateTimeConversational(issue.createdAt)}
Updated at {formatDateTimeConversational(issue.updatedAt)}
);
ProjectBoardIssueDetailsDates.propTypes = propTypes;
export default ProjectBoardIssueDetailsDates;
================================================
FILE: client/src/Project/Board/IssueDetails/Delete.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import api from 'shared/utils/api';
import toast from 'shared/utils/toast';
import { Button, ConfirmModal } from 'shared/components';
const propTypes = {
issue: PropTypes.object.isRequired,
fetchProject: PropTypes.func.isRequired,
modalClose: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsDelete = ({ issue, fetchProject, modalClose }) => {
const handleIssueDelete = async () => {
try {
await api.delete(`/issues/${issue.id}`);
await fetchProject();
modalClose();
} catch (error) {
toast.error(error);
}
};
return (
(
)}
/>
);
};
ProjectBoardIssueDetailsDelete.propTypes = propTypes;
export default ProjectBoardIssueDetailsDelete;
================================================
FILE: client/src/Project/Board/IssueDetails/Description/Styles.js
================================================
import styled from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
export const Title = styled.div`
padding: 20px 0 6px;
${font.size(15)}
${font.medium}
`;
export const EmptyLabel = styled.div`
margin-left: -7px;
padding: 7px;
border-radius: 3px;
color: ${color.textMedium}
transition: background 0.1s;
${font.size(15)}
${mixin.clickable}
&:hover {
background: ${color.backgroundLight};
}
`;
export const Actions = styled.div`
display: flex;
padding-top: 12px;
& > button {
margin-right: 6px;
}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Description/index.jsx
================================================
import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types';
import { getTextContentsFromHtmlString } from 'shared/utils/browser';
import { TextEditor, TextEditedContent, Button } from 'shared/components';
import { Title, EmptyLabel, Actions } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
updateIssue: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => {
const [description, setDescription] = useState(issue.description);
const [isEditing, setEditing] = useState(false);
const handleUpdate = () => {
setEditing(false);
updateIssue({ description });
};
const isDescriptionEmpty = getTextContentsFromHtmlString(description).trim().length === 0;
return (
Description
{isEditing ? (
Save
setEditing(false)}>
Cancel
) : (
{isDescriptionEmpty ? (
setEditing(true)}>Add a description...
) : (
setEditing(true)} />
)}
)}
);
};
ProjectBoardIssueDetailsDescription.propTypes = propTypes;
export default ProjectBoardIssueDetailsDescription;
================================================
FILE: client/src/Project/Board/IssueDetails/EstimateTracking/Styles.js
================================================
import styled from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
export const TrackingLink = styled.div`
padding: 4px 4px 2px 0;
border-radius: 4px;
transition: background 0.1s;
${mixin.clickable}
&:hover {
background: ${color.backgroundLight};
}
`;
export const ModalContents = styled.div`
padding: 20px 25px 25px;
`;
export const ModalTitle = styled.div`
padding-bottom: 14px;
${font.medium}
${font.size(20)}
`;
export const Inputs = styled.div`
display: flex;
margin: 20px -5px 30px;
`;
export const InputCont = styled.div`
margin: 0 5px;
width: 50%;
`;
export const InputLabel = styled.div`
padding-bottom: 5px;
color: ${color.textMedium};
${font.medium};
${font.size(13)};
`;
export const Actions = styled.div`
display: flex;
justify-content: flex-end;
`;
================================================
FILE: client/src/Project/Board/IssueDetails/EstimateTracking/TrackingWidget/Styles.js
================================================
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
import { Icon } from 'shared/components';
export const TrackingWidget = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
export const WatchIcon = styled(Icon)`
color: ${color.textMedium};
`;
export const Right = styled.div`
width: 90%;
`;
export const BarCont = styled.div`
height: 5px;
border-radius: 4px;
background: ${color.backgroundMedium};
`;
export const Bar = styled.div`
height: 5px;
border-radius: 4px;
background: ${color.primary};
transition: all 0.1s;
width: ${props => props.width}%;
`;
export const Values = styled.div`
display: flex;
justify-content: space-between;
padding-top: 3px;
${font.size(14.5)};
`;
================================================
FILE: client/src/Project/Board/IssueDetails/EstimateTracking/TrackingWidget/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { isNil } from 'lodash';
import { TrackingWidget, WatchIcon, Right, BarCont, Bar, Values } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
};
const ProjectBoardIssueDetailsTrackingWidget = ({ issue }) => (
{issue.timeSpent ? `${issue.timeSpent}h logged` : 'No time logged'}
{renderRemainingOrEstimate(issue)}
);
const calculateTrackingBarWidth = ({ timeSpent, timeRemaining, estimate }) => {
if (!timeSpent) {
return 0;
}
if (isNil(timeRemaining) && isNil(estimate)) {
return 100;
}
if (!isNil(timeRemaining)) {
return (timeSpent / (timeSpent + timeRemaining)) * 100;
}
if (!isNil(estimate)) {
return Math.min((timeSpent / estimate) * 100, 100);
}
};
const renderRemainingOrEstimate = ({ timeRemaining, estimate }) => {
if (isNil(timeRemaining) && isNil(estimate)) {
return null;
}
if (!isNil(timeRemaining)) {
return {`${timeRemaining}h remaining`}
;
}
if (!isNil(estimate)) {
return {`${estimate}h estimated`}
;
}
};
ProjectBoardIssueDetailsTrackingWidget.propTypes = propTypes;
export default ProjectBoardIssueDetailsTrackingWidget;
================================================
FILE: client/src/Project/Board/IssueDetails/EstimateTracking/index.jsx
================================================
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { isNil } from 'lodash';
import { InputDebounced, Modal, Button } from 'shared/components';
import TrackingWidget from './TrackingWidget';
import { SectionTitle } from '../Styles';
import {
TrackingLink,
ModalContents,
ModalTitle,
Inputs,
InputCont,
InputLabel,
Actions,
} from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
updateIssue: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsEstimateTracking = ({ issue, updateIssue }) => (
Original Estimate (hours)
{renderHourInput('estimate', issue, updateIssue)}
Time Tracking
(
)}
renderContent={modal => (
Time tracking
Time spent (hours)
{renderHourInput('timeSpent', issue, updateIssue)}
Time remaining (hours)
{renderHourInput('timeRemaining', issue, updateIssue)}
Done
)}
/>
);
const renderHourInput = (fieldName, issue, updateIssue) => (
{
const value = stringValue.trim() ? Number(stringValue) : null;
updateIssue({ [fieldName]: value });
}}
/>
);
ProjectBoardIssueDetailsEstimateTracking.propTypes = propTypes;
export default ProjectBoardIssueDetailsEstimateTracking;
================================================
FILE: client/src/Project/Board/IssueDetails/Loader.jsx
================================================
import React from 'react';
import ContentLoader from 'react-content-loader';
const IssueDetailsLoader = () => (
);
export default IssueDetailsLoader;
================================================
FILE: client/src/Project/Board/IssueDetails/Priority/Styles.js
================================================
import styled, { css } from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const Priority = styled.div`
display: flex;
align-items: center;
${props =>
props.isValue &&
css`
padding: 3px 4px 3px 0px;
border-radius: 4px;
&:hover,
&:focus {
background: ${color.backgroundLight};
}
`}
`;
export const Label = styled.div`
padding: 0 3px 0 8px;
${font.size(14.5)}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Priority/index.jsx
================================================
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { IssuePriority, IssuePriorityCopy } from 'shared/constants/issues';
import { Select, IssuePriorityIcon } from 'shared/components';
import { SectionTitle } from '../Styles';
import { Priority, Label } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
updateIssue: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsPriority = ({ issue, updateIssue }) => (
Priority
({
value: priority,
label: IssuePriorityCopy[priority],
}))}
onChange={priority => updateIssue({ priority })}
renderValue={({ value: priority }) => renderPriorityItem(priority, true)}
renderOption={({ value: priority }) => renderPriorityItem(priority)}
/>
);
const renderPriorityItem = (priority, isValue) => (
{IssuePriorityCopy[priority]}
);
ProjectBoardIssueDetailsPriority.propTypes = propTypes;
export default ProjectBoardIssueDetailsPriority;
================================================
FILE: client/src/Project/Board/IssueDetails/Status/Styles.js
================================================
import styled, { css } from 'styled-components';
import { issueStatusColors, issueStatusBackgroundColors, mixin } from 'shared/utils/styles';
export const Status = styled.div`
text-transform: uppercase;
transition: all 0.1s;
${props => mixin.tag(issueStatusBackgroundColors[props.color], issueStatusColors[props.color])}
${props =>
props.isValue &&
css`
padding: 0 12px;
height: 32px;
&:hover {
transform: scale(1.05);
}
`}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Status/index.jsx
================================================
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { IssueStatus, IssueStatusCopy } from 'shared/constants/issues';
import { Select, Icon } from 'shared/components';
import { SectionTitle } from '../Styles';
import { Status } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
updateIssue: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsStatus = ({ issue, updateIssue }) => (
Status
({
value: status,
label: IssueStatusCopy[status],
}))}
onChange={status => updateIssue({ status })}
renderValue={({ value: status }) => (
{IssueStatusCopy[status]}
)}
renderOption={({ value: status }) => (
{IssueStatusCopy[status]}
)}
/>
);
ProjectBoardIssueDetailsStatus.propTypes = propTypes;
export default ProjectBoardIssueDetailsStatus;
================================================
FILE: client/src/Project/Board/IssueDetails/Styles.js
================================================
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const Content = styled.div`
display: flex;
padding: 0 30px 60px;
`;
export const Left = styled.div`
width: 65%;
padding-right: 50px;
`;
export const Right = styled.div`
width: 35%;
padding-top: 5px;
`;
export const TopActions = styled.div`
display: flex;
justify-content: space-between;
padding: 21px 18px 0;
`;
export const TopActionsRight = styled.div`
display: flex;
align-items: center;
& > * {
margin-left: 4px;
}
`;
export const SectionTitle = styled.div`
margin: 24px 0 5px;
text-transform: uppercase;
color: ${color.textMedium};
${font.size(12.5)}
${font.bold}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Title/Styles.js
================================================
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
import { Textarea } from 'shared/components';
export const TitleTextarea = styled(Textarea)`
margin: 18px 0 0 -8px;
height: 44px;
width: 100%;
textarea {
padding: 7px 7px 8px;
line-height: 1.28;
border: none;
resize: none;
background: #fff;
border: 1px solid transparent;
box-shadow: 0 0 0 1px transparent;
transition: background 0.1s;
${font.size(24)}
${font.medium}
&:hover:not(:focus) {
background: ${color.backgroundLight};
}
}
`;
export const ErrorText = styled.div`
padding-top: 4px;
color: ${color.danger};
${font.size(13)}
${font.medium}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Title/index.jsx
================================================
import React, { Fragment, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { KeyCodes } from 'shared/constants/keyCodes';
import { is, generateErrors } from 'shared/utils/validation';
import { TitleTextarea, ErrorText } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
updateIssue: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsTitle = ({ issue, updateIssue }) => {
const $titleInputRef = useRef();
const [error, setError] = useState(null);
const handleTitleChange = () => {
setError(null);
const title = $titleInputRef.current.value;
if (title === issue.title) return;
const errors = generateErrors({ title }, { title: [is.required(), is.maxLength(200)] });
if (errors.title) {
setError(errors.title);
} else {
updateIssue({ title });
}
};
return (
{
if (event.keyCode === KeyCodes.ENTER) {
event.target.blur();
}
}}
/>
{error && {error} }
);
};
ProjectBoardIssueDetailsTitle.propTypes = propTypes;
export default ProjectBoardIssueDetailsTitle;
================================================
FILE: client/src/Project/Board/IssueDetails/Type/Styles.js
================================================
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
import { Button } from 'shared/components';
export const TypeButton = styled(Button)`
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${color.textMedium};
${font.size(13)}
`;
export const Type = styled.div`
display: flex;
align-items: center;
`;
export const TypeLabel = styled.div`
padding: 0 5px 0 7px;
${font.size(15)}
`;
================================================
FILE: client/src/Project/Board/IssueDetails/Type/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { IssueType, IssueTypeCopy } from 'shared/constants/issues';
import { IssueTypeIcon, Select } from 'shared/components';
import { TypeButton, Type, TypeLabel } from './Styles';
const propTypes = {
issue: PropTypes.object.isRequired,
updateIssue: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetailsType = ({ issue, updateIssue }) => (
({
value: type,
label: IssueTypeCopy[type],
}))}
onChange={type => updateIssue({ type })}
renderValue={({ value: type }) => (
}>
{`${IssueTypeCopy[type]}-${issue.id}`}
)}
renderOption={({ value: type }) => (
updateIssue({ type })}>
{IssueTypeCopy[type]}
)}
/>
);
ProjectBoardIssueDetailsType.propTypes = propTypes;
export default ProjectBoardIssueDetailsType;
================================================
FILE: client/src/Project/Board/IssueDetails/index.jsx
================================================
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import api from 'shared/utils/api';
import useApi from 'shared/hooks/api';
import { PageError, CopyLinkButton, Button, AboutTooltip } from 'shared/components';
import Loader from './Loader';
import Type from './Type';
import Delete from './Delete';
import Title from './Title';
import Description from './Description';
import Comments from './Comments';
import Status from './Status';
import AssigneesReporter from './AssigneesReporter';
import Priority from './Priority';
import EstimateTracking from './EstimateTracking';
import Dates from './Dates';
import { TopActions, TopActionsRight, Content, Left, Right } from './Styles';
const propTypes = {
issueId: PropTypes.string.isRequired,
projectUsers: PropTypes.array.isRequired,
fetchProject: PropTypes.func.isRequired,
updateLocalProjectIssues: PropTypes.func.isRequired,
modalClose: PropTypes.func.isRequired,
};
const ProjectBoardIssueDetails = ({
issueId,
projectUsers,
fetchProject,
updateLocalProjectIssues,
modalClose,
}) => {
const [{ data, error, setLocalData }, fetchIssue] = useApi.get(`/issues/${issueId}`);
if (!data) return ;
if (error) return ;
const { issue } = data;
const updateLocalIssueDetails = fields =>
setLocalData(currentData => ({ issue: { ...currentData.issue, ...fields } }));
const updateIssue = updatedFields => {
api.optimisticUpdate(`/issues/${issueId}`, {
updatedFields,
currentFields: issue,
setLocalData: fields => {
updateLocalIssueDetails(fields);
updateLocalProjectIssues(issue.id, fields);
},
});
};
return (
(
Give feedback
)}
/>
);
};
ProjectBoardIssueDetails.propTypes = propTypes;
export default ProjectBoardIssueDetails;
================================================
FILE: client/src/Project/Board/Lists/List/Issue/Styles.js
================================================
import styled, { css } from 'styled-components';
import { Link } from 'react-router-dom';
import { color, font, mixin } from 'shared/utils/styles';
import { Avatar } from 'shared/components';
export const IssueLink = styled(Link)`
display: block;
margin-bottom: 5px;
`;
export const Issue = styled.div`
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}
@media (max-width: 1100px) {
padding: 10px 8px;
}
&:hover {
background: ${color.backgroundLight};
}
${props =>
props.isBeingDragged &&
css`
transform: rotate(3deg);
box-shadow: 5px 10px 30px 0px rgba(9, 30, 66, 0.15);
`}
`;
export const Title = styled.p`
padding-bottom: 11px;
${font.size(15)}
@media (max-width: 1100px) {
${font.size(14.5)}
}
`;
export const Bottom = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
export const Assignees = styled.div`
display: flex;
flex-direction: row-reverse;
margin-left: 2px;
`;
export const AssigneeAvatar = styled(Avatar)`
margin-left: -2px;
box-shadow: 0 0 0 2px #fff;
`;
================================================
FILE: client/src/Project/Board/Lists/List/Issue/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { useRouteMatch } from 'react-router-dom';
import { Draggable } from 'react-beautiful-dnd';
import { IssueTypeIcon, IssuePriorityIcon } from 'shared/components';
import { IssueLink, Issue, Title, Bottom, Assignees, AssigneeAvatar } from './Styles';
const propTypes = {
projectUsers: PropTypes.array.isRequired,
issue: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
};
const ProjectBoardListIssue = ({ projectUsers, issue, index }) => {
const match = useRouteMatch();
const assignees = issue.userIds.map(userId => projectUsers.find(user => user.id === userId));
return (
{(provided, snapshot) => (
{issue.title}
{assignees.map(user => (
))}
)}
);
};
ProjectBoardListIssue.propTypes = propTypes;
export default ProjectBoardListIssue;
================================================
FILE: client/src/Project/Board/Lists/List/Styles.js
================================================
import styled from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
export const List = styled.div`
display: flex;
flex-direction: column;
margin: 0 5px;
min-height: 400px;
width: 25%;
border-radius: 3px;
background: ${color.backgroundLightest};
`;
export const Title = styled.div`
padding: 13px 10px 17px;
text-transform: uppercase;
color: ${color.textMedium};
${font.size(12.5)};
${mixin.truncateText}
`;
export const IssuesCount = styled.span`
text-transform: lowercase;
${font.size(13)};
`;
export const Issues = styled.div`
height: 100%;
padding: 0 5px;
`;
================================================
FILE: client/src/Project/Board/Lists/List/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import { Droppable } from 'react-beautiful-dnd';
import { intersection } from 'lodash';
import { IssueStatusCopy } from 'shared/constants/issues';
import Issue from './Issue';
import { List, Title, IssuesCount, Issues } from './Styles';
const propTypes = {
status: PropTypes.string.isRequired,
project: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
currentUserId: PropTypes.number,
};
const defaultProps = {
currentUserId: null,
};
const ProjectBoardList = ({ status, project, filters, currentUserId }) => {
const filteredIssues = filterIssues(project.issues, filters, currentUserId);
const filteredListIssues = getSortedListIssues(filteredIssues, status);
const allListIssues = getSortedListIssues(project.issues, status);
return (
{provided => (
{`${IssueStatusCopy[status]} `}
{formatIssuesCount(allListIssues, filteredListIssues)}
{filteredListIssues.map((issue, index) => (
))}
{provided.placeholder}
)}
);
};
const filterIssues = (projectIssues, filters, currentUserId) => {
const { searchTerm, userIds, myOnly, recent } = filters;
let issues = projectIssues;
if (searchTerm) {
issues = issues.filter(issue => issue.title.toLowerCase().includes(searchTerm.toLowerCase()));
}
if (userIds.length > 0) {
issues = issues.filter(issue => intersection(issue.userIds, userIds).length > 0);
}
if (myOnly && currentUserId) {
issues = issues.filter(issue => issue.userIds.includes(currentUserId));
}
if (recent) {
issues = issues.filter(issue => moment(issue.updatedAt).isAfter(moment().subtract(3, 'days')));
}
return issues;
};
const getSortedListIssues = (issues, status) =>
issues.filter(issue => issue.status === status).sort((a, b) => a.listPosition - b.listPosition);
const formatIssuesCount = (allListIssues, filteredListIssues) => {
if (allListIssues.length !== filteredListIssues.length) {
return `${filteredListIssues.length} of ${allListIssues.length}`;
}
return allListIssues.length;
};
ProjectBoardList.propTypes = propTypes;
ProjectBoardList.defaultProps = defaultProps;
export default ProjectBoardList;
================================================
FILE: client/src/Project/Board/Lists/Styles.js
================================================
import styled from 'styled-components';
export const Lists = styled.div`
display: flex;
margin: 26px -5px 0;
`;
================================================
FILE: client/src/Project/Board/Lists/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { DragDropContext } from 'react-beautiful-dnd';
import useCurrentUser from 'shared/hooks/currentUser';
import api from 'shared/utils/api';
import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/javascript';
import { IssueStatus } from 'shared/constants/issues';
import List from './List';
import { Lists } from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
updateLocalProjectIssues: PropTypes.func.isRequired,
};
const ProjectBoardLists = ({ project, filters, updateLocalProjectIssues }) => {
const { currentUserId } = useCurrentUser();
const handleIssueDrop = ({ draggableId, destination, source }) => {
if (!isPositionChanged(source, destination)) return;
const issueId = Number(draggableId);
api.optimisticUpdate(`/issues/${issueId}`, {
updatedFields: {
status: destination.droppableId,
listPosition: calculateIssueListPosition(project.issues, destination, source, issueId),
},
currentFields: project.issues.find(({ id }) => id === issueId),
setLocalData: fields => updateLocalProjectIssues(issueId, fields),
});
};
return (
{Object.values(IssueStatus).map(status => (
))}
);
};
const isPositionChanged = (destination, source) => {
if (!destination) return false;
const isSameList = destination.droppableId === source.droppableId;
const isSamePosition = destination.index === source.index;
return !isSameList || !isSamePosition;
};
const calculateIssueListPosition = (...args) => {
const { prevIssue, nextIssue } = getAfterDropPrevNextIssue(...args);
let position;
if (!prevIssue && !nextIssue) {
position = 1;
} else if (!prevIssue) {
position = nextIssue.listPosition - 1;
} else if (!nextIssue) {
position = prevIssue.listPosition + 1;
} else {
position = prevIssue.listPosition + (nextIssue.listPosition - prevIssue.listPosition) / 2;
}
return position;
};
const getAfterDropPrevNextIssue = (allIssues, destination, source, droppedIssueId) => {
const beforeDropDestinationIssues = getSortedListIssues(allIssues, destination.droppableId);
const droppedIssue = allIssues.find(issue => issue.id === droppedIssueId);
const isSameList = destination.droppableId === source.droppableId;
const afterDropDestinationIssues = isSameList
? moveItemWithinArray(beforeDropDestinationIssues, droppedIssue, destination.index)
: insertItemIntoArray(beforeDropDestinationIssues, droppedIssue, destination.index);
return {
prevIssue: afterDropDestinationIssues[destination.index - 1],
nextIssue: afterDropDestinationIssues[destination.index + 1],
};
};
const getSortedListIssues = (issues, status) =>
issues.filter(issue => issue.status === status).sort((a, b) => a.listPosition - b.listPosition);
ProjectBoardLists.propTypes = propTypes;
export default ProjectBoardLists;
================================================
FILE: client/src/Project/Board/index.jsx
================================================
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Route, useRouteMatch, useHistory } from 'react-router-dom';
import useMergeState from 'shared/hooks/mergeState';
import { Breadcrumbs, Modal } from 'shared/components';
import Header from './Header';
import Filters from './Filters';
import Lists from './Lists';
import IssueDetails from './IssueDetails';
const propTypes = {
project: PropTypes.object.isRequired,
fetchProject: PropTypes.func.isRequired,
updateLocalProjectIssues: PropTypes.func.isRequired,
};
const defaultFilters = {
searchTerm: '',
userIds: [],
myOnly: false,
recent: false,
};
const ProjectBoard = ({ project, fetchProject, updateLocalProjectIssues }) => {
const match = useRouteMatch();
const history = useHistory();
const [filters, mergeFilters] = useMergeState(defaultFilters);
return (
(
history.push(match.url)}
renderContent={modal => (
)}
/>
)}
/>
);
};
ProjectBoard.propTypes = propTypes;
export default ProjectBoard;
================================================
FILE: client/src/Project/IssueCreate/Styles.js
================================================
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
import { Button, Form } from 'shared/components';
export const FormElement = styled(Form.Element)`
padding: 25px 40px 35px;
`;
export const FormHeading = styled.div`
padding-bottom: 15px;
${font.size(21)}
`;
export const SelectItem = styled.div`
display: flex;
align-items: center;
margin-right: 15px;
${props => props.withBottomMargin && `margin-bottom: 5px;`}
`;
export const SelectItemLabel = styled.div`
padding: 0 3px 0 6px;
`;
export const Divider = styled.div`
margin-top: 22px;
border-top: 1px solid ${color.borderLightest};
`;
export const Actions = styled.div`
display: flex;
justify-content: flex-end;
padding-top: 30px;
`;
export const ActionButton = styled(Button)`
margin-left: 10px;
`;
================================================
FILE: client/src/Project/IssueCreate/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import {
IssueType,
IssueStatus,
IssuePriority,
IssueTypeCopy,
IssuePriorityCopy,
} from 'shared/constants/issues';
import toast from 'shared/utils/toast';
import useApi from 'shared/hooks/api';
import useCurrentUser from 'shared/hooks/currentUser';
import { Form, IssueTypeIcon, Icon, Avatar, IssuePriorityIcon } from 'shared/components';
import {
FormHeading,
FormElement,
SelectItem,
SelectItemLabel,
Divider,
Actions,
ActionButton,
} from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
fetchProject: PropTypes.func.isRequired,
onCreate: PropTypes.func.isRequired,
modalClose: PropTypes.func.isRequired,
};
const ProjectIssueCreate = ({ project, fetchProject, onCreate, modalClose }) => {
const [{ isCreating }, createIssue] = useApi.post('/issues');
const { currentUserId } = useCurrentUser();
return (
);
};
const typeOptions = Object.values(IssueType).map(type => ({
value: type,
label: IssueTypeCopy[type],
}));
const priorityOptions = Object.values(IssuePriority).map(priority => ({
value: priority,
label: IssuePriorityCopy[priority],
}));
const userOptions = project => project.users.map(user => ({ value: user.id, label: user.name }));
const renderType = ({ value: type }) => (
{IssueTypeCopy[type]}
);
const renderPriority = ({ value: priority }) => (
{IssuePriorityCopy[priority]}
);
const renderUser = project => ({ value: userId, removeOptionValue }) => {
const user = project.users.find(({ id }) => id === userId);
return (
removeOptionValue && removeOptionValue()}
>
{user.name}
{removeOptionValue && }
);
};
ProjectIssueCreate.propTypes = propTypes;
export default ProjectIssueCreate;
================================================
FILE: client/src/Project/IssueSearch/NoResultsSvg.jsx
================================================
import React from 'react';
const NoResults = () => (
);
export default NoResults;
================================================
FILE: client/src/Project/IssueSearch/Styles.js
================================================
import styled from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
import { InputDebounced, Spinner, Icon } from 'shared/components';
export const IssueSearch = styled.div`
padding: 25px 35px 60px;
`;
export const SearchInputCont = styled.div`
position: relative;
padding-right: 30px;
margin-bottom: 40px;
`;
export const SearchInputDebounced = styled(InputDebounced)`
height: 40px;
input {
padding: 0 0 0 32px;
border: none;
border-bottom: 2px solid ${color.primary};
background: #fff;
${font.size(21)}
&:focus,
&:hover {
box-shadow: none;
border: none;
border-bottom: 2px solid ${color.primary};
background: #fff;
}
}
`;
export const SearchIcon = styled(Icon)`
position: absolute;
top: 8px;
left: 0;
color: ${color.textMedium};
`;
export const SearchSpinner = styled(Spinner)`
position: absolute;
top: 5px;
right: 30px;
`;
export const Issue = styled.div`
display: flex;
align-items: center;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.1s;
${mixin.clickable}
&:hover {
background: ${color.backgroundLight};
}
`;
export const IssueData = styled.div`
padding-left: 15px;
`;
export const IssueTitle = styled.div`
color: ${color.textDark};
${font.size(15)}
`;
export const IssueTypeId = styled.div`
text-transform: uppercase;
color: ${color.textMedium};
${font.size(12.5)}
`;
export const SectionTitle = styled.div`
padding-bottom: 12px;
text-transform: uppercase;
color: ${color.textMedium};
${font.bold}
${font.size(11.5)}
`;
export const NoResults = styled.div`
padding-top: 50px;
text-align: center;
`;
export const NoResultsTitle = styled.div`
padding-top: 30px;
${font.medium}
${font.size(20)}
`;
export const NoResultsTip = styled.div`
padding-top: 10px;
${font.size(15)}
`;
================================================
FILE: client/src/Project/IssueSearch/index.jsx
================================================
import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { get } from 'lodash';
import useApi from 'shared/hooks/api';
import { sortByNewest } from 'shared/utils/javascript';
import { IssueTypeIcon } from 'shared/components';
import NoResultsSVG from './NoResultsSvg';
import {
IssueSearch,
SearchInputCont,
SearchInputDebounced,
SearchIcon,
SearchSpinner,
Issue,
IssueData,
IssueTitle,
IssueTypeId,
SectionTitle,
NoResults,
NoResultsTitle,
NoResultsTip,
} from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
};
const ProjectIssueSearch = ({ project }) => {
const [isSearchTermEmpty, setIsSearchTermEmpty] = useState(true);
const [{ data, isLoading }, fetchIssues] = useApi.get('/issues', {}, { lazy: true });
const matchingIssues = get(data, 'issues', []);
const recentIssues = sortByNewest(project.issues, 'createdAt').slice(0, 10);
const handleSearchChange = value => {
const searchTerm = value.trim();
setIsSearchTermEmpty(!searchTerm);
if (searchTerm) {
fetchIssues({ searchTerm });
}
};
return (
{isLoading && }
{isSearchTermEmpty && recentIssues.length > 0 && (
Recent Issues
{recentIssues.map(renderIssue)}
)}
{!isSearchTermEmpty && matchingIssues.length > 0 && (
Matching Issues
{matchingIssues.map(renderIssue)}
)}
{!isSearchTermEmpty && !isLoading && matchingIssues.length === 0 && (
We couldn't find anything matching your search
Try again with a different term.
)}
);
};
const renderIssue = issue => (
{issue.title}
{`${issue.type}-${issue.id}`}
);
ProjectIssueSearch.propTypes = propTypes;
export default ProjectIssueSearch;
================================================
FILE: client/src/Project/NavbarLeft/Styles.js
================================================
import styled from 'styled-components';
import { NavLink } from 'react-router-dom';
import { font, sizes, color, mixin, zIndexValues } from 'shared/utils/styles';
import { Logo } from 'shared/components';
export const NavLeft = styled.aside`
z-index: ${zIndexValues.navLeft};
position: fixed;
top: 0;
left: 0;
overflow-x: hidden;
height: 100vh;
width: ${sizes.appNavBarLeftWidth}px;
background: ${color.backgroundDarkPrimary};
transition: all 0.1s;
${mixin.hardwareAccelerate}
&:hover {
width: 200px;
box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.6);
}
`;
export const LogoLink = styled(NavLink)`
display: block;
position: relative;
left: 0;
margin: 20px 0 10px;
transition: left 0.1s;
`;
export const StyledLogo = styled(Logo)`
display: inline-block;
margin-left: 8px;
padding: 10px;
${mixin.clickable}
`;
export const Bottom = styled.div`
position: absolute;
bottom: 20px;
left: 0;
width: 100%;
`;
export const Item = styled.div`
position: relative;
width: 100%;
height: 42px;
line-height: 42px;
padding-left: 64px;
color: #deebff;
transition: color 0.1s;
${mixin.clickable}
&:hover {
background: rgba(255, 255, 255, 0.1);
}
i {
position: absolute;
left: 18px;
}
`;
export const ItemText = styled.div`
position: relative;
right: 12px;
visibility: hidden;
opacity: 0;
text-transform: uppercase;
transition: all 0.1s;
transition-property: right, visibility, opacity;
${font.bold}
${font.size(12)}
${NavLeft}:hover & {
right: 0;
visibility: visible;
opacity: 1;
}
`;
================================================
FILE: client/src/Project/NavbarLeft/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { Icon, AboutTooltip } from 'shared/components';
import { NavLeft, LogoLink, StyledLogo, Bottom, Item, ItemText } from './Styles';
const propTypes = {
issueSearchModalOpen: PropTypes.func.isRequired,
issueCreateModalOpen: PropTypes.func.isRequired,
};
const ProjectNavbarLeft = ({ issueSearchModalOpen, issueCreateModalOpen }) => (
-
Search issues
-
Create Issue
(
-
About
)}
/>
);
ProjectNavbarLeft.propTypes = propTypes;
export default ProjectNavbarLeft;
================================================
FILE: client/src/Project/ProjectSettings/Styles.js
================================================
import styled from 'styled-components';
import { font } from 'shared/utils/styles';
import { Button, Form } from 'shared/components';
export const FormCont = styled.div`
display: flex;
justify-content: center;
`;
export const FormElement = styled(Form.Element)`
width: 100%;
max-width: 640px;
`;
export const FormHeading = styled.h1`
padding: 6px 0 15px;
${font.size(24)}
${font.medium}
`;
export const ActionButton = styled(Button)`
margin-top: 30px;
`;
================================================
FILE: client/src/Project/ProjectSettings/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { ProjectCategory, ProjectCategoryCopy } from 'shared/constants/projects';
import toast from 'shared/utils/toast';
import useApi from 'shared/hooks/api';
import { Form, Breadcrumbs } from 'shared/components';
import { FormCont, FormHeading, FormElement, ActionButton } from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
fetchProject: PropTypes.func.isRequired,
};
const ProjectSettings = ({ project, fetchProject }) => {
const [{ isUpdating }, updateProject] = useApi.put('/project');
return (
);
};
const categoryOptions = Object.values(ProjectCategory).map(category => ({
value: category,
label: ProjectCategoryCopy[category],
}));
ProjectSettings.propTypes = propTypes;
export default ProjectSettings;
================================================
FILE: client/src/Project/Sidebar/Styles.js
================================================
import styled from 'styled-components';
import { color, sizes, font, mixin, zIndexValues } from 'shared/utils/styles';
export const Sidebar = styled.div`
position: fixed;
z-index: ${zIndexValues.navLeft - 1};
top: 0;
left: ${sizes.appNavBarLeftWidth}px;
height: 100vh;
width: ${sizes.secondarySideBarWidth}px;
padding: 0 16px 24px;
background: ${color.backgroundLightest};
border-right: 1px solid ${color.borderLightest};
${mixin.scrollableY}
${mixin.customScrollbar()}
@media (max-width: 1100px) {
width: ${sizes.secondarySideBarWidth - 10}px;
}
@media (max-width: 999px) {
display: none;
}
`;
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 Divider = styled.div`
margin-top: 17px;
padding-top: 18px;
border-top: 1px solid ${color.borderLight};
`;
export const LinkItem = styled.div`
position: relative;
display: flex;
padding: 8px 12px;
border-radius: 3px;
${mixin.clickable}
${props =>
!props.to ? `cursor: not-allowed;` : `&:hover { background: ${color.backgroundLight}; }`}
i {
margin-right: 15px;
font-size: 20px;
}
&.active {
color: ${color.primary};
background: ${color.backgroundLight};
i {
color: ${color.primary};
}
}
`;
export const LinkText = styled.div`
padding-top: 2px;
${font.size(14.7)};
`;
export const NotImplemented = styled.div`
display: inline-block;
position: absolute;
top: 7px;
left: 40px;
width: 140px;
padding: 5px 0 5px 8px;
border-radius: 3px;
text-transform: uppercase;
color: ${color.textDark};
background: ${color.backgroundMedium};
opacity: 0;
${font.size(11.5)};
${font.bold}
${LinkItem}:hover & {
opacity: 1;
}
`;
================================================
FILE: client/src/Project/Sidebar/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { NavLink, useRouteMatch } from 'react-router-dom';
import { ProjectCategoryCopy } from 'shared/constants/projects';
import { Icon, ProjectAvatar } from 'shared/components';
import {
Sidebar,
ProjectInfo,
ProjectTexts,
ProjectName,
ProjectCategory,
Divider,
LinkItem,
LinkText,
NotImplemented,
} from './Styles';
const propTypes = {
project: PropTypes.object.isRequired,
};
const ProjectSidebar = ({ project }) => {
const match = useRouteMatch();
return (
{project.name}
{ProjectCategoryCopy[project.category]} project
{renderLinkItem(match, 'Kanban Board', 'board', '/board')}
{renderLinkItem(match, 'Project settings', 'settings', '/settings')}
{renderLinkItem(match, 'Releases', 'shipping')}
{renderLinkItem(match, 'Issues and filters', 'issues')}
{renderLinkItem(match, 'Pages', 'page')}
{renderLinkItem(match, 'Reports', 'reports')}
{renderLinkItem(match, 'Components', 'component')}
);
};
const renderLinkItem = (match, text, iconType, path) => {
const isImplemented = !!path;
const linkItemProps = isImplemented
? { as: NavLink, exact: true, to: `${match.path}${path}` }
: { as: 'div' };
return (
{text}
{!isImplemented && Not implemented }
);
};
ProjectSidebar.propTypes = propTypes;
export default ProjectSidebar;
================================================
FILE: client/src/Project/Styles.js
================================================
import styled from 'styled-components';
import { sizes } from 'shared/utils/styles';
const paddingLeft = sizes.appNavBarLeftWidth + sizes.secondarySideBarWidth + 40;
export const ProjectPage = styled.div`
padding: 25px 32px 50px ${paddingLeft}px;
@media (max-width: 1100px) {
padding: 25px 20px 50px ${paddingLeft - 20}px;
}
@media (max-width: 999px) {
padding-left: ${paddingLeft - 20 - sizes.secondarySideBarWidth}px;
}
`;
================================================
FILE: client/src/Project/index.jsx
================================================
import React from 'react';
import { Route, Redirect, useRouteMatch, useHistory } from 'react-router-dom';
import useApi from 'shared/hooks/api';
import { updateArrayItemById } from 'shared/utils/javascript';
import { createQueryParamModalHelpers } from 'shared/utils/queryParamModal';
import { PageLoader, PageError, Modal } from 'shared/components';
import NavbarLeft from './NavbarLeft';
import Sidebar from './Sidebar';
import Board from './Board';
import IssueSearch from './IssueSearch';
import IssueCreate from './IssueCreate';
import ProjectSettings from './ProjectSettings';
import { ProjectPage } from './Styles';
const Project = () => {
const match = useRouteMatch();
const history = useHistory();
const issueSearchModalHelpers = createQueryParamModalHelpers('issue-search');
const issueCreateModalHelpers = createQueryParamModalHelpers('issue-create');
const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project');
if (!data) return ;
if (error) return ;
const { project } = data;
const updateLocalProjectIssues = (issueId, updatedFields) => {
setLocalData(currentData => ({
project: {
...currentData.project,
issues: updateArrayItemById(currentData.project.issues, issueId, updatedFields),
},
}));
};
return (
{issueSearchModalHelpers.isOpen() && (
}
/>
)}
{issueCreateModalHelpers.isOpen() && (
(
history.push(`${match.url}/board`)}
modalClose={modal.close}
/>
)}
/>
)}
(
)}
/>
}
/>
{match.isExact && }
);
};
export default Project;
================================================
FILE: client/src/browserHistory.js
================================================
import { createBrowserHistory } from 'history';
export default createBrowserHistory();
================================================
FILE: client/src/index.html
================================================
Jira Clone
================================================
FILE: client/src/index.jsx
================================================
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import React from 'react';
import ReactDOM from 'react-dom';
import App from 'App';
ReactDOM.render( , document.getElementById('root'));
================================================
FILE: client/src/shared/components/AboutTooltip/Styles.js
================================================
import styled from 'styled-components';
import { font } from 'shared/utils/styles';
export const FeedbackDropdown = styled.div`
padding: 16px 24px 24px;
`;
export const FeedbackImageCont = styled.div`
padding: 24px 56px 20px;
`;
export const FeedbackImage = styled.img`
width: 100%;
`;
export const FeedbackParagraph = styled.p`
margin-bottom: 12px;
${font.size(15)}
&:last-of-type {
margin-bottom: 22px;
}
`;
================================================
FILE: client/src/shared/components/AboutTooltip/index.jsx
================================================
import React from 'react';
import Button from 'shared/components/Button';
import Tooltip from 'shared/components/Tooltip';
import feedbackImage from './assets/feedback.png';
import { FeedbackDropdown, FeedbackImageCont, FeedbackImage, FeedbackParagraph } from './Styles';
const AboutTooltip = tooltipProps => (
(
This simplified Jira clone is built with React on the front-end and Node/TypeScript on the
back-end.
{'Read more on my website or reach out via '}
ivor@codetree.co
Visit Website
Github Repo
)}
/>
);
export default AboutTooltip;
================================================
FILE: client/src/shared/components/Avatar/Styles.js
================================================
import styled from 'styled-components';
import { font, mixin } from 'shared/utils/styles';
export const Image = styled.div`
display: inline-block;
width: ${props => props.size}px;
height: ${props => props.size}px;
border-radius: 100%;
${props => mixin.backgroundImage(props.avatarUrl)}
`;
export const Letter = styled.div`
display: inline-block;
width: ${props => props.size}px;
height: ${props => props.size}px;
border-radius: 100%;
text-transform: uppercase;
color: #fff;
background: ${props => props.color};
${font.medium}
${props => font.size(Math.round(props.size / 1.7))}
& > span {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
`;
================================================
FILE: client/src/shared/components/Avatar/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { Image, Letter } from './Styles';
const propTypes = {
className: PropTypes.string,
avatarUrl: PropTypes.string,
name: PropTypes.string,
size: PropTypes.number,
};
const defaultProps = {
className: undefined,
avatarUrl: null,
name: '',
size: 32,
};
const Avatar = ({ className, avatarUrl, name, size, ...otherProps }) => {
const sharedProps = {
className,
size,
'data-testid': name ? `avatar:${name}` : 'avatar',
...otherProps,
};
if (avatarUrl) {
return ;
}
return (
{name.charAt(0)}
);
};
const colors = [
'#DA7657',
'#6ADA57',
'#5784DA',
'#AA57DA',
'#DA5757',
'#DA5792',
'#57DACA',
'#57A5DA',
];
const getColorFromName = name => colors[name.toLocaleLowerCase().charCodeAt(0) % colors.length];
Avatar.propTypes = propTypes;
Avatar.defaultProps = defaultProps;
export default Avatar;
================================================
FILE: client/src/shared/components/Breadcrumbs/Styles.js
================================================
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const Container = styled.div`
color: ${color.textMedium};
${font.size(15)};
`;
export const Divider = styled.span`
position: relative;
top: 2px;
margin: 0 10px;
${font.size(18)};
`;
================================================
FILE: client/src/shared/components/Breadcrumbs/index.jsx
================================================
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Container, Divider } from './Styles';
const propTypes = {
items: PropTypes.array.isRequired,
};
const Breadcrumbs = ({ items }) => (
{items.map((item, index) => (
{index !== 0 && / }
{item}
))}
);
Breadcrumbs.propTypes = propTypes;
export default Breadcrumbs;
================================================
FILE: client/src/shared/components/Button/Styles.js
================================================
import styled, { css } from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
import Spinner from 'shared/components/Spinner';
export const StyledButton = styled.button`
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
vertical-align: middle;
line-height: 1;
padding: 0 ${props => (props.iconOnly ? 9 : 12)}px;
white-space: nowrap;
border-radius: 3px;
transition: all 0.1s;
appearance: none;
${mixin.clickable}
${font.size(14.5)}
${props => buttonVariants[props.variant]}
&:disabled {
opacity: 0.6;
cursor: default;
}
`;
const colored = css`
color: #fff;
background: ${props => color[props.variant]};
${font.medium}
&:not(:disabled) {
&:hover {
background: ${props => mixin.lighten(color[props.variant], 0.15)};
}
&:active {
background: ${props => mixin.darken(color[props.variant], 0.1)};
}
${props =>
props.isActive &&
css`
background: ${mixin.darken(color[props.variant], 0.1)} !important;
`}
}
`;
const secondaryAndEmptyShared = css`
color: ${color.textDark};
${font.regular}
&:not(:disabled) {
&:hover {
background: ${color.backgroundLight};
}
&:active {
color: ${color.primary};
background: ${color.backgroundLightPrimary};
}
${props =>
props.isActive &&
css`
color: ${color.primary};
background: ${color.backgroundLightPrimary} !important;
`}
}
`;
const buttonVariants = {
primary: colored,
success: colored,
danger: colored,
secondary: css`
background: ${color.secondary};
${secondaryAndEmptyShared};
`,
empty: css`
background: #fff;
${secondaryAndEmptyShared};
`,
};
export const StyledSpinner = styled(Spinner)`
position: relative;
top: 1px;
`;
export const Text = styled.div`
padding-left: ${props => (props.withPadding ? 7 : 0)}px;
`;
================================================
FILE: client/src/shared/components/Button/index.jsx
================================================
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import { color } from 'shared/utils/styles';
import Icon from 'shared/components/Icon';
import { StyledButton, StyledSpinner, Text } from './Styles';
const propTypes = {
className: PropTypes.string,
children: PropTypes.node,
variant: PropTypes.oneOf(['primary', 'success', 'danger', 'secondary', 'empty']),
icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
iconSize: PropTypes.number,
disabled: PropTypes.bool,
isWorking: PropTypes.bool,
onClick: PropTypes.func,
};
const defaultProps = {
className: undefined,
children: undefined,
variant: 'secondary',
icon: undefined,
iconSize: 18,
disabled: false,
isWorking: false,
onClick: () => {},
};
const Button = forwardRef(
({ children, variant, icon, iconSize, disabled, isWorking, onClick, ...buttonProps }, ref) => {
const handleClick = () => {
if (!disabled && !isWorking) {
onClick();
}
};
return (
{isWorking && }
{!isWorking && icon && typeof icon === 'string' ? (
) : (
icon
)}
{children && {children} }
);
},
);
const getIconColor = variant =>
['secondary', 'empty'].includes(variant) ? color.textDark : '#fff';
Button.propTypes = propTypes;
Button.defaultProps = defaultProps;
export default Button;
================================================
FILE: client/src/shared/components/ConfirmModal/Styles.js
================================================
import styled from 'styled-components';
import { font } from 'shared/utils/styles';
import Modal from 'shared/components/Modal';
import Button from 'shared/components/Button';
export const StyledConfirmModal = styled(Modal)`
padding: 35px 40px 40px;
`;
export const Title = styled.div`
padding-bottom: 25px;
${font.medium}
${font.size(22)}
line-height: 1.5;
`;
export const Message = styled.p`
padding-bottom: 25px;
white-space: pre-wrap;
${font.size(15)}
`;
export const Actions = styled.div`
display: flex;
padding-top: 6px;
`;
export const StyledButton = styled(Button)`
margin-right: 10px;
`;
================================================
FILE: client/src/shared/components/ConfirmModal/index.jsx
================================================
import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types';
import { StyledConfirmModal, Title, Message, Actions, StyledButton } from './Styles';
const propTypes = {
className: PropTypes.string,
variant: PropTypes.oneOf(['primary', 'danger']),
title: PropTypes.string,
message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
confirmText: PropTypes.string,
cancelText: PropTypes.string,
onConfirm: PropTypes.func.isRequired,
renderLink: PropTypes.func.isRequired,
};
const defaultProps = {
className: undefined,
variant: 'primary',
title: 'Warning',
message: 'Are you sure you want to continue with this action?',
confirmText: 'Confirm',
cancelText: 'Cancel',
};
const ConfirmModal = ({
className,
variant,
title,
message,
confirmText,
cancelText,
onConfirm,
renderLink,
}) => {
const [isWorking, setWorking] = useState(false);
const handleConfirm = modal => {
setWorking(true);
onConfirm({
close: () => {
modal.close();
setWorking(false);
},
});
};
return (
(
{title}
{message && {message} }
handleConfirm(modal)}
>
{confirmText}
{cancelText}
)}
/>
);
};
ConfirmModal.propTypes = propTypes;
ConfirmModal.defaultProps = defaultProps;
export default ConfirmModal;
================================================
FILE: client/src/shared/components/CopyLinkButton.jsx
================================================
import React, { useState } from 'react';
import { copyToClipboard } from 'shared/utils/browser';
import { Button } from 'shared/components';
const CopyLinkButton = ({ ...buttonProps }) => {
const [isLinkCopied, setLinkCopied] = useState(false);
const handleLinkCopy = () => {
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
copyToClipboard(window.location.href);
};
return (
{isLinkCopied ? 'Link Copied' : 'Copy link'}
);
};
export default CopyLinkButton;
================================================
FILE: client/src/shared/components/DatePicker/DateSection.jsx
================================================
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import { times, range } from 'lodash';
import { formatDate, formatDateTimeForAPI } from 'shared/utils/dateTime';
import Icon from 'shared/components/Icon';
import {
DateSection,
YearSelect,
SelectedMonthYear,
Grid,
PrevNextIcons,
DayName,
Day,
} from './Styles';
const propTypes = {
withTime: PropTypes.bool,
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
setDropdownOpen: PropTypes.func.isRequired,
};
const defaultProps = {
withTime: true,
value: undefined,
};
const DatePickerDateSection = ({ withTime, value, onChange, setDropdownOpen }) => {
const [selectedMonth, setSelectedMonth] = useState(moment(value).startOf('month'));
const handleYearChange = year => {
setSelectedMonth(moment(selectedMonth).set({ year: Number(year) }));
};
const handleMonthChange = addOrSubtract => {
setSelectedMonth(moment(selectedMonth)[addOrSubtract](1, 'month'));
};
const handleDayChange = newDate => {
const existingHour = value ? moment(value).hour() : '00';
const existingMinute = value ? moment(value).minute() : '00';
const newDateWithExistingTime = newDate.set({
hour: existingHour,
minute: existingMinute,
});
onChange(formatDateTimeForAPI(newDateWithExistingTime));
if (!withTime) {
setDropdownOpen(false);
}
};
return (
{formatDate(selectedMonth, 'MMM YYYY')}
handleYearChange(event.target.value)}>
{generateYearOptions().map(option => (
{option.label}
))}
handleMonthChange('subtract')} />
handleMonthChange('add')} />
{generateWeekDayNames().map(name => (
{name}
))}
{generateFillerDaysBeforeMonthStart(selectedMonth).map(i => (
))}
{generateMonthDays(selectedMonth).map(date => (
handleDayChange(date)}
>
{formatDate(date, 'D')}
))}
{generateFillerDaysAfterMonthEnd(selectedMonth).map(i => (
))}
);
};
const currentYear = moment().year();
const generateYearOptions = () => [
{ label: 'Year', value: '' },
...times(50, i => ({ label: `${i + currentYear - 10}`, value: `${i + currentYear - 10}` })),
];
const generateWeekDayNames = () => moment.weekdaysMin(true);
const generateFillerDaysBeforeMonthStart = selectedMonth => {
const count = selectedMonth.diff(moment(selectedMonth).startOf('week'), 'days');
return range(count);
};
const generateMonthDays = selectedMonth =>
times(selectedMonth.daysInMonth()).map(i => moment(selectedMonth).add(i, 'days'));
const generateFillerDaysAfterMonthEnd = selectedMonth => {
const selectedMonthEnd = moment(selectedMonth).endOf('month');
const weekEnd = moment(selectedMonthEnd).endOf('week');
const count = weekEnd.diff(selectedMonthEnd, 'days');
return range(count);
};
DatePickerDateSection.propTypes = propTypes;
DatePickerDateSection.defaultProps = defaultProps;
export default DatePickerDateSection;
================================================
FILE: client/src/shared/components/DatePicker/Styles.js
================================================
import styled, { css } from 'styled-components';
import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
export const StyledDatePicker = styled.div`
position: relative;
`;
export const Dropdown = styled.div`
z-index: ${zIndexValues.dropdown};
position: absolute;
top: 130%;
right: 0;
width: 270px;
border-radius: 3px;
background: #fff;
${mixin.boxShadowDropdown}
${props =>
props.withTime &&
css`
width: 360px;
padding-right: 90px;
`}
`;
export const DateSection = styled.div`
position: relative;
padding: 20px;
`;
export const SelectedMonthYear = styled.div`
display: inline-block;
padding-left: 7px;
${font.bold}
${font.size(16)}
`;
export const YearSelect = styled.select`
margin-left: 5px;
width: 60px;
height: 22px;
${font.size(13)}
`;
export const PrevNextIcons = styled.div`
position: absolute;
top: 12px;
right: 19px;
i {
padding: 7px 5px 4px;
font-size: 22px;
color: ${color.textLight};
${mixin.clickable}
&:hover {
color: ${color.textDarkest};
}
}
`;
export const Grid = styled.div`
display: flex;
flex-wrap: wrap;
padding-top: 15px;
text-align: center;
`;
export const DayName = styled.div`
width: 14.28%;
height: 30px;
line-height: 30px;
color: ${color.textLight};
${font.size(13)}
`;
export const Day = styled.div`
width: 14.28%;
height: 30px;
line-height: 30px;
border-radius: 3px;
${font.size(15)}
${props => !props.isFiller && hoverStyles}
${props => props.isToday && font.bold}
${props => props.isSelected && selectedStyles}
`;
export const TimeSection = styled.div`
position: absolute;
top: 0;
right: 0;
height: 100%;
width: 90px;
padding: 5px 0;
border-left: 1px solid ${color.borderLight};
${mixin.scrollableY}
`;
export const Time = styled.div`
padding: 5px 0 5px 20px;
${font.size(14)}
${props => !props.isFiller && hoverStyles}
${props => props.isSelected && selectedStyles}
`;
const hoverStyles = css`
${mixin.clickable}
&:hover {
background: ${color.backgroundMedium};
}
`;
const selectedStyles = css`
color: #fff;
&:hover,
& {
background: ${color.primary};
}
`;
================================================
FILE: client/src/shared/components/DatePicker/TimeSection.jsx
================================================
import React, { useLayoutEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import { range } from 'lodash';
import { formatDate, formatDateTimeForAPI } from 'shared/utils/dateTime';
import { TimeSection, Time } from './Styles';
const propTypes = {
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
setDropdownOpen: PropTypes.func.isRequired,
};
const defaultProps = {
value: undefined,
};
const DatePickerTimeSection = ({ value, onChange, setDropdownOpen }) => {
const $sectionRef = useRef();
useLayoutEffect(() => {
scrollToSelectedTime($sectionRef.current, value);
}, [value]);
const handleTimeChange = newTime => {
const [newHour, newMinute] = newTime.split(':');
const existingDateWithNewTime = moment(value).set({
hour: Number(newHour),
minute: Number(newMinute),
});
onChange(formatDateTimeForAPI(existingDateWithNewTime));
setDropdownOpen(false);
};
return (
{generateTimes().map(time => (
handleTimeChange(time)}
>
{time}
))}
);
};
const formatTime = value => formatDate(value, 'HH:mm');
const scrollToSelectedTime = ($scrollCont, value) => {
if (!$scrollCont) return;
const $selectedTime = $scrollCont.querySelector(`[data-time="${formatTime(value)}"]`);
if (!$selectedTime) return;
$scrollCont.scrollTop = $selectedTime.offsetTop - 80;
};
const generateTimes = () =>
range(48).map(i => {
const hour = `${Math.floor(i / 2)}`;
const paddedHour = hour.length < 2 ? `0${hour}` : hour;
const minute = i % 2 === 0 ? '00' : '30';
return `${paddedHour}:${minute}`;
});
DatePickerTimeSection.propTypes = propTypes;
DatePickerTimeSection.defaultProps = defaultProps;
export default DatePickerTimeSection;
================================================
FILE: client/src/shared/components/DatePicker/index.jsx
================================================
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { formatDate, formatDateTime } from 'shared/utils/dateTime';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import Input from 'shared/components/Input';
import DateSection from './DateSection';
import TimeSection from './TimeSection';
import { StyledDatePicker, Dropdown } from './Styles';
const propTypes = {
className: PropTypes.string,
withTime: PropTypes.bool,
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
const defaultProps = {
className: undefined,
withTime: true,
value: undefined,
};
const DatePicker = ({ className, withTime, value, onChange, ...inputProps }) => {
const [isDropdownOpen, setDropdownOpen] = useState(false);
const $containerRef = useRef();
useOnOutsideClick($containerRef, isDropdownOpen, () => setDropdownOpen(false));
return (
setDropdownOpen(true)}
/>
{isDropdownOpen && (
{withTime && (
)}
)}
);
};
const getFormattedInputValue = (value, withTime) => {
if (!value) return '';
return withTime ? formatDateTime(value) : formatDate(value);
};
DatePicker.propTypes = propTypes;
DatePicker.defaultProps = defaultProps;
export default DatePicker;
================================================
FILE: client/src/shared/components/Form/Field.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { uniqueId } from 'lodash';
import Input from 'shared/components/Input';
import Select from 'shared/components/Select';
import Textarea from 'shared/components/Textarea';
import TextEditor from 'shared/components/TextEditor';
import DatePicker from 'shared/components/DatePicker';
import { StyledField, FieldLabel, FieldTip, FieldError } from './Styles';
const propTypes = {
className: PropTypes.string,
label: PropTypes.string,
tip: PropTypes.string,
error: PropTypes.string,
name: PropTypes.string,
};
const defaultProps = {
className: undefined,
label: undefined,
tip: undefined,
error: undefined,
name: undefined,
};
const generateField = FormComponent => {
const FieldComponent = ({ className, label, tip, error, name, ...otherProps }) => {
const fieldId = uniqueId('form-field-');
return (
{label && {label} }
{tip && {tip} }
{error && {error} }
);
};
FieldComponent.propTypes = propTypes;
FieldComponent.defaultProps = defaultProps;
return FieldComponent;
};
export default {
Input: generateField(Input),
Select: generateField(Select),
Textarea: generateField(Textarea),
TextEditor: generateField(TextEditor),
DatePicker: generateField(DatePicker),
};
================================================
FILE: client/src/shared/components/Form/Styles.js
================================================
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const StyledField = styled.div`
margin-top: 20px;
`;
export const FieldLabel = styled.label`
display: block;
padding-bottom: 5px;
color: ${color.textMedium};
${font.medium}
${font.size(13)}
`;
export const FieldTip = styled.div`
padding-top: 6px;
color: ${color.textMedium};
${font.size(12.5)}
`;
export const FieldError = styled.div`
margin-top: 6px;
line-height: 1;
color: ${color.danger};
${font.medium}
${font.size(12.5)}
`;
================================================
FILE: client/src/shared/components/Form/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { Formik, Form as FormikForm, Field as FormikField } from 'formik';
import { get, mapValues } from 'lodash';
import toast from 'shared/utils/toast';
import { is, generateErrors } from 'shared/utils/validation';
import Field from './Field';
const propTypes = {
validate: PropTypes.func,
validations: PropTypes.object,
validateOnBlur: PropTypes.bool,
};
const defaultProps = {
validate: undefined,
validations: undefined,
validateOnBlur: false,
};
const Form = ({ validate, validations, ...otherProps }) => (
{
if (validate) {
return validate(values);
}
if (validations) {
return generateErrors(values, validations);
}
return {};
}}
/>
);
Form.Element = props => ;
Form.Field = mapValues(Field, FieldComponent => ({ name, validate, ...props }) => (
{({ field, form: { touched, errors, setFieldValue } }) => (
setFieldValue(name, value)}
/>
)}
));
Form.initialValues = (data, getFieldValues) =>
getFieldValues((key, defaultValue = '') => {
const value = get(data, key);
return value === undefined || value === null ? defaultValue : value;
});
Form.handleAPIError = (error, form) => {
if (error.data.fields) {
form.setErrors(error.data.fields);
} else {
toast.error(error);
}
};
Form.is = is;
Form.propTypes = propTypes;
Form.defaultProps = defaultProps;
export default Form;
================================================
FILE: client/src/shared/components/Icon/Styles.js
================================================
import styled from 'styled-components';
export const StyledIcon = styled.i`
display: inline-block;
font-size: ${props => `${props.size}px`};
${props =>
props.left || props.top ? `transform: translate(${props.left}px, ${props.top}px);` : ''}
&:before {
content: "${props => props.code}";
font-family: "jira" !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
`;
================================================
FILE: client/src/shared/components/Icon/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { StyledIcon } from './Styles';
const fontIconCodes = {
[`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',
[`feedback`]: '\\e918',
[`trash`]: '\\e912',
[`github`]: '\\e915',
[`shipping`]: '\\e91c',
[`component`]: '\\e91a',
[`reports`]: '\\e91b',
[`page`]: '\\e919',
[`calendar`]: '\\e91d',
[`arrow-left`]: '\\e91e',
[`arrow-right`]: '\\e91f',
};
const propTypes = {
className: PropTypes.string,
type: PropTypes.oneOf(Object.keys(fontIconCodes)).isRequired,
size: PropTypes.number,
left: PropTypes.number,
top: PropTypes.number,
};
const defaultProps = {
className: undefined,
size: 16,
left: 0,
top: 0,
};
const Icon = ({ type, ...iconProps }) => (
);
Icon.propTypes = propTypes;
Icon.defaultProps = defaultProps;
export default Icon;
================================================
FILE: client/src/shared/components/Input/Styles.js
================================================
import styled, { css } from 'styled-components';
import { color, font } from 'shared/utils/styles';
import Icon from 'shared/components/Icon';
export const StyledInput = styled.div`
position: relative;
display: inline-block;
height: 32px;
width: 100%;
`;
export const InputElement = styled.input`
height: 100%;
width: 100%;
padding: 0 7px;
border-radius: 3px;
border: 1px solid ${color.borderLightest};
color: ${color.textDarkest};
background: ${color.backgroundLightest};
transition: background 0.1s;
${font.regular}
${font.size(15)}
${props => props.hasIcon && 'padding-left: 32px;'}
&:hover {
background: ${color.backgroundLight};
}
&:focus {
background: #fff;
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
}
${props =>
props.invalid &&
css`
&,
&:focus {
border: 1px solid ${color.danger};
box-shadow: none;
}
`}
`;
export const StyledIcon = styled(Icon)`
position: absolute;
top: 8px;
left: 8px;
pointer-events: none;
color: ${color.textMedium};
`;
================================================
FILE: client/src/shared/components/Input/index.jsx
================================================
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import { StyledInput, InputElement, StyledIcon } from './Styles';
const propTypes = {
className: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
icon: PropTypes.string,
invalid: PropTypes.bool,
filter: PropTypes.instanceOf(RegExp),
onChange: PropTypes.func,
};
const defaultProps = {
className: undefined,
value: undefined,
icon: undefined,
invalid: false,
filter: undefined,
onChange: () => {},
};
const Input = forwardRef(({ icon, className, filter, onChange, ...inputProps }, ref) => {
const handleChange = event => {
if (!filter || filter.test(event.target.value)) {
onChange(event.target.value, event);
}
};
return (
{icon && }
);
});
Input.propTypes = propTypes;
Input.defaultProps = defaultProps;
export default Input;
================================================
FILE: client/src/shared/components/InputDebounced.jsx
================================================
import React, { useState, useRef, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { debounce } from 'lodash';
import { Input } from 'shared/components';
const propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
onChange: PropTypes.func.isRequired,
};
const defaultProps = {
value: undefined,
};
const InputDebounced = ({ onChange, value: propsValue, ...inputProps }) => {
const [value, setValue] = useState(propsValue);
const isControlled = propsValue !== undefined;
const handleChange = useCallback(
debounce(newValue => onChange(newValue), 500),
[],
);
const valueRef = useRef(value);
valueRef.current = value;
useEffect(() => {
if (propsValue !== valueRef.current) {
setValue(propsValue);
}
}, [propsValue]);
return (
{
setValue(newValue);
handleChange(newValue);
}}
/>
);
};
InputDebounced.propTypes = propTypes;
InputDebounced.defaultProps = defaultProps;
export default InputDebounced;
================================================
FILE: client/src/shared/components/IssuePriorityIcon/Styles.js
================================================
import styled from 'styled-components';
import { issuePriorityColors } from 'shared/utils/styles';
import { Icon } from 'shared/components';
export const PriorityIcon = styled(Icon)`
color: ${props => issuePriorityColors[props.color]};
`;
================================================
FILE: client/src/shared/components/IssuePriorityIcon/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { IssuePriority } from 'shared/constants/issues';
import { PriorityIcon } from './Styles';
const propTypes = {
priority: PropTypes.string.isRequired,
};
const IssuePriorityIcon = ({ priority, ...otherProps }) => {
const iconType = [IssuePriority.LOW, IssuePriority.LOWEST].includes(priority)
? 'arrow-down'
: 'arrow-up';
return ;
};
IssuePriorityIcon.propTypes = propTypes;
export default IssuePriorityIcon;
================================================
FILE: client/src/shared/components/IssueTypeIcon/Styles.js
================================================
import styled from 'styled-components';
import { issueTypeColors } from 'shared/utils/styles';
import { Icon } from 'shared/components';
export const TypeIcon = styled(Icon)`
color: ${props => issueTypeColors[props.color]};
`;
================================================
FILE: client/src/shared/components/IssueTypeIcon/index.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { TypeIcon } from './Styles';
const propTypes = {
type: PropTypes.string.isRequired,
};
const IssueTypeIcon = ({ type, ...otherProps }) => (
);
IssueTypeIcon.propTypes = propTypes;
export default IssueTypeIcon;
================================================
FILE: client/src/shared/components/Logo.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
const propTypes = {
className: PropTypes.string,
size: PropTypes.number,
};
const defaultProps = {
className: undefined,
size: 28,
};
const Logo = ({ className, size }) => (
Jira Software-blue
);
Logo.propTypes = propTypes;
Logo.defaultProps = defaultProps;
export default Logo;
================================================
FILE: client/src/shared/components/Modal/Styles.js
================================================
import styled, { css } from 'styled-components';
import { color, mixin, zIndexValues } from 'shared/utils/styles';
import Icon from 'shared/components/Icon';
export const ScrollOverlay = styled.div`
z-index: ${zIndexValues.modal};
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
${mixin.scrollableY}
`;
export const ClickableOverlay = styled.div`
min-height: 100%;
background: rgba(9, 30, 66, 0.54);
${props => clickOverlayStyles[props.variant]}
`;
const clickOverlayStyles = {
center: css`
display: flex;
justify-content: center;
align-items: center;
padding: 50px;
`,
aside: '',
};
export const StyledModal = styled.div`
display: inline-block;
position: relative;
width: 100%;
background: #fff;
${props => modalStyles[props.variant]}
`;
const modalStyles = {
center: css`
max-width: ${props => props.width}px;
vertical-align: middle;
border-radius: 3px;
${mixin.boxShadowMedium}
`,
aside: css`
min-height: 100vh;
max-width: ${props => props.width}px;
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.15);
`,
};
export const CloseIcon = styled(Icon)`
position: absolute;
font-size: 25px;
color: ${color.textMedium};
transition: all 0.1s;
${mixin.clickable}
${props => closeIconStyles[props.variant]}
`;
const closeIconStyles = {
center: css`
top: 10px;
right: 12px;
padding: 3px 5px 0px 5px;
border-radius: 4px;
&:hover {
background: ${color.backgroundLight};
}
`,
aside: css`
top: 10px;
right: -30px;
width: 50px;
height: 50px;
padding-top: 10px;
border-radius: 3px;
text-align: center;
background: #fff;
border: 1px solid ${color.borderLightest};
${mixin.boxShadowMedium};
&:hover {
color: ${color.primary};
}
`,
};
================================================
FILE: client/src/shared/components/Modal/index.jsx
================================================
import React, { Fragment, useState, useRef, useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import { ScrollOverlay, ClickableOverlay, StyledModal, CloseIcon } from './Styles';
const propTypes = {
className: PropTypes.string,
testid: PropTypes.string,
variant: PropTypes.oneOf(['center', 'aside']),
width: PropTypes.number,
withCloseIcon: PropTypes.bool,
isOpen: PropTypes.bool,
onClose: PropTypes.func,
renderLink: PropTypes.func,
renderContent: PropTypes.func.isRequired,
};
const defaultProps = {
className: undefined,
testid: 'modal',
variant: 'center',
width: 600,
withCloseIcon: true,
isOpen: undefined,
onClose: () => {},
renderLink: () => {},
};
const Modal = ({
className,
testid,
variant,
width,
withCloseIcon,
isOpen: propsIsOpen,
onClose: tellParentToClose,
renderLink,
renderContent,
}) => {
const [stateIsOpen, setStateOpen] = useState(false);
const isControlled = typeof propsIsOpen === 'boolean';
const isOpen = isControlled ? propsIsOpen : stateIsOpen;
const $modalRef = useRef();
const $clickableOverlayRef = useRef();
const closeModal = useCallback(() => {
if (!isControlled) {
setStateOpen(false);
} else {
tellParentToClose();
}
}, [isControlled, tellParentToClose]);
useOnOutsideClick($modalRef, isOpen, closeModal, $clickableOverlayRef);
useOnEscapeKeyDown(isOpen, closeModal);
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'visible';
};
}, [isOpen]);
return (
{!isControlled && renderLink({ open: () => setStateOpen(true) })}
{isOpen &&
ReactDOM.createPortal(
{withCloseIcon && }
{renderContent({ close: closeModal })}
,
$root,
)}
);
};
const $root = document.getElementById('root');
Modal.propTypes = propTypes;
Modal.defaultProps = defaultProps;
export default Modal;
================================================
FILE: client/src/shared/components/PageError/Styles.js
================================================
import styled from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
import { Icon } from 'shared/components';
import imageBackground from './assets/background-forest.jpg';
export const ErrorPage = styled.div`
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)}
`;
================================================
FILE: client/src/shared/components/PageError/index.jsx
================================================
import React from 'react';
import { ErrorPage, ErrorPageInner, ErrorBox, StyledIcon, Title } from './Styles';
const PageError = () => (
There’s been a glitch…
{'We’re not quite sure what went wrong. Please contact us or try looking on our '}
Help Center
{' if you need a hand.'}
);
export default PageError;
================================================
FILE: client/src/shared/components/PageLoader/Styles.js
================================================
import styled from 'styled-components';
export default styled.div`
width: 100%;
padding: 200px 0;
text-align: center;
`;
================================================
FILE: client/src/shared/components/PageLoader/index.jsx
================================================
import React from 'react';
import Spinner from 'shared/components/Spinner';
import StyledPageLoader from './Styles';
const PageLoader = () => (
);
export default PageLoader;
================================================
FILE: client/src/shared/components/ProjectAvatar.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
const propTypes = {
className: PropTypes.string,
size: PropTypes.number,
};
const defaultProps = {
className: undefined,
size: 40,
};
const ProjectAvatar = ({ className, size }) => (
);
ProjectAvatar.propTypes = propTypes;
ProjectAvatar.defaultProps = defaultProps;
export default ProjectAvatar;
================================================
FILE: client/src/shared/components/Select/Dropdown.jsx
================================================
import React, { useState, useRef, useLayoutEffect } from 'react';
import PropTypes from 'prop-types';
import { uniq } from 'lodash';
import { KeyCodes } from 'shared/constants/keyCodes';
import { ClearIcon, Dropdown, DropdownInput, Options, Option, OptionsNoResults } from './Styles';
const propTypes = {
dropdownWidth: PropTypes.number,
value: PropTypes.any,
isValueEmpty: PropTypes.bool.isRequired,
searchValue: PropTypes.string.isRequired,
setSearchValue: PropTypes.func.isRequired,
$inputRef: PropTypes.object.isRequired,
deactivateDropdown: PropTypes.func.isRequired,
options: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
onCreate: PropTypes.func,
isMulti: PropTypes.bool.isRequired,
withClearValue: PropTypes.bool.isRequired,
propsRenderOption: PropTypes.func,
};
const defaultProps = {
dropdownWidth: undefined,
value: undefined,
onCreate: undefined,
propsRenderOption: undefined,
};
const SelectDropdown = ({
dropdownWidth,
value,
isValueEmpty,
searchValue,
setSearchValue,
$inputRef,
deactivateDropdown,
options,
onChange,
onCreate,
isMulti,
withClearValue,
propsRenderOption,
}) => {
const [isCreatingOption, setCreatingOption] = useState(false);
const $optionsRef = useRef();
useLayoutEffect(() => {
const setFirstOptionAsActive = () => {
const $active = getActiveOptionNode();
if ($active) $active.classList.remove(activeOptionClass);
if ($optionsRef.current.firstElementChild) {
$optionsRef.current.firstElementChild.classList.add(activeOptionClass);
}
};
setFirstOptionAsActive();
});
const selectOptionValue = optionValue => {
deactivateDropdown();
if (isMulti) {
onChange(uniq([...value, optionValue]));
} else {
onChange(optionValue);
}
};
const createOption = newOptionLabel => {
setCreatingOption(true);
onCreate(newOptionLabel, createdOptionValue => {
setCreatingOption(false);
selectOptionValue(createdOptionValue);
});
};
const clearOptionValues = () => {
$inputRef.current.value = '';
$inputRef.current.focus();
onChange(isMulti ? [] : null);
};
const handleInputKeyDown = event => {
if (event.keyCode === KeyCodes.ESCAPE) {
handleInputEscapeKeyDown(event);
} else if (event.keyCode === KeyCodes.ENTER) {
handleInputEnterKeyDown(event);
} else if (event.keyCode === KeyCodes.ARROW_DOWN || event.keyCode === KeyCodes.ARROW_UP) {
handleInputArrowUpOrDownKeyDown(event);
}
};
const handleInputEscapeKeyDown = event => {
event.nativeEvent.stopImmediatePropagation();
deactivateDropdown();
};
const handleInputEnterKeyDown = event => {
event.preventDefault();
const $active = getActiveOptionNode();
if (!$active) return;
const optionValueToSelect = $active.getAttribute('data-select-option-value');
const optionLabelToCreate = $active.getAttribute('data-create-option-label');
if (optionValueToSelect) {
selectOptionValue(optionValueToSelect);
} else if (optionLabelToCreate) {
createOption(optionLabelToCreate);
}
};
const handleInputArrowUpOrDownKeyDown = event => {
const $active = getActiveOptionNode();
if (!$active) return;
const $options = $optionsRef.current;
const $optionsHeight = $options.getBoundingClientRect().height;
const $activeHeight = $active.getBoundingClientRect().height;
if (event.keyCode === KeyCodes.ARROW_DOWN) {
if ($options.lastElementChild === $active) {
$active.classList.remove(activeOptionClass);
$options.firstElementChild.classList.add(activeOptionClass);
$options.scrollTop = 0;
} else {
$active.classList.remove(activeOptionClass);
$active.nextElementSibling.classList.add(activeOptionClass);
if ($active.offsetTop > $options.scrollTop + $optionsHeight / 1.4) {
$options.scrollTop += $activeHeight;
}
}
} else if (event.keyCode === KeyCodes.ARROW_UP) {
if ($options.firstElementChild === $active) {
$active.classList.remove(activeOptionClass);
$options.lastElementChild.classList.add(activeOptionClass);
$options.scrollTop = $options.scrollHeight;
} else {
$active.classList.remove(activeOptionClass);
$active.previousElementSibling.classList.add(activeOptionClass);
if ($active.offsetTop < $options.scrollTop + $optionsHeight / 2.4) {
$options.scrollTop -= $activeHeight;
}
}
}
};
const handleOptionMouseEnter = event => {
const $active = getActiveOptionNode();
if ($active) $active.classList.remove(activeOptionClass);
event.currentTarget.classList.add(activeOptionClass);
};
const getActiveOptionNode = () => $optionsRef.current.querySelector(`.${activeOptionClass}`);
const optionsFilteredBySearchValue = options.filter(option =>
option.label
.toString()
.toLowerCase()
.includes(searchValue.toLowerCase()),
);
const removeSelectedOptionsMulti = opts => opts.filter(option => !value.includes(option.value));
const removeSelectedOptionsSingle = opts => opts.filter(option => value !== option.value);
const filteredOptions = isMulti
? removeSelectedOptionsMulti(optionsFilteredBySearchValue)
: removeSelectedOptionsSingle(optionsFilteredBySearchValue);
const isSearchValueInOptions = options.map(option => option.label).includes(searchValue);
const isOptionCreatable = onCreate && searchValue && !isSearchValueInOptions;
return (
setSearchValue(event.target.value)}
/>
{!isValueEmpty && withClearValue && }
{filteredOptions.map(option => (
selectOptionValue(option.value)}
>
{propsRenderOption ? propsRenderOption(option) : option.label}
))}
{isOptionCreatable && (
createOption(searchValue)}
>
{isCreatingOption ? `Creating "${searchValue}"...` : `Create "${searchValue}"`}
)}
{filteredOptions.length === 0 && No results }
);
};
const activeOptionClass = 'jira-select-option-is-active';
SelectDropdown.propTypes = propTypes;
SelectDropdown.defaultProps = defaultProps;
export default SelectDropdown;
================================================
FILE: client/src/shared/components/Select/Styles.js
================================================
import styled, { css } from 'styled-components';
import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
import Icon from 'shared/components/Icon';
export const StyledSelect = styled.div`
position: relative;
border-radius: 4px;
cursor: pointer;
${font.size(14)}
${props => props.variant === 'empty' && `display: inline-block;`}
${props =>
props.variant === 'normal' &&
css`
width: 100%;
border: 1px solid ${color.borderLightest};
background: ${color.backgroundLightest};
transition: background 0.1s;
&:hover {
background: ${color.backgroundLight};
}
`}
&:focus {
outline: none;
${props =>
props.variant === 'normal' &&
css`
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
background: #fff;
}
`}
}
${props =>
props.invalid &&
css`
&,
&:focus {
border: 1px solid ${color.danger};
box-shadow: none;
}
`}
`;
export const ValueContainer = styled.div`
display: flex;
align-items: center;
width: 100%;
${props =>
props.variant === 'normal' &&
css`
min-height: 32px;
padding: 5px 5px 5px 10px;
`}
`;
export const ChevronIcon = styled(Icon)`
margin-left: auto;
font-size: 18px;
color: ${color.textMedium};
`;
export const Placeholder = styled.div`
color: ${color.textLight};
`;
export const ValueMulti = styled.div`
display: flex;
align-items: center;
flex-wrap: wrap;
${props => props.variant === 'normal' && `padding-top: 5px;`}
`;
export const ValueMultiItem = styled.div`
margin: 0 5px 5px 0;
${mixin.tag()}
`;
export const AddMore = styled.div`
display: inline-block;
margin-bottom: 3px;
padding: 3px 0;
${font.size(12.5)}
${mixin.link()}
i {
margin-right: 3px;
vertical-align: middle;
font-size: 14px;
}
`;
export const Dropdown = styled.div`
z-index: ${zIndexValues.dropdown};
position: absolute;
top: 100%;
left: 0;
border-radius: 0 0 4px 4px;
background: #fff;
${mixin.boxShadowDropdown}
${props => (props.width ? `width: ${props.width}px;` : 'width: 100%;')}
`;
export const DropdownInput = styled.input`
padding: 10px 14px 8px;
width: 100%;
border: none;
color: ${color.textDarkest};
background: none;
&:focus {
outline: none;
}
`;
export const ClearIcon = styled(Icon)`
position: absolute;
top: 4px;
right: 7px;
padding: 5px;
font-size: 16px;
color: ${color.textMedium};
${mixin.clickable}
`;
export const Options = styled.div`
max-height: 200px;
${mixin.scrollableY};
${mixin.customScrollbar()};
`;
export const Option = styled.div`
padding: 8px 14px;
word-break: break-word;
cursor: pointer;
&:last-of-type {
margin-bottom: 8px;
}
&.jira-select-option-is-active {
background: ${color.backgroundLightPrimary};
}
`;
export const OptionsNoResults = styled.div`
padding: 5px 15px 15px;
color: ${color.textLight};
`;
================================================
FILE: client/src/shared/components/Select/index.jsx
================================================
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { KeyCodes } from 'shared/constants/keyCodes';
import Icon from 'shared/components/Icon';
import Dropdown from './Dropdown';
import {
StyledSelect,
ValueContainer,
ChevronIcon,
Placeholder,
ValueMulti,
ValueMultiItem,
AddMore,
} from './Styles';
const propTypes = {
className: PropTypes.string,
variant: PropTypes.oneOf(['normal', 'empty']),
dropdownWidth: PropTypes.number,
name: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
defaultValue: PropTypes.any,
placeholder: PropTypes.string,
invalid: PropTypes.bool,
options: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
onCreate: PropTypes.func,
isMulti: PropTypes.bool,
withClearValue: PropTypes.bool,
renderValue: PropTypes.func,
renderOption: PropTypes.func,
};
const defaultProps = {
className: undefined,
variant: 'normal',
dropdownWidth: undefined,
name: undefined,
value: undefined,
defaultValue: undefined,
placeholder: 'Select',
invalid: false,
onCreate: undefined,
isMulti: false,
withClearValue: true,
renderValue: undefined,
renderOption: undefined,
};
const Select = ({
className,
variant,
dropdownWidth,
name,
value: propsValue,
defaultValue,
placeholder,
invalid,
options,
onChange,
onCreate,
isMulti,
withClearValue,
renderValue: propsRenderValue,
renderOption: propsRenderOption,
}) => {
const [stateValue, setStateValue] = useState(defaultValue || (isMulti ? [] : null));
const [isDropdownOpen, setDropdownOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');
const isControlled = propsValue !== undefined;
const value = isControlled ? propsValue : stateValue;
const $selectRef = useRef();
const $inputRef = useRef();
const activateDropdown = () => {
if (isDropdownOpen) {
$inputRef.current.focus();
} else {
setDropdownOpen(true);
}
};
const deactivateDropdown = () => {
setDropdownOpen(false);
setSearchValue('');
$selectRef.current.focus();
};
useOnOutsideClick($selectRef, isDropdownOpen, deactivateDropdown);
const preserveValueType = newValue => {
const areOptionValuesNumbers = options.some(option => typeof option.value === 'number');
if (areOptionValuesNumbers) {
if (isMulti) {
return newValue.map(Number);
}
if (newValue) {
return Number(newValue);
}
}
return newValue;
};
const handleChange = newValue => {
if (!isControlled) {
setStateValue(preserveValueType(newValue));
}
onChange(preserveValueType(newValue));
};
const removeOptionValue = optionValue => {
handleChange(value.filter(val => val !== optionValue));
};
const handleFocusedSelectKeydown = event => {
if (isDropdownOpen) return;
if (event.keyCode === KeyCodes.ENTER) {
event.preventDefault();
}
if (event.keyCode !== KeyCodes.ESCAPE && event.keyCode !== KeyCodes.TAB && !event.shiftKey) {
setDropdownOpen(true);
}
};
const getOption = optionValue => options.find(option => option.value === optionValue);
const getOptionLabel = optionValue => (getOption(optionValue) || { label: '' }).label;
const isValueEmpty = isMulti ? !value.length : !getOption(value);
return (
{isValueEmpty && {placeholder} }
{!isValueEmpty && !isMulti && propsRenderValue
? propsRenderValue({ value })
: getOptionLabel(value)}
{!isValueEmpty && isMulti && (
{value.map(optionValue =>
propsRenderValue ? (
propsRenderValue({
value: optionValue,
removeOptionValue: () => removeOptionValue(optionValue),
})
) : (
removeOptionValue(optionValue)}>
{getOptionLabel(optionValue)}
),
)}
Add more
)}
{(!isMulti || isValueEmpty) && variant !== 'empty' && (
)}
{isDropdownOpen && (
)}
);
};
Select.propTypes = propTypes;
Select.defaultProps = defaultProps;
export default Select;
================================================
FILE: client/src/shared/components/Spinner.jsx
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { color as colors } from 'shared/utils/styles';
const propTypes = {
className: PropTypes.string,
size: PropTypes.number,
color: PropTypes.string,
};
const defaultProps = {
className: undefined,
size: 32,
color: colors.textMedium,
};
const Spinner = ({ className, size, color }) => (
);
Spinner.propTypes = propTypes;
Spinner.defaultProps = defaultProps;
export default Spinner;
================================================
FILE: client/src/shared/components/TextEditedContent/Styles.js
================================================
import styled from 'styled-components';
import { font } from 'shared/utils/styles';
export const Content = styled.div`
padding: 0 !important;
${font.size(15)}
${font.regular}
`;
================================================
FILE: client/src/shared/components/TextEditedContent/index.jsx
================================================
/* eslint-disable react/no-danger */
import React from 'react';
import PropTypes from 'prop-types';
import 'quill/dist/quill.snow.css';
import { Content } from './Styles';
const propTypes = {
content: PropTypes.string.isRequired,
};
const TextEditedContent = ({ content, ...otherProps }) => (
);
TextEditedContent.propTypes = propTypes;
export default TextEditedContent;
================================================
FILE: client/src/shared/components/TextEditor/Styles.js
================================================
import styled from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const EditorCont = styled.div`
.ql-toolbar.ql-snow {
border-radius: 4px 4px 0 0;
border: 1px solid ${color.borderLightest};
border-bottom: none;
}
.ql-container.ql-snow {
border-radius: 0 0 4px 4px;
border: 1px solid ${color.borderLightest};
border-top: none;
color: ${color.textDarkest};
${font.size(15)}
${font.regular}
}
.ql-editor {
min-height: 110px;
}
`;
================================================
FILE: client/src/shared/components/TextEditor/index.jsx
================================================
import React, { useLayoutEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
import { EditorCont } from './Styles';
const propTypes = {
className: PropTypes.string,
placeholder: PropTypes.string,
defaultValue: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
getEditor: PropTypes.func,
};
const defaultProps = {
className: undefined,
placeholder: undefined,
defaultValue: undefined,
value: undefined,
onChange: () => {},
getEditor: () => {},
};
const TextEditor = ({
className,
placeholder,
defaultValue,
// we're not really feeding new value to quill instance on each render because it's too
// expensive, but we're still accepting 'value' prop as alias for defaultValue because
// other components like feed their children with data via the 'value' prop
value: alsoDefaultValue,
onChange,
getEditor,
}) => {
const $editorContRef = useRef();
const $editorRef = useRef();
const initialValueRef = useRef(defaultValue || alsoDefaultValue || '');
useLayoutEffect(() => {
let quill = new Quill($editorRef.current, { placeholder, ...quillConfig });
const insertInitialValue = () => {
quill.clipboard.dangerouslyPasteHTML(0, initialValueRef.current);
quill.blur();
};
const handleContentsChange = () => {
onChange(getHTMLValue());
};
const getHTMLValue = () => $editorContRef.current.querySelector('.ql-editor').innerHTML;
insertInitialValue();
getEditor({ getValue: getHTMLValue });
quill.on('text-change', handleContentsChange);
return () => {
quill.off('text-change', handleContentsChange);
quill = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
);
};
const quillConfig = {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }],
['clean'],
],
},
};
TextEditor.propTypes = propTypes;
TextEditor.defaultProps = defaultProps;
export default TextEditor;
================================================
FILE: client/src/shared/components/Textarea/Styles.js
================================================
import styled, { css } from 'styled-components';
import { color, font } from 'shared/utils/styles';
export const StyledTextarea = styled.div`
display: inline-block;
width: 100%;
textarea {
overflow-y: hidden;
width: 100%;
padding: 8px 12px 9px;
border-radius: 3px;
border: 1px solid ${color.borderLightest};
color: ${color.textDarkest};
background: ${color.backgroundLightest};
${font.regular}
${font.size(15)}
&:focus {
background: #fff;
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
}
${props =>
props.invalid &&
css`
&,
&:focus {
border: 1px solid ${color.danger};
}
`}
}
`;
================================================
FILE: client/src/shared/components/Textarea/index.jsx
================================================
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import TextareaAutoSize from 'react-textarea-autosize';
import { StyledTextarea } from './Styles';
const propTypes = {
className: PropTypes.string,
invalid: PropTypes.bool,
minRows: PropTypes.number,
value: PropTypes.string,
onChange: PropTypes.func,
};
const defaultProps = {
className: undefined,
invalid: false,
minRows: 2,
value: undefined,
onChange: () => {},
};
const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps }, ref) => (
onChange(event.target.value, event)}
inputRef={ref || undefined}
/>
));
Textarea.propTypes = propTypes;
Textarea.defaultProps = defaultProps;
export default Textarea;
================================================
FILE: client/src/shared/components/Tooltip/Styles.js
================================================
import styled from 'styled-components';
import { zIndexValues, mixin } from 'shared/utils/styles';
export const StyledTooltip = styled.div`
z-index: ${zIndexValues.modal + 1};
position: fixed;
width: ${props => props.width}px;
border-radius: 3px;
background: #fff;
${mixin.hardwareAccelerate}
${mixin.boxShadowDropdown}
`;
================================================
FILE: client/src/shared/components/Tooltip/index.jsx
================================================
import React, { Fragment, useState, useRef, useLayoutEffect } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { StyledTooltip } from './Styles';
const propTypes = {
className: PropTypes.string,
placement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
offset: PropTypes.shape({
top: PropTypes.number,
left: PropTypes.number,
}),
width: PropTypes.number.isRequired,
renderLink: PropTypes.func.isRequired,
renderContent: PropTypes.func.isRequired,
};
const defaultProps = {
className: undefined,
placement: 'bottom',
offset: {
top: 0,
left: 0,
},
};
const Tooltip = ({ className, placement, offset, width, renderLink, renderContent }) => {
const [isOpen, setIsOpen] = useState(false);
const $linkRef = useRef();
const $tooltipRef = useRef();
const openTooltip = () => setIsOpen(true);
const closeTooltip = () => setIsOpen(false);
useOnOutsideClick([$tooltipRef, $linkRef], isOpen, closeTooltip);
useLayoutEffect(() => {
const setTooltipPosition = () => {
const { top, left } = calcPosition(offset, placement, $tooltipRef, $linkRef);
$tooltipRef.current.style.top = `${top}px`;
$tooltipRef.current.style.left = `${left}px`;
};
if (isOpen) {
setTooltipPosition();
window.addEventListener('resize', setTooltipPosition);
window.addEventListener('scroll', setTooltipPosition);
}
return () => {
window.removeEventListener('resize', setTooltipPosition);
window.removeEventListener('scroll', setTooltipPosition);
};
}, [isOpen, offset, placement]);
return (
{renderLink({ ref: $linkRef, onClick: isOpen ? closeTooltip : openTooltip })}
{isOpen &&
ReactDOM.createPortal(
{renderContent({ close: closeTooltip })}
,
$root,
)}
);
};
const calcPosition = (offset, placement, $tooltipRef, $linkRef) => {
const margin = 10;
const finalOffset = { ...defaultProps.offset, ...offset };
const tooltipRect = $tooltipRef.current.getBoundingClientRect();
const linkRect = $linkRef.current.getBoundingClientRect();
const linkCenterY = linkRect.top + linkRect.height / 2;
const linkCenterX = linkRect.left + linkRect.width / 2;
const placements = {
top: {
top: linkRect.top - margin - tooltipRect.height,
left: linkCenterX - tooltipRect.width / 2,
},
right: {
top: linkCenterY - tooltipRect.height / 2,
left: linkRect.right + margin,
},
bottom: {
top: linkRect.bottom + margin,
left: linkCenterX - tooltipRect.width / 2,
},
left: {
top: linkCenterY - tooltipRect.height / 2,
left: linkRect.left - margin - tooltipRect.width,
},
};
return {
top: placements[placement].top + finalOffset.top,
left: placements[placement].left + finalOffset.left,
};
};
const $root = document.getElementById('root');
Tooltip.propTypes = propTypes;
Tooltip.defaultProps = defaultProps;
export default Tooltip;
================================================
FILE: client/src/shared/components/index.js
================================================
export { default as AboutTooltip } from './AboutTooltip';
export { default as Avatar } from './Avatar';
export { default as Button } from './Button';
export { default as Breadcrumbs } from './Breadcrumbs';
export { default as ConfirmModal } from './ConfirmModal';
export { default as CopyLinkButton } from './CopyLinkButton';
export { default as DatePicker } from './DatePicker';
export { default as Form } from './Form';
export { default as Icon } from './Icon';
export { default as Input } from './Input';
export { default as InputDebounced } from './InputDebounced';
export { default as IssueTypeIcon } from './IssueTypeIcon';
export { default as IssuePriorityIcon } from './IssuePriorityIcon';
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';
export { default as TextEditedContent } from './TextEditedContent';
export { default as TextEditor } from './TextEditor';
export { default as Tooltip } from './Tooltip';
================================================
FILE: client/src/shared/constants/issues.js
================================================
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',
};
export const IssueTypeCopy = {
[IssueType.TASK]: 'Task',
[IssueType.BUG]: 'Bug',
[IssueType.STORY]: 'Story',
};
export const IssueStatusCopy = {
[IssueStatus.BACKLOG]: 'Backlog',
[IssueStatus.SELECTED]: 'Selected for development',
[IssueStatus.INPROGRESS]: 'In progress',
[IssueStatus.DONE]: 'Done',
};
export const IssuePriorityCopy = {
[IssuePriority.HIGHEST]: 'Highest',
[IssuePriority.HIGH]: 'High',
[IssuePriority.MEDIUM]: 'Medium',
[IssuePriority.LOW]: 'Low',
[IssuePriority.LOWEST]: 'Lowest',
};
================================================
FILE: client/src/shared/constants/keyCodes.js
================================================
export const KeyCodes = {
TAB: 9,
ENTER: 13,
ESCAPE: 27,
SPACE: 32,
ARROW_LEFT: 37,
ARROW_UP: 38,
ARROW_RIGHT: 39,
ARROW_DOWN: 40,
M: 77,
};
================================================
FILE: client/src/shared/constants/projects.js
================================================
export const ProjectCategory = {
SOFTWARE: 'software',
MARKETING: 'marketing',
BUSINESS: 'business',
};
export const ProjectCategoryCopy = {
[ProjectCategory.SOFTWARE]: 'Software',
[ProjectCategory.MARKETING]: 'Marketing',
[ProjectCategory.BUSINESS]: 'Business',
};
================================================
FILE: client/src/shared/hooks/api/index.js
================================================
import useQuery from './query';
import useMutation from './mutation';
/* eslint-disable react-hooks/rules-of-hooks */
export default {
get: (...args) => useQuery(...args),
post: (...args) => useMutation('post', ...args),
put: (...args) => useMutation('put', ...args),
patch: (...args) => useMutation('patch', ...args),
delete: (...args) => useMutation('delete', ...args),
};
================================================
FILE: client/src/shared/hooks/api/mutation.js
================================================
import { useCallback } from 'react';
import api from 'shared/utils/api';
import useMergeState from 'shared/hooks/mergeState';
const useMutation = (method, url) => {
const [state, mergeState] = useMergeState({
data: null,
error: null,
isWorking: false,
});
const makeRequest = useCallback(
(variables = {}) =>
new Promise((resolve, reject) => {
mergeState({ isWorking: true });
api[method](url, variables).then(
data => {
resolve(data);
mergeState({ data, error: null, isWorking: false });
},
error => {
reject(error);
mergeState({ error, data: null, isWorking: false });
},
);
}),
[method, url, mergeState],
);
return [
{
...state,
[isWorkingAlias[method]]: state.isWorking,
},
makeRequest,
];
};
const isWorkingAlias = {
post: 'isCreating',
put: 'isUpdating',
patch: 'isUpdating',
delete: 'isDeleting',
};
export default useMutation;
================================================
FILE: client/src/shared/hooks/api/query.js
================================================
import { useRef, useCallback, useEffect } from 'react';
import { isEqual } from 'lodash';
import api from 'shared/utils/api';
import useMergeState from 'shared/hooks/mergeState';
import useDeepCompareMemoize from 'shared/hooks/deepCompareMemoize';
const useQuery = (url, propsVariables = {}, options = {}) => {
const { lazy = false, cachePolicy = 'cache-first' } = options;
const wasCalled = useRef(false);
const propsVariablesMemoized = useDeepCompareMemoize(propsVariables);
const isSleeping = lazy && !wasCalled.current;
const isCacheAvailable = cache[url] && isEqual(cache[url].apiVariables, propsVariables);
const canUseCache = isCacheAvailable && cachePolicy !== 'no-cache' && !wasCalled.current;
const [state, mergeState] = useMergeState({
data: canUseCache ? cache[url].data : null,
error: null,
isLoading: !lazy && !canUseCache,
variables: {},
});
const makeRequest = useCallback(
newVariables => {
const variables = { ...state.variables, ...(newVariables || {}) };
const apiVariables = { ...propsVariablesMemoized, ...variables };
const skipLoading = canUseCache && cachePolicy === 'cache-first';
if (!skipLoading) {
mergeState({ isLoading: true, variables });
} else if (newVariables) {
mergeState({ variables });
}
api.get(url, apiVariables).then(
data => {
cache[url] = { data, apiVariables };
mergeState({ data, error: null, isLoading: false });
},
error => {
mergeState({ error, data: null, isLoading: false });
},
);
wasCalled.current = true;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[propsVariablesMemoized],
);
useEffect(() => {
if (isSleeping) return;
if (canUseCache && cachePolicy === 'cache-only') return;
makeRequest();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [makeRequest]);
const setLocalData = useCallback(
getUpdatedData =>
mergeState(({ data }) => {
const updatedData = getUpdatedData(data);
cache[url] = { ...(cache[url] || {}), data: updatedData };
return { data: updatedData };
}),
[mergeState, url],
);
return [
{
...state,
variables: { ...propsVariablesMemoized, ...state.variables },
setLocalData,
},
makeRequest,
];
};
const cache = {};
export default useQuery;
================================================
FILE: client/src/shared/hooks/currentUser.js
================================================
import { get } from 'lodash';
import useApi from 'shared/hooks/api';
const useCurrentUser = ({ cachePolicy = 'cache-only' } = {}) => {
const [{ data }] = useApi.get('/currentUser', {}, { cachePolicy });
return {
currentUser: get(data, 'currentUser'),
currentUserId: get(data, 'currentUser.id'),
};
};
export default useCurrentUser;
================================================
FILE: client/src/shared/hooks/deepCompareMemoize.js
================================================
import { useRef } from 'react';
import { isEqual } from 'lodash';
const useDeepCompareMemoize = value => {
const valueRef = useRef();
if (!isEqual(value, valueRef.current)) {
valueRef.current = value;
}
return valueRef.current;
};
export default useDeepCompareMemoize;
================================================
FILE: client/src/shared/hooks/mergeState.js
================================================
import { useState, useCallback } from 'react';
import { isFunction } from 'lodash';
const useMergeState = initialState => {
const [state, setState] = useState(initialState || {});
const mergeState = useCallback(newState => {
if (isFunction(newState)) {
setState(currentState => ({ ...currentState, ...newState(currentState) }));
} else {
setState(currentState => ({ ...currentState, ...newState }));
}
}, []);
return [state, mergeState];
};
export default useMergeState;
================================================
FILE: client/src/shared/hooks/onEscapeKeyDown.js
================================================
import { useEffect } from 'react';
import { KeyCodes } from 'shared/constants/keyCodes';
const useOnEscapeKeyDown = (isListening, onEscapeKeyDown) => {
useEffect(() => {
const handleKeyDown = event => {
if (event.keyCode === KeyCodes.ESCAPE) {
onEscapeKeyDown();
}
};
if (isListening) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isListening, onEscapeKeyDown]);
};
export default useOnEscapeKeyDown;
================================================
FILE: client/src/shared/hooks/onOutsideClick.js
================================================
import { useEffect, useRef } from 'react';
import useDeepCompareMemoize from 'shared/hooks/deepCompareMemoize';
const useOnOutsideClick = (
$ignoredElementRefs,
isListening,
onOutsideClick,
$listeningElementRef,
) => {
const $mouseDownTargetRef = useRef();
const $ignoredElementRefsMemoized = useDeepCompareMemoize([$ignoredElementRefs].flat());
useEffect(() => {
const handleMouseDown = event => {
$mouseDownTargetRef.current = event.target;
};
const handleMouseUp = event => {
const isAnyIgnoredElementAncestorOfTarget = $ignoredElementRefsMemoized.some(
$elementRef =>
$elementRef.current.contains($mouseDownTargetRef.current) ||
$elementRef.current.contains(event.target),
);
if (event.button === 0 && !isAnyIgnoredElementAncestorOfTarget) {
onOutsideClick();
}
};
const $listeningElement = ($listeningElementRef || {}).current || document;
if (isListening) {
$listeningElement.addEventListener('mousedown', handleMouseDown);
$listeningElement.addEventListener('mouseup', handleMouseUp);
}
return () => {
$listeningElement.removeEventListener('mousedown', handleMouseDown);
$listeningElement.removeEventListener('mouseup', handleMouseUp);
};
}, [$ignoredElementRefsMemoized, $listeningElementRef, isListening, onOutsideClick]);
};
export default useOnOutsideClick;
================================================
FILE: client/src/shared/utils/api.js
================================================
import axios from 'axios';
import history from 'browserHistory';
import toast from 'shared/utils/toast';
import { objectToQueryString } from 'shared/utils/url';
import { getStoredAuthToken, removeStoredAuthToken } from 'shared/utils/authToken';
const defaults = {
baseURL: process.env.API_URL || '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,
data: {},
},
};
const api = (method, url, variables) =>
new Promise((resolve, reject) => {
axios({
url: `${defaults.baseURL}${url}`,
method,
headers: defaults.headers(),
params: method === 'get' ? variables : undefined,
data: method !== 'get' ? variables : 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);
}
},
);
});
const optimisticUpdate = async (url, { updatedFields, currentFields, setLocalData }) => {
try {
setLocalData(updatedFields);
await api('put', url, updatedFields);
} catch (error) {
setLocalData(currentFields);
toast.error(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),
optimisticUpdate,
};
================================================
FILE: client/src/shared/utils/authToken.js
================================================
export const getStoredAuthToken = () => localStorage.getItem('authToken');
export const storeAuthToken = token => localStorage.setItem('authToken', token);
export const removeStoredAuthToken = () => localStorage.removeItem('authToken');
================================================
FILE: client/src/shared/utils/browser.js
================================================
export const getTextContentsFromHtmlString = html => {
const el = document.createElement('div');
el.innerHTML = html;
return el.textContent;
};
export const copyToClipboard = value => {
const $textarea = document.createElement('textarea');
$textarea.value = value;
document.body.appendChild($textarea);
$textarea.select();
document.execCommand('copy');
document.body.removeChild($textarea);
};
export const isFocusedElementEditable = () =>
!!document.activeElement.getAttribute('contenteditable') ||
['TEXTAREA', 'INPUT'].includes(document.activeElement.tagName);
================================================
FILE: client/src/shared/utils/dateTime.js
================================================
import moment from 'moment';
export const formatDate = (date, format = 'MMMM D, YYYY') =>
date ? moment(date).format(format) : date;
export const formatDateTime = (date, format = 'MMMM D, YYYY, h:mm A') =>
date ? moment(date).format(format) : date;
export const formatDateTimeForAPI = date =>
date
? moment(date)
.utc()
.format()
: date;
export const formatDateTimeConversational = date => (date ? moment(date).fromNow() : date);
================================================
FILE: client/src/shared/utils/javascript.js
================================================
export const moveItemWithinArray = (arr, item, newIndex) => {
const arrClone = [...arr];
const oldIndex = arrClone.indexOf(item);
arrClone.splice(newIndex, 0, arrClone.splice(oldIndex, 1)[0]);
return arrClone;
};
export const insertItemIntoArray = (arr, item, index) => {
const arrClone = [...arr];
arrClone.splice(index, 0, item);
return arrClone;
};
export const updateArrayItemById = (arr, itemId, fields) => {
const arrClone = [...arr];
const item = arrClone.find(({ id }) => id === itemId);
if (item) {
const itemIndex = arrClone.indexOf(item);
arrClone.splice(itemIndex, 1, { ...item, ...fields });
}
return arrClone;
};
export const sortByNewest = (items, sortField) =>
items.sort((a, b) => -a[sortField].localeCompare(b[sortField]));
================================================
FILE: client/src/shared/utils/queryParamModal.js
================================================
import history from 'browserHistory';
import { queryStringToObject, addToQueryString, omitFromQueryString } from 'shared/utils/url';
const open = param =>
history.push({
pathname: history.location.pathname,
search: addToQueryString(history.location.search, { [`modal-${param}`]: true }),
});
const close = param =>
history.push({
pathname: history.location.pathname,
search: omitFromQueryString(history.location.search, [`modal-${param}`]),
});
const isOpen = param => !!queryStringToObject(history.location.search)[`modal-${param}`];
export const createQueryParamModalHelpers = param => ({
open: () => open(param),
close: () => close(param),
isOpen: () => isOpen(param),
});
================================================
FILE: client/src/shared/utils/styles.js
================================================
import { css } from 'styled-components';
import Color from 'color';
import { IssueType, IssueStatus, IssuePriority } from 'shared/constants/issues';
export const color = {
primary: '#0052cc', // Blue
success: '#0B875B', // green
danger: '#E13C3C', // red
warning: '#F89C1C', // orange
secondary: '#F4F5F7', // light grey
textDarkest: '#172b4d',
textDark: '#42526E',
textMedium: '#5E6C84',
textLight: '#8993a4',
textLink: '#0052cc',
backgroundDarkPrimary: '#0747A6',
backgroundMedium: '#dfe1e6',
backgroundLight: '#ebecf0',
backgroundLightest: '#F4F5F7',
backgroundLightPrimary: '#D2E5FE',
backgroundLightSuccess: '#E4FCEF',
borderLightest: '#dfe1e6',
borderLight: '#C1C7D0',
borderInputFocus: '#4c9aff',
};
export const issueTypeColors = {
[IssueType.TASK]: '#4FADE6', // blue
[IssueType.BUG]: '#E44D42', // red
[IssueType.STORY]: '#65BA43', // green
};
export const issuePriorityColors = {
[IssuePriority.HIGHEST]: '#CD1317', // red
[IssuePriority.HIGH]: '#E9494A', // orange
[IssuePriority.MEDIUM]: '#E97F33', // orange
[IssuePriority.LOW]: '#2D8738', // green
[IssuePriority.LOWEST]: '#57A55A', // green
};
export const issueStatusColors = {
[IssueStatus.BACKLOG]: color.textDark,
[IssueStatus.INPROGRESS]: '#fff',
[IssueStatus.SELECTED]: color.textDark,
[IssueStatus.DONE]: '#fff',
};
export const issueStatusBackgroundColors = {
[IssueStatus.BACKLOG]: color.backgroundMedium,
[IssueStatus.INPROGRESS]: color.primary,
[IssueStatus.SELECTED]: color.backgroundMedium,
[IssueStatus.DONE]: color.success,
};
export const sizes = {
appNavBarLeftWidth: 64,
secondarySideBarWidth: 230,
minViewportWidth: 1000,
};
export const zIndexValues = {
modal: 1000,
dropdown: 101,
navLeft: 100,
};
export const font = {
regular: 'font-family: "CircularStdBook"; font-weight: normal;',
medium: 'font-family: "CircularStdMedium"; font-weight: normal;',
bold: 'font-family: "CircularStdBold"; font-weight: normal;',
black: 'font-family: "CircularStdBlack"; font-weight: normal;',
size: size => `font-size: ${size}px;`,
};
export const mixin = {
darken: (colorValue, amount) =>
Color(colorValue)
.darken(amount)
.string(),
lighten: (colorValue, amount) =>
Color(colorValue)
.lighten(amount)
.string(),
rgba: (colorValue, opacity) =>
Color(colorValue)
.alpha(opacity)
.string(),
boxShadowMedium: css`
box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.1);
`,
boxShadowDropdown: css`
box-shadow: rgba(9, 30, 66, 0.25) 0px 4px 8px -2px, rgba(9, 30, 66, 0.31) 0px 0px 1px;
`,
truncateText: css`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`,
clickable: css`
cursor: pointer;
user-select: none;
`,
hardwareAccelerate: css`
transform: translateZ(0);
`,
cover: css`
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
`,
placeholderColor: colorValue => css`
::-webkit-input-placeholder {
color: ${colorValue} !important;
opacity: 1 !important;
}
:-moz-placeholder {
color: ${colorValue} !important;
opacity: 1 !important;
}
::-moz-placeholder {
color: ${colorValue} !important;
opacity: 1 !important;
}
:-ms-input-placeholder {
color: ${colorValue} !important;
opacity: 1 !important;
}
`,
scrollableY: css`
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
`,
customScrollbar: ({ width = 8, background = color.backgroundMedium } = {}) => css`
&::-webkit-scrollbar {
width: ${width}px;
}
&::-webkit-scrollbar-track {
background: none;
}
&::-webkit-scrollbar-thumb {
border-radius: 99px;
background: ${background};
}
`,
backgroundImage: imageURL => css`
background-image: url("${imageURL}");
background-position: 50% 50%;
background-repeat: no-repeat;
background-size: cover;
background-color: ${color.backgroundLight};
`,
link: (colorValue = color.textLink) => css`
cursor: pointer;
color: ${colorValue};
${font.medium}
&:hover, &:visited, &:active {
color: ${colorValue};
}
&:hover {
text-decoration: underline;
}
`,
tag: (background = color.backgroundMedium, colorValue = color.textDarkest) => css`
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 4px;
cursor: pointer;
user-select: none;
color: ${colorValue};
background: ${background};
${font.bold}
${font.size(12)}
i {
margin-left: 4px;
}
`,
};
================================================
FILE: client/src/shared/utils/toast.js
================================================
import pubsub from 'sweet-pubsub';
import { get } from 'lodash';
const show = toast => pubsub.emit('toast', toast);
const success = title => show({ title });
const error = err => {
show({
type: 'danger',
title: 'Error',
message: get(err, 'message', err),
duration: 0,
});
};
export default { show, error, success };
================================================
FILE: client/src/shared/utils/url.js
================================================
import queryString from 'query-string';
import { omit } from 'lodash';
export const queryStringToObject = (str, options = {}) =>
queryString.parse(str, {
arrayFormat: 'bracket',
...options,
});
export const objectToQueryString = (obj, options = {}) =>
queryString.stringify(obj, {
arrayFormat: 'bracket',
...options,
});
export const omitFromQueryString = (str, keys) =>
objectToQueryString(omit(queryStringToObject(str), keys));
export const addToQueryString = (str, fields) =>
objectToQueryString({
...queryStringToObject(str),
...fields,
});
================================================
FILE: client/src/shared/utils/validation.js
================================================
export const is = {
match: (testFn, message = '') => (value, fieldValues) => !testFn(value, fieldValues) && message,
required: () => value => isNilOrEmptyString(value) && 'This field is required',
minLength: min => value => !!value && value.length < min && `Must be at least ${min} characters`,
maxLength: max => value => !!value && value.length > max && `Must be at most ${max} characters`,
notEmptyArray: () => value =>
Array.isArray(value) && value.length === 0 && 'Please add at least one item',
email: () => value => !!value && !/.+@.+\..+/.test(value) && 'Must be a valid email',
url: () => value =>
!!value &&
// eslint-disable-next-line no-useless-escape
!/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/.test(value) &&
'Must be a valid URL',
};
const isNilOrEmptyString = value => value === undefined || value === null || value === '';
export const generateErrors = (fieldValues, fieldValidators) => {
const errors = {};
Object.entries(fieldValidators).forEach(([fieldName, validators]) => {
[validators].flat().forEach(validator => {
const errorMessage = validator(fieldValues[fieldName], fieldValues);
if (errorMessage && !errors[fieldName]) {
errors[fieldName] = errorMessage;
}
});
});
return errors;
};
================================================
FILE: client/webpack.config.js
================================================
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: path.join(__dirname, 'src/index.jsx'),
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dev'),
publicPath: '/',
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: ['babel-loader'],
},
{
test: /\.css$/,
use: ['style-loader', { loader: 'css-loader' }],
},
{
test: /\.(jpe?g|png|gif|woff2?|eot|ttf|otf|svg)$/,
use: [
{
loader: 'url-loader',
options: { limit: 15000 },
},
],
},
],
},
resolve: {
// allows us to do absolute imports from "src"
modules: [path.join(__dirname, 'src'), 'node_modules'],
extensions: ['*', '.js', '.jsx'],
},
devtool: 'eval-source-map',
devServer: {
contentBase: path.join(__dirname, 'dev'),
historyApiFallback: true,
hot: true,
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new HtmlWebpackPlugin({
template: path.join(__dirname, 'src/index.html'),
favicon: path.join(__dirname, 'src/favicon.png'),
}),
],
};
================================================
FILE: client/webpack.config.production.js
================================================
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'production',
entry: {
main: path.join(__dirname, 'src/index.jsx'),
},
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name]-[hash].js',
publicPath: '/',
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: ['babel-loader'],
},
{
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: { sourceMap: true },
},
],
},
{
test: /\.(jpe?g|png|gif|svg)$/,
use: [
{
loader: 'url-loader',
options: { name: '[name]-[hash].[ext]', limit: 10000 },
},
],
},
{
test: /\.(woff2?|eot|ttf|otf)$/,
use: [
{
loader: 'file-loader',
options: { name: '[name]-[hash].[ext]' },
},
],
},
],
},
resolve: {
modules: [path.join(__dirname, 'src'), 'node_modules'],
extensions: ['*', '.js', '.jsx', '.css'],
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, 'src/index.html'),
favicon: path.join(__dirname, 'src/favicon.png'),
}),
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production'),
API_URL: JSON.stringify('https://jira-api.ivorreic.com'),
},
}),
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
],
};
================================================
FILE: package.json
================================================
{
"name": "jira_clone",
"version": "1.0.0",
"author": "Ivor Reic",
"license": "MIT",
"scripts": {
"pre-commit": "cd api && npm run pre-commit && cd ../client && npm run pre-commit",
"install-dependencies": "npm install && cd api && npm install && cd ../client && npm install",
"build": "cd api && npm run build && cd ../client && npm run build",
"start:production": "cd api && npm run start:production && cd ../client && npm run start:production"
},
"devDependencies": {
"husky": "^4.0.0-beta.5"
},
"husky": {
"hooks": {
"pre-commit": "npm run pre-commit"
}
},
"dependencies": {}
}