Wrote end-to-end cypress tests
This commit is contained in:
52
api/package-lock.json
generated
52
api/package-lock.json
generated
@@ -1045,6 +1045,58 @@
|
||||
"capture-stack-trace": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"cross-env": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-6.0.3.tgz",
|
||||
"integrity": "sha512-+KqxF6LCvfhWvADcDPqo64yVIB31gv/jQulX2NGzKS/g3GEVz6/pt4wjHFtFWsHMddebWD/sDthJemzM4MaAag==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cross-spawn": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"cross-spawn": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.1.tgz",
|
||||
"integrity": "sha512-u7v4o84SwFpD32Z8IIcPZ6z1/ie24O6RU3RbtL5Y316l3KuHVPx9ItBgWQ6VlfAFnRnTtMUrsQ9MUUTuEZjogg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true
|
||||
},
|
||||
"shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true
|
||||
},
|
||||
"which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"isexe": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cross-spawn": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "nodemon --exec ts-node --files src/index.ts",
|
||||
"db-seed": "nodemon --exec ts-node --files src/database/seeds/development/index.ts",
|
||||
"test:start": "cross-env NODE_ENV='test' DB_DATABASE='jira_test' nodemon --exec ts-node --files src/index.ts",
|
||||
"pre-commit": "lint-staged"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -32,6 +32,7 @@
|
||||
"@types/node": "^12.12.11",
|
||||
"@typescript-eslint/eslint-plugin": "^2.7.0",
|
||||
"@typescript-eslint/parser": "^2.7.0",
|
||||
"cross-env": "^6.0.3",
|
||||
"eslint": "^6.1.0",
|
||||
"eslint-config-airbnb-base": "^14.0.0",
|
||||
"eslint-config-prettier": "^6.7.0",
|
||||
|
||||
@@ -2,14 +2,14 @@ import express from 'express';
|
||||
|
||||
import { catchErrors } from 'errors';
|
||||
import { signToken } from 'utils/authToken';
|
||||
import seedGuestUserEntities from 'database/seeds/guestUser';
|
||||
import createGuestAccount from 'database/createGuestAccount';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post(
|
||||
'/authentication/guest',
|
||||
catchErrors(async (_req, res) => {
|
||||
const user = await seedGuestUserEntities();
|
||||
const user = await createGuestAccount();
|
||||
res.respond({
|
||||
authToken: signToken({ sub: user.id }),
|
||||
});
|
||||
|
||||
28
api/src/controllers/test.ts
Normal file
28
api/src/controllers/test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import express from 'express';
|
||||
|
||||
import { catchErrors } from 'errors';
|
||||
import { signToken } from 'utils/authToken';
|
||||
import resetDatabase from 'database/resetDatabase';
|
||||
import createTestAccount from 'database/createTestAccount';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.delete(
|
||||
'/test/reset-database',
|
||||
catchErrors(async (_req, res) => {
|
||||
await resetDatabase();
|
||||
res.respond(true);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/test/create-account',
|
||||
catchErrors(async (_req, res) => {
|
||||
const user = await createTestAccount();
|
||||
res.respond({
|
||||
authToken: signToken({ sub: user.id }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,5 +1,3 @@
|
||||
import { sample } from 'lodash';
|
||||
|
||||
import { Comment, Issue, Project, User } from 'entities';
|
||||
import { ProjectCategory } from 'constants/projects';
|
||||
import { IssueType, IssueStatus, IssuePriority } from 'constants/issues';
|
||||
@@ -37,7 +35,8 @@ const seedProject = (users: User[]): Promise<Project> =>
|
||||
});
|
||||
|
||||
const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
const getRandomUser = (): User => sample(project.users) as User;
|
||||
const { users } = project;
|
||||
|
||||
const issues = [
|
||||
createEntity(Issue, {
|
||||
title: 'This is an issue of type: Task.',
|
||||
@@ -46,9 +45,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
priority: IssuePriority.LOWEST,
|
||||
listPosition: 1,
|
||||
estimate: 8,
|
||||
reporterId: getRandomUser().id,
|
||||
reporterId: users[1].id,
|
||||
project,
|
||||
users: [getRandomUser()],
|
||||
users: [users[0]],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: "Click on an issue to see what's behind it.",
|
||||
@@ -58,7 +57,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
listPosition: 2,
|
||||
description: 'Nothing in particular.',
|
||||
estimate: 40,
|
||||
reporterId: getRandomUser().id,
|
||||
reporterId: users[2].id,
|
||||
project,
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
@@ -68,9 +67,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
priority: IssuePriority.MEDIUM,
|
||||
listPosition: 3,
|
||||
estimate: 15,
|
||||
reporterId: getRandomUser().id,
|
||||
reporterId: users[1].id,
|
||||
project,
|
||||
users: [getRandomUser()],
|
||||
users: [users[2]],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: 'You can use markdown for issue descriptions.',
|
||||
@@ -80,9 +79,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
listPosition: 4,
|
||||
description: '#### Colons can be used to align columns.',
|
||||
estimate: 4,
|
||||
reporterId: getRandomUser().id,
|
||||
reporterId: users[0].id,
|
||||
project,
|
||||
users: [getRandomUser()],
|
||||
users: [users[2]],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: 'You must assign priority from lowest to highest to all issues.',
|
||||
@@ -91,7 +90,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
priority: IssuePriority.HIGHEST,
|
||||
listPosition: 5,
|
||||
estimate: 15,
|
||||
reporterId: getRandomUser().id,
|
||||
reporterId: users[2].id,
|
||||
project,
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
@@ -101,9 +100,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
priority: IssuePriority.MEDIUM,
|
||||
listPosition: 6,
|
||||
estimate: 55,
|
||||
reporterId: getRandomUser().id,
|
||||
reporterId: users[1].id,
|
||||
project,
|
||||
users: [getRandomUser()],
|
||||
users: [users[0]],
|
||||
}),
|
||||
createEntity(Issue, {
|
||||
title: 'Try leaving a comment on this issue.',
|
||||
@@ -112,7 +111,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
priority: IssuePriority.MEDIUM,
|
||||
listPosition: 7,
|
||||
estimate: 12,
|
||||
reporterId: getRandomUser().id,
|
||||
reporterId: users[0].id,
|
||||
project,
|
||||
}),
|
||||
];
|
||||
@@ -122,16 +121,16 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
const seedComments = (issue: Issue, user: User): Promise<Comment> =>
|
||||
createEntity(Comment, {
|
||||
body: "Be nice to each other! Don't be mean to each other!",
|
||||
issue,
|
||||
user,
|
||||
issueId: issue.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const seedGuestUserEntities = async (): Promise<User> => {
|
||||
const createGuestAccount = async (): Promise<User> => {
|
||||
const users = await seedUsers();
|
||||
const project = await seedProject(users);
|
||||
const issues = await seedIssues(project);
|
||||
await seedComments(issues[issues.length - 1], project.users[0]);
|
||||
await seedComments(issues[0], project.users[0]);
|
||||
return users[0];
|
||||
};
|
||||
|
||||
export default seedGuestUserEntities;
|
||||
export default createGuestAccount;
|
||||
87
api/src/database/createTestAccount.ts
Normal file
87
api/src/database/createTestAccount.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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<User[]> => {
|
||||
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<Project> =>
|
||||
createEntity(Project, {
|
||||
name: 'Project name',
|
||||
url: 'https://www.testurl.com',
|
||||
description: 'Project description',
|
||||
category: ProjectCategory.SOFTWARE,
|
||||
users,
|
||||
});
|
||||
|
||||
const seedIssues = (project: Project): Promise<Issue[]> => {
|
||||
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<Comment> =>
|
||||
createEntity(Comment, {
|
||||
body: 'Comment body',
|
||||
issueId: issue.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const createTestAccount = async (): Promise<User> => {
|
||||
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;
|
||||
9
api/src/database/resetDatabase.ts
Normal file
9
api/src/database/resetDatabase.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getConnection } from 'typeorm';
|
||||
|
||||
const resetDatabase = async (): Promise<void> => {
|
||||
const connection = getConnection();
|
||||
await connection.dropDatabase();
|
||||
await connection.synchronize();
|
||||
};
|
||||
|
||||
export default resetDatabase;
|
||||
@@ -1,10 +0,0 @@
|
||||
import faker from 'faker';
|
||||
|
||||
import Comment from 'entities/Comment';
|
||||
|
||||
const generateComment = (data: Partial<Comment> = {}): Partial<Comment> => ({
|
||||
body: faker.lorem.paragraph(),
|
||||
...data,
|
||||
});
|
||||
|
||||
export default generateComment;
|
||||
@@ -1,69 +0,0 @@
|
||||
import 'module-alias/register';
|
||||
import 'reflect-metadata';
|
||||
import 'dotenv/config';
|
||||
import { times, sample } from 'lodash';
|
||||
|
||||
import { Comment, Issue, Project, User } from 'entities';
|
||||
import createDatabaseConnection from 'database/connection';
|
||||
import { createEntity } from 'utils/typeorm';
|
||||
import generateUser from './user';
|
||||
import generateProject from './project';
|
||||
import generateIssue from './issue';
|
||||
import generateComment from './comment';
|
||||
|
||||
const seedUsers = (): Promise<User[]> => {
|
||||
const users = times(4, () => createEntity(User, generateUser()));
|
||||
return Promise.all(users);
|
||||
};
|
||||
|
||||
const seedProjects = (users: User[]): Promise<Project[]> => {
|
||||
const projects = times(2, () => createEntity(Project, generateProject({ users })));
|
||||
return Promise.all(projects);
|
||||
};
|
||||
|
||||
const seedIssues = (projects: Project[]): Promise<Issue[]> => {
|
||||
const issues = projects
|
||||
.map(project =>
|
||||
times(10, i =>
|
||||
createEntity(
|
||||
Issue,
|
||||
generateIssue({
|
||||
listPosition: i + 1,
|
||||
reporterId: (sample(project.users) as User).id,
|
||||
project,
|
||||
users: [sample(project.users) as User],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.flat();
|
||||
return Promise.all(issues);
|
||||
};
|
||||
|
||||
const seedComments = (issues: Issue[]): Promise<Comment[]> => {
|
||||
const comments = issues.map(issue =>
|
||||
createEntity(Comment, generateComment({ issue, user: sample(issue.project.users) })),
|
||||
);
|
||||
return Promise.all(comments);
|
||||
};
|
||||
|
||||
const seedEntities = async (): Promise<void> => {
|
||||
const users = await seedUsers();
|
||||
const projects = await seedProjects(users);
|
||||
const issues = await seedIssues(projects);
|
||||
await seedComments(issues);
|
||||
};
|
||||
|
||||
const initializeSeed = async (): Promise<void> => {
|
||||
try {
|
||||
const Connection = await createDatabaseConnection();
|
||||
await Connection.dropDatabase();
|
||||
await Connection.synchronize();
|
||||
await seedEntities();
|
||||
console.log('Seeding completed!');
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeSeed();
|
||||
@@ -1,17 +0,0 @@
|
||||
import faker from 'faker';
|
||||
import { sample, random } from 'lodash';
|
||||
|
||||
import Issue from 'entities/Issue';
|
||||
import { IssueType, IssueStatus, IssuePriority } from 'constants/issues';
|
||||
|
||||
const generateIssue = (data: Partial<Issue> = {}): Partial<Issue> => ({
|
||||
title: faker.company.catchPhrase(),
|
||||
type: sample(Object.values(IssueType)),
|
||||
status: sample(Object.values(IssueStatus)),
|
||||
priority: sample(Object.values(IssuePriority)),
|
||||
description: faker.lorem.paragraph(),
|
||||
estimate: random(1, 40),
|
||||
...data,
|
||||
});
|
||||
|
||||
export default generateIssue;
|
||||
@@ -1,15 +0,0 @@
|
||||
import faker from 'faker';
|
||||
import { sample } from 'lodash';
|
||||
|
||||
import Project from 'entities/Project';
|
||||
import { ProjectCategory } from 'constants/projects';
|
||||
|
||||
const generateProject = (data: Partial<Project> = {}): Partial<Project> => ({
|
||||
name: faker.company.companyName(),
|
||||
url: faker.internet.url(),
|
||||
description: faker.lorem.paragraph(),
|
||||
category: sample(Object.values(ProjectCategory)),
|
||||
...data,
|
||||
});
|
||||
|
||||
export default generateProject;
|
||||
@@ -1,12 +0,0 @@
|
||||
import faker from 'faker';
|
||||
|
||||
import User from 'entities/User';
|
||||
|
||||
const generateUser = (data: Partial<User> = {}): Partial<User> => ({
|
||||
name: faker.company.companyName(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
email: faker.internet.email(),
|
||||
...data,
|
||||
});
|
||||
|
||||
export default generateUser;
|
||||
@@ -4,12 +4,13 @@ import 'reflect-metadata';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
import createDatabaseConnection from 'database/connection';
|
||||
import createDatabaseConnection from 'database/createConnection';
|
||||
import { authenticateUser } from 'middleware/authentication';
|
||||
import authenticationRoutes from 'controllers/authentication';
|
||||
import commentsRoutes from 'controllers/comments';
|
||||
import issuesRoutes from 'controllers/issues';
|
||||
import projectsRoutes from 'controllers/projects';
|
||||
import testRoutes from 'controllers/test';
|
||||
import usersRoutes from 'controllers/users';
|
||||
import { RouteNotFoundError } from 'errors';
|
||||
import { errorHandler } from 'errors/errorHandler';
|
||||
@@ -37,6 +38,10 @@ const initializeExpress = (): void => {
|
||||
next();
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
app.use('/', testRoutes);
|
||||
}
|
||||
|
||||
app.use('/', authenticationRoutes);
|
||||
|
||||
app.use('/', authenticateUser);
|
||||
|
||||
@@ -2,51 +2,57 @@
|
||||
"compilerOptions": {
|
||||
/* Basic Options */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
|
||||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
|
||||
"lib": ["dom", "es6", "es2017", "es2019", "esnext.asynciterable"], /* Specify library files to be included in the compilation. */
|
||||
"target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
|
||||
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
|
||||
"lib": [
|
||||
"dom",
|
||||
"es6",
|
||||
"es2017",
|
||||
"es2019",
|
||||
"esnext.asynciterable"
|
||||
] /* Specify library files to be included in the compilation. */,
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
"sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
"sourceMap": true /* Generates corresponding '.map' file. */,
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "./build", /* Redirect output structure to the directory. */
|
||||
"outDir": "./build" /* Redirect output structure to the directory. */,
|
||||
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
"removeComments": true, /* Do not emit comments to output. */
|
||||
"removeComments": true /* Do not emit comments to output. */,
|
||||
// "noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
"strictNullChecks": true, /* Enable strict null checks. */
|
||||
"strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
"strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */
|
||||
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
|
||||
"strictNullChecks": true /* Enable strict null checks. */,
|
||||
"strictFunctionTypes": true /* Enable strict checking of function types. */,
|
||||
"strictBindCallApply": true /* Enable strict 'bind', 'call', and 'apply' methods on functions. */,
|
||||
"strictPropertyInitialization": false /* Enable strict checking of property initialization in classes. */,
|
||||
"noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
|
||||
"alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
|
||||
|
||||
/* Additional Checks */
|
||||
"noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
"noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
"noImplicitReturns": false, /* Report error when not all code paths in function return a value. */
|
||||
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
"noUnusedLocals": true /* Report errors on unused locals. */,
|
||||
"noUnusedParameters": true /* Report errors on unused parameters. */,
|
||||
"noImplicitReturns": false /* Report error when not all code paths in function return a value. */,
|
||||
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
|
||||
|
||||
/* Module Resolution Options */
|
||||
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
"moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
|
||||
"baseUrl": "./src",
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
"types": ["node"] /* Type declaration files to be included in compilation. */,
|
||||
"allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
|
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
@@ -57,8 +63,8 @@
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
"experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
|
||||
"emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,
|
||||
|
||||
/* Advanced Options */
|
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
|
||||
|
||||
8
client/cypress.json
Normal file
8
client/cypress.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"baseUrl": "http://localhost:8080",
|
||||
"viewportHeight": 800,
|
||||
"viewportWidth": 1440,
|
||||
"env": {
|
||||
"apiBaseUrl": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
6
client/cypress/.eslintrc.json
Normal file
6
client/cypress/.eslintrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": ["plugin:cypress/recommended"],
|
||||
"rules": {
|
||||
"no-unused-expressions": 0 // chai assertions trigger this rule
|
||||
}
|
||||
}
|
||||
21
client/cypress/integration/authentication.spec.js
Normal file
21
client/cypress/integration/authentication.spec.js
Normal file
@@ -0,0 +1,21 @@
|
||||
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', 7);
|
||||
});
|
||||
});
|
||||
35
client/cypress/integration/issueCreate.spec.js
Normal file
35
client/cypress/integration/issueCreate.spec.js
Normal file
@@ -0,0 +1,35 @@
|
||||
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`);
|
||||
});
|
||||
});
|
||||
168
client/cypress/integration/issueDetails.spec.js
Normal file
168
client/cypress/integration/issueDetails.spec.js
Normal file
@@ -0,0 +1,168 @@
|
||||
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`)
|
||||
.first()
|
||||
.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`)
|
||||
.first()
|
||||
.should('contain', 'Edit')
|
||||
.and('contain', 'TEST_COMMENT_EDITED');
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a comment successfully', () => {
|
||||
getIssueDetailsModal()
|
||||
.find(testid`issue-comment`)
|
||||
.first()
|
||||
.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);
|
||||
});
|
||||
35
client/cypress/integration/issueFilters.spec.js
Normal file
35
client/cypress/integration/issueFilters.spec.js
Normal file
@@ -0,0 +1,35 @@
|
||||
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);
|
||||
});
|
||||
50
client/cypress/integration/issueSearch.spec.js
Normal file
50
client/cypress/integration/issueSearch.spec.js
Normal file
@@ -0,0 +1,50 @@
|
||||
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/"]');
|
||||
});
|
||||
48
client/cypress/integration/issuesDragDrop.spec.js
Normal file
48
client/cypress/integration/issuesDragDrop.spec.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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 });
|
||||
});
|
||||
};
|
||||
});
|
||||
34
client/cypress/integration/projectSettings.spec.js
Normal file
34
client/cypress/integration/projectSettings.spec.js
Normal file
@@ -0,0 +1,34 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
22
client/cypress/plugins/index.js
Normal file
22
client/cypress/plugins/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/* 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 }));
|
||||
};
|
||||
75
client/cypress/support/commands.js
Normal file
75
client/cypress/support/commands.js
Normal file
@@ -0,0 +1,75 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
16
client/cypress/support/index.js
Normal file
16
client/cypress/support/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// ***********************************************************
|
||||
// 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';
|
||||
4
client/cypress/support/utils.js
Normal file
4
client/cypress/support/utils.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const testid = (strings, ...values) => {
|
||||
const id = strings.map((str, index) => str + (values[index] || '')).join('');
|
||||
return `[data-testid="${id}"]`;
|
||||
};
|
||||
9
client/jest.config.js
Normal file
9
client/jest.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
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)$':
|
||||
'<rootDir>/jest/fileMock.js',
|
||||
'\\.(css|scss|less)$': '<rootDir>/jest/styleMock.js',
|
||||
},
|
||||
};
|
||||
1
client/jest/fileMock.js
Normal file
1
client/jest/fileMock.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = 'test-file-stub';
|
||||
1
client/jest/styleMock.js
Normal file
1
client/jest/styleMock.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = {};
|
||||
@@ -4,5 +4,5 @@
|
||||
"baseUrl": "./src",
|
||||
"jsx": "react"
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "dev"]
|
||||
"include": ["src/**/*", "cypress/**/*.js", "./node_modules/cypress"]
|
||||
}
|
||||
|
||||
2849
client/package-lock.json
generated
2849
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "webpack-dev-server",
|
||||
"test-jest": "jest",
|
||||
"test-cypress": "node_modules/.bin/cypress open",
|
||||
"build": "rm -rf dist && webpack --config webpack.config.production.js --progress",
|
||||
"pre-commit": "lint-staged"
|
||||
},
|
||||
@@ -16,18 +18,22 @@
|
||||
"@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",
|
||||
@@ -37,6 +43,7 @@
|
||||
"webpack-dev-server": "^3.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@4tw/cypress-drag-drop": "^1.3.0",
|
||||
"axios": "^0.19.0",
|
||||
"color": "^3.1.2",
|
||||
"core-js": "^3.4.7",
|
||||
|
||||
@@ -25,7 +25,7 @@ const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, mergeFilte
|
||||
const areFiltersCleared = !searchTerm && userIds.length === 0 && !myOnly && !recent;
|
||||
|
||||
return (
|
||||
<Filters>
|
||||
<Filters data-testid="board-filters">
|
||||
<SearchInput
|
||||
icon="search"
|
||||
value={searchTerm}
|
||||
|
||||
@@ -25,6 +25,7 @@ const ProjectBoardIssueDetailsAssigneesReporter = ({ issue, updateIssue, project
|
||||
variant="empty"
|
||||
dropdownWidth={343}
|
||||
placeholder="Unassigned"
|
||||
name="assignees"
|
||||
value={issue.userIds}
|
||||
options={userOptions}
|
||||
onChange={userIds => {
|
||||
@@ -41,6 +42,7 @@ const ProjectBoardIssueDetailsAssigneesReporter = ({ issue, updateIssue, project
|
||||
variant="empty"
|
||||
dropdownWidth={343}
|
||||
withClearValue={false}
|
||||
name="reporter"
|
||||
value={issue.reporterId}
|
||||
options={userOptions}
|
||||
onChange={userId => updateIssue({ reporterId: userId })}
|
||||
|
||||
@@ -50,7 +50,7 @@ const ProjectBoardIssueDetailsComment = ({ comment, fetchIssue }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Comment>
|
||||
<Comment data-testid="issue-comment">
|
||||
<UserAvatar name={comment.user.name} avatarUrl={comment.user.avatarUrl} />
|
||||
<Content>
|
||||
<Username>{comment.user.name}</Username>
|
||||
@@ -71,7 +71,7 @@ const ProjectBoardIssueDetailsComment = ({ comment, fetchIssue }) => {
|
||||
<ConfirmModal
|
||||
title="Are you sure you want to delete this comment?"
|
||||
message="Once you delete, it's gone for good."
|
||||
confirmText="Delete Comment"
|
||||
confirmText="Delete comment"
|
||||
onConfirm={handleCommentDelete}
|
||||
renderLink={modal => <DeleteLink onClick={modal.open}>Delete</DeleteLink>}
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,7 @@ const ProjectBoardIssueDetailsEstimateTracking = ({ issue, updateIssue }) => (
|
||||
|
||||
<SectionTitle>Time Tracking</SectionTitle>
|
||||
<Modal
|
||||
testid="modal:tracking"
|
||||
width={400}
|
||||
renderLink={modal => (
|
||||
<TrackingLink onClick={modal.open}>
|
||||
|
||||
@@ -19,6 +19,7 @@ const ProjectBoardIssueDetailsPriority = ({ issue, updateIssue }) => (
|
||||
variant="empty"
|
||||
withClearValue={false}
|
||||
dropdownWidth={343}
|
||||
name="priority"
|
||||
value={issue.priority}
|
||||
options={Object.values(IssuePriority).map(priority => ({
|
||||
value: priority,
|
||||
|
||||
@@ -19,6 +19,7 @@ const ProjectBoardIssueDetailsStatus = ({ issue, updateIssue }) => (
|
||||
variant="empty"
|
||||
dropdownWidth={343}
|
||||
withClearValue={false}
|
||||
name="status"
|
||||
value={issue.status}
|
||||
options={Object.values(IssueStatus).map(status => ({
|
||||
value: status,
|
||||
|
||||
@@ -16,6 +16,7 @@ const ProjectBoardIssueDetailsType = ({ issue, updateIssue }) => (
|
||||
variant="empty"
|
||||
dropdownWidth={150}
|
||||
withClearValue={false}
|
||||
name="type"
|
||||
value={issue.type}
|
||||
options={Object.values(IssueType).map(type => ({
|
||||
value: type,
|
||||
|
||||
@@ -24,6 +24,7 @@ const ProjectBoardListIssue = ({ projectUsers, issue, index }) => {
|
||||
<IssueLink
|
||||
to={`${match.url}/issues/${issue.id}`}
|
||||
ref={provided.innerRef}
|
||||
data-testid="list-issue"
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
|
||||
@@ -31,7 +31,11 @@ const ProjectBoardList = ({ status, project, filters }) => {
|
||||
{`${IssueStatusCopy[status]} `}
|
||||
<IssuesCount>{formatIssuesCount(allListIssues, filteredListIssues)}</IssuesCount>
|
||||
</Title>
|
||||
<Issues {...provided.droppableProps} ref={provided.innerRef}>
|
||||
<Issues
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
data-testid={`board-list:${status}`}
|
||||
>
|
||||
{filteredListIssues.map((issue, index) => (
|
||||
<Issue key={issue.id} projectUsers={project.users} issue={issue} index={index} />
|
||||
))}
|
||||
|
||||
@@ -49,6 +49,7 @@ const ProjectBoard = ({ project, fetchProject, updateLocalProjectIssues }) => {
|
||||
render={routeProps => (
|
||||
<Modal
|
||||
isOpen
|
||||
testid="modal:issue-details"
|
||||
width={1040}
|
||||
withCloseIcon={false}
|
||||
onClose={() => history.push(match.url)}
|
||||
|
||||
@@ -30,7 +30,7 @@ const propTypes = {
|
||||
modalClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const ProjectIssueCreateForm = ({ project, fetchProject, onCreate, modalClose }) => {
|
||||
const ProjectIssueCreate = ({ project, fetchProject, onCreate, modalClose }) => {
|
||||
const [{ isCreating }, createIssue] = useApi.post('/issues');
|
||||
|
||||
const { currentUserId } = useCurrentUser();
|
||||
@@ -168,6 +168,6 @@ const renderUser = project => ({ value: userId, removeOptionValue }) => {
|
||||
);
|
||||
};
|
||||
|
||||
ProjectIssueCreateForm.propTypes = propTypes;
|
||||
ProjectIssueCreate.propTypes = propTypes;
|
||||
|
||||
export default ProjectIssueCreateForm;
|
||||
export default ProjectIssueCreate;
|
||||
@@ -9,10 +9,11 @@ export const FormCont = styled.div`
|
||||
`;
|
||||
|
||||
export const FormElement = styled(Form.Element)`
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
`;
|
||||
|
||||
export const FormHeading = styled.div`
|
||||
export const FormHeading = styled.h1`
|
||||
padding: 6px 0 15px;
|
||||
${font.size(24)}
|
||||
${font.medium}
|
||||
|
||||
@@ -10,7 +10,7 @@ import NavbarLeft from './NavbarLeft';
|
||||
import Sidebar from './Sidebar';
|
||||
import Board from './Board';
|
||||
import IssueSearch from './IssueSearch';
|
||||
import IssueCreateForm from './IssueCreateForm';
|
||||
import IssueCreate from './IssueCreate';
|
||||
import ProjectSettings from './ProjectSettings';
|
||||
import { ProjectPage } from './Styles';
|
||||
|
||||
@@ -49,6 +49,7 @@ const Project = () => {
|
||||
{issueSearchModalHelpers.isOpen() && (
|
||||
<Modal
|
||||
isOpen
|
||||
testid="modal:issue-search"
|
||||
variant="aside"
|
||||
width={600}
|
||||
onClose={issueSearchModalHelpers.close}
|
||||
@@ -59,11 +60,12 @@ const Project = () => {
|
||||
{issueCreateModalHelpers.isOpen() && (
|
||||
<Modal
|
||||
isOpen
|
||||
testid="modal:issue-create"
|
||||
width={800}
|
||||
withCloseIcon={false}
|
||||
onClose={issueCreateModalHelpers.close}
|
||||
renderContent={modal => (
|
||||
<IssueCreateForm
|
||||
<IssueCreate
|
||||
project={project}
|
||||
fetchProject={fetchProject}
|
||||
onCreate={() => history.push(`${match.url}/board`)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import App from 'App';
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
|
||||
// QUERY component cache-only doesn't work until first req finishes, look at currentUser on page load
|
||||
// APP IS NOT RESPONSIVE - REDUCE BROWSER HEIGHT, ISSUES DONT SCROLL
|
||||
// TODO: UPDATE FORMIK TO FIX SETFIELDVALUE TO EMPTY ARRAY ISSUE https://github.com/jaredpalmer/formik/pull/2144
|
||||
// REFACTOR HTML TO USE SEMANTIC ELEMENTS
|
||||
|
||||
@@ -18,12 +18,19 @@ const defaultProps = {
|
||||
};
|
||||
|
||||
const Avatar = ({ className, avatarUrl, name, size, ...otherProps }) => {
|
||||
const sharedProps = {
|
||||
className,
|
||||
size,
|
||||
'data-testid': name ? `avatar:${name}` : 'avatar',
|
||||
...otherProps,
|
||||
};
|
||||
|
||||
if (avatarUrl) {
|
||||
return <Image className={className} size={size} avatarUrl={avatarUrl} {...otherProps} />;
|
||||
return <Image avatarUrl={avatarUrl} {...sharedProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Letter className={className} size={size} color={getColorFromName(name)} {...otherProps}>
|
||||
<Letter color={getColorFromName(name)} {...sharedProps}>
|
||||
<span>{name.charAt(0)}</span>
|
||||
</Letter>
|
||||
);
|
||||
|
||||
@@ -47,8 +47,9 @@ const ConfirmModal = ({
|
||||
|
||||
return (
|
||||
<StyledConfirmModal
|
||||
withCloseIcon={false}
|
||||
className={className}
|
||||
testid="modal:confirm"
|
||||
withCloseIcon={false}
|
||||
renderLink={renderLink}
|
||||
renderContent={modal => (
|
||||
<>
|
||||
|
||||
@@ -15,23 +15,29 @@ const propTypes = {
|
||||
label: PropTypes.string,
|
||||
tip: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
label: null,
|
||||
tip: null,
|
||||
error: null,
|
||||
label: undefined,
|
||||
tip: undefined,
|
||||
error: undefined,
|
||||
name: undefined,
|
||||
};
|
||||
|
||||
const generateField = FormComponent => {
|
||||
const FieldComponent = ({ className, label, tip, error, ...otherProps }) => {
|
||||
const FieldComponent = ({ className, label, tip, error, name, ...otherProps }) => {
|
||||
const fieldId = uniqueId('form-field-');
|
||||
|
||||
return (
|
||||
<StyledField className={className} hasLabel={!!label}>
|
||||
<StyledField
|
||||
className={className}
|
||||
hasLabel={!!label}
|
||||
data-testid={name ? `form-field:${name}` : 'form-field'}
|
||||
>
|
||||
{label && <FieldLabel htmlFor={fieldId}>{label}</FieldLabel>}
|
||||
<FormComponent id={fieldId} invalid={!!error} {...otherProps} />
|
||||
<FormComponent id={fieldId} invalid={!!error} name={name} {...otherProps} />
|
||||
{tip && <FieldTip>{tip}</FieldTip>}
|
||||
{error && <FieldError>{error}</FieldError>}
|
||||
</StyledField>
|
||||
|
||||
@@ -53,7 +53,9 @@ const defaultProps = {
|
||||
top: 0,
|
||||
};
|
||||
|
||||
const Icon = ({ type, ...iconProps }) => <StyledIcon {...iconProps} code={fontIconCodes[type]} />;
|
||||
const Icon = ({ type, ...iconProps }) => (
|
||||
<StyledIcon {...iconProps} data-testid={`icon:${type}`} code={fontIconCodes[type]} />
|
||||
);
|
||||
|
||||
Icon.propTypes = propTypes;
|
||||
Icon.defaultProps = defaultProps;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ScrollOverlay, ClickableOverlay, StyledModal, CloseIcon } from './Style
|
||||
|
||||
const propTypes = {
|
||||
className: PropTypes.string,
|
||||
testid: PropTypes.string,
|
||||
variant: PropTypes.oneOf(['center', 'aside']),
|
||||
width: PropTypes.number,
|
||||
withCloseIcon: PropTypes.bool,
|
||||
@@ -20,6 +21,7 @@ const propTypes = {
|
||||
|
||||
const defaultProps = {
|
||||
className: undefined,
|
||||
testid: 'modal',
|
||||
variant: 'center',
|
||||
width: 600,
|
||||
withCloseIcon: true,
|
||||
@@ -30,6 +32,7 @@ const defaultProps = {
|
||||
|
||||
const Modal = ({
|
||||
className,
|
||||
testid,
|
||||
variant,
|
||||
width,
|
||||
withCloseIcon,
|
||||
@@ -63,9 +66,16 @@ const Modal = ({
|
||||
|
||||
{isOpen &&
|
||||
ReactDOM.createPortal(
|
||||
<ScrollOverlay data-jira-modal="true">
|
||||
<ScrollOverlay>
|
||||
<ClickableOverlay variant={variant} ref={$clickableOverlayRef}>
|
||||
<StyledModal className={className} variant={variant} width={width} ref={$modalRef}>
|
||||
<StyledModal
|
||||
className={className}
|
||||
variant={variant}
|
||||
width={width}
|
||||
data-jira-modal="true"
|
||||
data-testid={testid}
|
||||
ref={$modalRef}
|
||||
>
|
||||
{withCloseIcon && <CloseIcon type="close" variant={variant} onClick={closeModal} />}
|
||||
{renderContent({ close: closeModal })}
|
||||
</StyledModal>
|
||||
|
||||
@@ -192,6 +192,7 @@ const SelectDropdown = ({
|
||||
<Option
|
||||
key={option.value}
|
||||
data-select-option-value={option.value}
|
||||
data-testid={`select-option:${option.label}`}
|
||||
onMouseEnter={handleOptionMouseEnter}
|
||||
onClick={() => selectOptionValue(option.value)}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,10 @@ export const StyledSelect = styled.div`
|
||||
width: 100%;
|
||||
border: 1px solid ${color.borderLightest};
|
||||
background: ${color.backgroundLightest};
|
||||
transition: background 0.1s;
|
||||
&:hover {
|
||||
background: ${color.backgroundLight};
|
||||
}
|
||||
`}
|
||||
&:focus {
|
||||
outline: none;
|
||||
|
||||
@@ -20,6 +20,7 @@ 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,
|
||||
@@ -37,6 +38,7 @@ const defaultProps = {
|
||||
className: undefined,
|
||||
variant: 'normal',
|
||||
dropdownWidth: undefined,
|
||||
name: undefined,
|
||||
value: undefined,
|
||||
defaultValue: undefined,
|
||||
placeholder: 'Select',
|
||||
@@ -52,6 +54,7 @@ const Select = ({
|
||||
className,
|
||||
variant,
|
||||
dropdownWidth,
|
||||
name,
|
||||
value: propsValue,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
@@ -140,7 +143,11 @@ const Select = ({
|
||||
onKeyDown={handleFocusedSelectKeydown}
|
||||
invalid={invalid}
|
||||
>
|
||||
<ValueContainer variant={variant} onClick={activateDropdown}>
|
||||
<ValueContainer
|
||||
variant={variant}
|
||||
data-testid={name ? `select:${name}` : 'select'}
|
||||
onClick={activateDropdown}
|
||||
>
|
||||
{isValueEmpty && <Placeholder>{placeholder}</Placeholder>}
|
||||
|
||||
{!isValueEmpty && !isMulti && propsRenderValue
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export const KeyCodes = {
|
||||
ESCAPE: 27,
|
||||
TAB: 9,
|
||||
ENTER: 13,
|
||||
ESCAPE: 27,
|
||||
SPACE: 32,
|
||||
ARROW_LEFT: 37,
|
||||
ARROW_UP: 38,
|
||||
ARROW_RIGHT: 39,
|
||||
ARROW_DOWN: 40,
|
||||
M: 77,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ const useQuery = (url, propsVariables = {}, options = {}) => {
|
||||
data: null,
|
||||
error: null,
|
||||
isLoading: !lazy,
|
||||
wasCalled: !lazy,
|
||||
variables: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import useDeepCompareMemoize from 'shared/hooks/deepCompareMemoize';
|
||||
|
||||
const useOnOutsideClick = (
|
||||
$ignoredElementRefs,
|
||||
shouldListen,
|
||||
isListening,
|
||||
onOutsideClick,
|
||||
$listeningElementRef,
|
||||
) => {
|
||||
@@ -17,19 +17,19 @@ const useOnOutsideClick = (
|
||||
};
|
||||
|
||||
const handleMouseUp = event => {
|
||||
const isAnyIgnoredElementParentOfTarget = $ignoredElementRefsMemoized.some(
|
||||
const isAnyIgnoredElementAncestorOfTarget = $ignoredElementRefsMemoized.some(
|
||||
$elementRef =>
|
||||
$elementRef.current.contains($mouseDownTargetRef.current) ||
|
||||
$elementRef.current.contains(event.target),
|
||||
);
|
||||
if (event.button === 0 && !isAnyIgnoredElementParentOfTarget) {
|
||||
if (event.button === 0 && !isAnyIgnoredElementAncestorOfTarget) {
|
||||
onOutsideClick();
|
||||
}
|
||||
};
|
||||
|
||||
const $listeningElement = ($listeningElementRef || {}).current || document;
|
||||
|
||||
if (shouldListen) {
|
||||
if (isListening) {
|
||||
$listeningElement.addEventListener('mousedown', handleMouseDown);
|
||||
$listeningElement.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const useOnOutsideClick = (
|
||||
$listeningElement.removeEventListener('mousedown', handleMouseDown);
|
||||
$listeningElement.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [$ignoredElementRefsMemoized, $listeningElementRef, shouldListen, onOutsideClick]);
|
||||
}, [$ignoredElementRefsMemoized, $listeningElementRef, isListening, onOutsideClick]);
|
||||
};
|
||||
|
||||
export default useOnOutsideClick;
|
||||
|
||||
Reference in New Issue
Block a user