Wrote end-to-end cypress tests

This commit is contained in:
ireic
2020-01-05 02:54:46 +01:00
parent ad74afb628
commit 64b237e046
60 changed files with 3698 additions and 215 deletions

52
api/package-lock.json generated
View File

@@ -1045,6 +1045,58 @@
"capture-stack-trace": "^1.0.0" "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": { "cross-spawn": {
"version": "6.0.5", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",

View File

@@ -5,7 +5,7 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"start": "nodemon --exec ts-node --files src/index.ts", "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" "pre-commit": "lint-staged"
}, },
"dependencies": { "dependencies": {
@@ -32,6 +32,7 @@
"@types/node": "^12.12.11", "@types/node": "^12.12.11",
"@typescript-eslint/eslint-plugin": "^2.7.0", "@typescript-eslint/eslint-plugin": "^2.7.0",
"@typescript-eslint/parser": "^2.7.0", "@typescript-eslint/parser": "^2.7.0",
"cross-env": "^6.0.3",
"eslint": "^6.1.0", "eslint": "^6.1.0",
"eslint-config-airbnb-base": "^14.0.0", "eslint-config-airbnb-base": "^14.0.0",
"eslint-config-prettier": "^6.7.0", "eslint-config-prettier": "^6.7.0",

View File

@@ -2,14 +2,14 @@ import express from 'express';
import { catchErrors } from 'errors'; import { catchErrors } from 'errors';
import { signToken } from 'utils/authToken'; import { signToken } from 'utils/authToken';
import seedGuestUserEntities from 'database/seeds/guestUser'; import createGuestAccount from 'database/createGuestAccount';
const router = express.Router(); const router = express.Router();
router.post( router.post(
'/authentication/guest', '/authentication/guest',
catchErrors(async (_req, res) => { catchErrors(async (_req, res) => {
const user = await seedGuestUserEntities(); const user = await createGuestAccount();
res.respond({ res.respond({
authToken: signToken({ sub: user.id }), authToken: signToken({ sub: user.id }),
}); });

View 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;

View File

@@ -1,5 +1,3 @@
import { sample } from 'lodash';
import { Comment, Issue, Project, User } from 'entities'; import { Comment, Issue, Project, User } from 'entities';
import { ProjectCategory } from 'constants/projects'; import { ProjectCategory } from 'constants/projects';
import { IssueType, IssueStatus, IssuePriority } from 'constants/issues'; import { IssueType, IssueStatus, IssuePriority } from 'constants/issues';
@@ -37,7 +35,8 @@ const seedProject = (users: User[]): Promise<Project> =>
}); });
const seedIssues = (project: Project): Promise<Issue[]> => { const seedIssues = (project: Project): Promise<Issue[]> => {
const getRandomUser = (): User => sample(project.users) as User; const { users } = project;
const issues = [ const issues = [
createEntity(Issue, { createEntity(Issue, {
title: 'This is an issue of type: Task.', title: 'This is an issue of type: Task.',
@@ -46,9 +45,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
priority: IssuePriority.LOWEST, priority: IssuePriority.LOWEST,
listPosition: 1, listPosition: 1,
estimate: 8, estimate: 8,
reporterId: getRandomUser().id, reporterId: users[1].id,
project, project,
users: [getRandomUser()], users: [users[0]],
}), }),
createEntity(Issue, { createEntity(Issue, {
title: "Click on an issue to see what's behind it.", title: "Click on an issue to see what's behind it.",
@@ -58,7 +57,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
listPosition: 2, listPosition: 2,
description: 'Nothing in particular.', description: 'Nothing in particular.',
estimate: 40, estimate: 40,
reporterId: getRandomUser().id, reporterId: users[2].id,
project, project,
}), }),
createEntity(Issue, { createEntity(Issue, {
@@ -68,9 +67,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
priority: IssuePriority.MEDIUM, priority: IssuePriority.MEDIUM,
listPosition: 3, listPosition: 3,
estimate: 15, estimate: 15,
reporterId: getRandomUser().id, reporterId: users[1].id,
project, project,
users: [getRandomUser()], users: [users[2]],
}), }),
createEntity(Issue, { createEntity(Issue, {
title: 'You can use markdown for issue descriptions.', title: 'You can use markdown for issue descriptions.',
@@ -80,9 +79,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
listPosition: 4, listPosition: 4,
description: '#### Colons can be used to align columns.', description: '#### Colons can be used to align columns.',
estimate: 4, estimate: 4,
reporterId: getRandomUser().id, reporterId: users[0].id,
project, project,
users: [getRandomUser()], users: [users[2]],
}), }),
createEntity(Issue, { createEntity(Issue, {
title: 'You must assign priority from lowest to highest to all issues.', title: 'You must assign priority from lowest to highest to all issues.',
@@ -91,7 +90,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
priority: IssuePriority.HIGHEST, priority: IssuePriority.HIGHEST,
listPosition: 5, listPosition: 5,
estimate: 15, estimate: 15,
reporterId: getRandomUser().id, reporterId: users[2].id,
project, project,
}), }),
createEntity(Issue, { createEntity(Issue, {
@@ -101,9 +100,9 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
priority: IssuePriority.MEDIUM, priority: IssuePriority.MEDIUM,
listPosition: 6, listPosition: 6,
estimate: 55, estimate: 55,
reporterId: getRandomUser().id, reporterId: users[1].id,
project, project,
users: [getRandomUser()], users: [users[0]],
}), }),
createEntity(Issue, { createEntity(Issue, {
title: 'Try leaving a comment on this issue.', title: 'Try leaving a comment on this issue.',
@@ -112,7 +111,7 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
priority: IssuePriority.MEDIUM, priority: IssuePriority.MEDIUM,
listPosition: 7, listPosition: 7,
estimate: 12, estimate: 12,
reporterId: getRandomUser().id, reporterId: users[0].id,
project, project,
}), }),
]; ];
@@ -122,16 +121,16 @@ const seedIssues = (project: Project): Promise<Issue[]> => {
const seedComments = (issue: Issue, user: User): Promise<Comment> => const seedComments = (issue: Issue, user: User): Promise<Comment> =>
createEntity(Comment, { createEntity(Comment, {
body: "Be nice to each other! Don't be mean to each other!", body: "Be nice to each other! Don't be mean to each other!",
issue, issueId: issue.id,
user, userId: user.id,
}); });
const seedGuestUserEntities = async (): Promise<User> => { const createGuestAccount = async (): Promise<User> => {
const users = await seedUsers(); const users = await seedUsers();
const project = await seedProject(users); const project = await seedProject(users);
const issues = await seedIssues(project); const issues = await seedIssues(project);
await seedComments(issues[issues.length - 1], project.users[0]); await seedComments(issues[0], project.users[0]);
return users[0]; return users[0];
}; };
export default seedGuestUserEntities; export default createGuestAccount;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -4,12 +4,13 @@ import 'reflect-metadata';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import createDatabaseConnection from 'database/connection'; import createDatabaseConnection from 'database/createConnection';
import { authenticateUser } from 'middleware/authentication'; import { authenticateUser } from 'middleware/authentication';
import authenticationRoutes from 'controllers/authentication'; import authenticationRoutes from 'controllers/authentication';
import commentsRoutes from 'controllers/comments'; import commentsRoutes from 'controllers/comments';
import issuesRoutes from 'controllers/issues'; import issuesRoutes from 'controllers/issues';
import projectsRoutes from 'controllers/projects'; import projectsRoutes from 'controllers/projects';
import testRoutes from 'controllers/test';
import usersRoutes from 'controllers/users'; import usersRoutes from 'controllers/users';
import { RouteNotFoundError } from 'errors'; import { RouteNotFoundError } from 'errors';
import { errorHandler } from 'errors/errorHandler'; import { errorHandler } from 'errors/errorHandler';
@@ -37,6 +38,10 @@ const initializeExpress = (): void => {
next(); next();
}); });
if (process.env.NODE_ENV === 'test') {
app.use('/', testRoutes);
}
app.use('/', authenticationRoutes); app.use('/', authenticationRoutes);
app.use('/', authenticateUser); app.use('/', authenticateUser);

View File

@@ -2,51 +2,57 @@
"compilerOptions": { "compilerOptions": {
/* Basic Options */ /* Basic Options */
// "incremental": true, /* Enable incremental compilation */ // "incremental": true, /* Enable incremental compilation */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ "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'. */ "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. */ "lib": [
"dom",
"es6",
"es2017",
"es2019",
"esnext.asynciterable"
] /* Specify library files to be included in the compilation. */,
// "allowJs": true, /* Allow javascript files to be compiled. */ // "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */ // "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */ // "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each 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. */ // "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. */ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */ // "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ // "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. */ // "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */ // "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'. */ // "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'). */ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */ /* Strict Type-Checking Options */
"strict": true, /* Enable all 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. */ "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
"strictNullChecks": true, /* Enable strict null checks. */ "strictNullChecks": true /* Enable strict null checks. */,
"strictFunctionTypes": true, /* Enable strict checking of function types. */ "strictFunctionTypes": true /* Enable strict checking of function types. */,
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ "strictBindCallApply": true /* Enable strict 'bind', 'call', and 'apply' methods on functions. */,
"strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */ "strictPropertyInitialization": false /* Enable strict checking of property initialization in classes. */,
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ "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. */ "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
/* Additional Checks */ /* Additional Checks */
"noUnusedLocals": true, /* Report errors on unused locals. */ "noUnusedLocals": true /* Report errors on unused locals. */,
"noUnusedParameters": true, /* Report errors on unused parameters. */ "noUnusedParameters": true /* Report errors on unused parameters. */,
"noImplicitReturns": false, /* Report error when not all code paths in function return a value. */ "noImplicitReturns": false /* Report error when not all code paths in function return a value. */,
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
/* Module Resolution Options */ /* 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", "baseUrl": "./src",
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "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. */ // "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. */ // "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */ "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. */ "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'. */ "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. */ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
@@ -57,11 +63,11 @@
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */ /* Experimental Options */
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,
/* Advanced Options */ /* Advanced Options */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}, },
"exclude": ["node_modules"], "exclude": ["node_modules"],
"include": ["./src/**/*.ts"] "include": ["./src/**/*.ts"]

8
client/cypress.json Normal file
View File

@@ -0,0 +1,8 @@
{
"baseUrl": "http://localhost:8080",
"viewportHeight": 800,
"viewportWidth": 1440,
"env": {
"apiBaseUrl": "http://localhost:3000"
}
}

View File

@@ -0,0 +1,6 @@
{
"extends": ["plugin:cypress/recommended"],
"rules": {
"no-unused-expressions": 0 // chai assertions trigger this rule
}
}

View 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);
});
});

View 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`);
});
});

View 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);
});

View 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);
});

View 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/"]');
});

View 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 });
});
};
});

View 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');
});
});

View 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 }));
};

View 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);
});
});

View 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';

View 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
View 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
View File

@@ -0,0 +1 @@
module.exports = 'test-file-stub';

1
client/jest/styleMock.js Normal file
View File

@@ -0,0 +1 @@
module.exports = {};

View File

@@ -4,5 +4,5 @@
"baseUrl": "./src", "baseUrl": "./src",
"jsx": "react" "jsx": "react"
}, },
"exclude": ["node_modules", "dist", "dev"] "include": ["src/**/*", "cypress/**/*.js", "./node_modules/cypress"]
} }

2849
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"start": "webpack-dev-server", "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", "build": "rm -rf dist && webpack --config webpack.config.production.js --progress",
"pre-commit": "lint-staged" "pre-commit": "lint-staged"
}, },
@@ -16,18 +18,22 @@
"@babel/plugin-syntax-dynamic-import": "^7.7.4", "@babel/plugin-syntax-dynamic-import": "^7.7.4",
"@babel/preset-env": "^7.7.4", "@babel/preset-env": "^7.7.4",
"@babel/preset-react": "^7.7.4", "@babel/preset-react": "^7.7.4",
"@cypress/webpack-preprocessor": "^4.1.1",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"babel-loader": "^8.0.6", "babel-loader": "^8.0.6",
"css-loader": "^3.3.2", "css-loader": "^3.3.2",
"cypress": "^3.8.1",
"eslint": "^6.1.0", "eslint": "^6.1.0",
"eslint-config-airbnb": "^18.0.1", "eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.7.0", "eslint-config-prettier": "^6.7.0",
"eslint-plugin-cypress": "^2.8.1",
"eslint-plugin-import": "^2.18.2", "eslint-plugin-import": "^2.18.2",
"eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.17.0", "eslint-plugin-react": "^7.17.0",
"eslint-plugin-react-hooks": "^1.7.0", "eslint-plugin-react-hooks": "^1.7.0",
"file-loader": "^5.0.2", "file-loader": "^5.0.2",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"jest": "^24.9.0",
"lint-staged": "^9.5.0", "lint-staged": "^9.5.0",
"prettier": "^1.19.1", "prettier": "^1.19.1",
"style-loader": "^1.0.1", "style-loader": "^1.0.1",
@@ -37,6 +43,7 @@
"webpack-dev-server": "^3.9.0" "webpack-dev-server": "^3.9.0"
}, },
"dependencies": { "dependencies": {
"@4tw/cypress-drag-drop": "^1.3.0",
"axios": "^0.19.0", "axios": "^0.19.0",
"color": "^3.1.2", "color": "^3.1.2",
"core-js": "^3.4.7", "core-js": "^3.4.7",

View File

@@ -25,7 +25,7 @@ const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, mergeFilte
const areFiltersCleared = !searchTerm && userIds.length === 0 && !myOnly && !recent; const areFiltersCleared = !searchTerm && userIds.length === 0 && !myOnly && !recent;
return ( return (
<Filters> <Filters data-testid="board-filters">
<SearchInput <SearchInput
icon="search" icon="search"
value={searchTerm} value={searchTerm}

View File

@@ -25,6 +25,7 @@ const ProjectBoardIssueDetailsAssigneesReporter = ({ issue, updateIssue, project
variant="empty" variant="empty"
dropdownWidth={343} dropdownWidth={343}
placeholder="Unassigned" placeholder="Unassigned"
name="assignees"
value={issue.userIds} value={issue.userIds}
options={userOptions} options={userOptions}
onChange={userIds => { onChange={userIds => {
@@ -41,6 +42,7 @@ const ProjectBoardIssueDetailsAssigneesReporter = ({ issue, updateIssue, project
variant="empty" variant="empty"
dropdownWidth={343} dropdownWidth={343}
withClearValue={false} withClearValue={false}
name="reporter"
value={issue.reporterId} value={issue.reporterId}
options={userOptions} options={userOptions}
onChange={userId => updateIssue({ reporterId: userId })} onChange={userId => updateIssue({ reporterId: userId })}

View File

@@ -50,7 +50,7 @@ const ProjectBoardIssueDetailsComment = ({ comment, fetchIssue }) => {
}; };
return ( return (
<Comment> <Comment data-testid="issue-comment">
<UserAvatar name={comment.user.name} avatarUrl={comment.user.avatarUrl} /> <UserAvatar name={comment.user.name} avatarUrl={comment.user.avatarUrl} />
<Content> <Content>
<Username>{comment.user.name}</Username> <Username>{comment.user.name}</Username>
@@ -71,7 +71,7 @@ const ProjectBoardIssueDetailsComment = ({ comment, fetchIssue }) => {
<ConfirmModal <ConfirmModal
title="Are you sure you want to delete this comment?" title="Are you sure you want to delete this comment?"
message="Once you delete, it's gone for good." message="Once you delete, it's gone for good."
confirmText="Delete Comment" confirmText="Delete comment"
onConfirm={handleCommentDelete} onConfirm={handleCommentDelete}
renderLink={modal => <DeleteLink onClick={modal.open}>Delete</DeleteLink>} renderLink={modal => <DeleteLink onClick={modal.open}>Delete</DeleteLink>}
/> />

View File

@@ -28,6 +28,7 @@ const ProjectBoardIssueDetailsEstimateTracking = ({ issue, updateIssue }) => (
<SectionTitle>Time Tracking</SectionTitle> <SectionTitle>Time Tracking</SectionTitle>
<Modal <Modal
testid="modal:tracking"
width={400} width={400}
renderLink={modal => ( renderLink={modal => (
<TrackingLink onClick={modal.open}> <TrackingLink onClick={modal.open}>

View File

@@ -19,6 +19,7 @@ const ProjectBoardIssueDetailsPriority = ({ issue, updateIssue }) => (
variant="empty" variant="empty"
withClearValue={false} withClearValue={false}
dropdownWidth={343} dropdownWidth={343}
name="priority"
value={issue.priority} value={issue.priority}
options={Object.values(IssuePriority).map(priority => ({ options={Object.values(IssuePriority).map(priority => ({
value: priority, value: priority,

View File

@@ -19,6 +19,7 @@ const ProjectBoardIssueDetailsStatus = ({ issue, updateIssue }) => (
variant="empty" variant="empty"
dropdownWidth={343} dropdownWidth={343}
withClearValue={false} withClearValue={false}
name="status"
value={issue.status} value={issue.status}
options={Object.values(IssueStatus).map(status => ({ options={Object.values(IssueStatus).map(status => ({
value: status, value: status,

View File

@@ -16,6 +16,7 @@ const ProjectBoardIssueDetailsType = ({ issue, updateIssue }) => (
variant="empty" variant="empty"
dropdownWidth={150} dropdownWidth={150}
withClearValue={false} withClearValue={false}
name="type"
value={issue.type} value={issue.type}
options={Object.values(IssueType).map(type => ({ options={Object.values(IssueType).map(type => ({
value: type, value: type,

View File

@@ -24,6 +24,7 @@ const ProjectBoardListIssue = ({ projectUsers, issue, index }) => {
<IssueLink <IssueLink
to={`${match.url}/issues/${issue.id}`} to={`${match.url}/issues/${issue.id}`}
ref={provided.innerRef} ref={provided.innerRef}
data-testid="list-issue"
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}
> >

View File

@@ -31,7 +31,11 @@ const ProjectBoardList = ({ status, project, filters }) => {
{`${IssueStatusCopy[status]} `} {`${IssueStatusCopy[status]} `}
<IssuesCount>{formatIssuesCount(allListIssues, filteredListIssues)}</IssuesCount> <IssuesCount>{formatIssuesCount(allListIssues, filteredListIssues)}</IssuesCount>
</Title> </Title>
<Issues {...provided.droppableProps} ref={provided.innerRef}> <Issues
{...provided.droppableProps}
ref={provided.innerRef}
data-testid={`board-list:${status}`}
>
{filteredListIssues.map((issue, index) => ( {filteredListIssues.map((issue, index) => (
<Issue key={issue.id} projectUsers={project.users} issue={issue} index={index} /> <Issue key={issue.id} projectUsers={project.users} issue={issue} index={index} />
))} ))}

View File

@@ -49,6 +49,7 @@ const ProjectBoard = ({ project, fetchProject, updateLocalProjectIssues }) => {
render={routeProps => ( render={routeProps => (
<Modal <Modal
isOpen isOpen
testid="modal:issue-details"
width={1040} width={1040}
withCloseIcon={false} withCloseIcon={false}
onClose={() => history.push(match.url)} onClose={() => history.push(match.url)}

View File

@@ -30,7 +30,7 @@ const propTypes = {
modalClose: PropTypes.func.isRequired, modalClose: PropTypes.func.isRequired,
}; };
const ProjectIssueCreateForm = ({ project, fetchProject, onCreate, modalClose }) => { const ProjectIssueCreate = ({ project, fetchProject, onCreate, modalClose }) => {
const [{ isCreating }, createIssue] = useApi.post('/issues'); const [{ isCreating }, createIssue] = useApi.post('/issues');
const { currentUserId } = useCurrentUser(); 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;

View File

@@ -9,10 +9,11 @@ export const FormCont = styled.div`
`; `;
export const FormElement = styled(Form.Element)` export const FormElement = styled(Form.Element)`
width: 100%;
max-width: 640px; max-width: 640px;
`; `;
export const FormHeading = styled.div` export const FormHeading = styled.h1`
padding: 6px 0 15px; padding: 6px 0 15px;
${font.size(24)} ${font.size(24)}
${font.medium} ${font.medium}

View File

@@ -10,7 +10,7 @@ import NavbarLeft from './NavbarLeft';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import Board from './Board'; import Board from './Board';
import IssueSearch from './IssueSearch'; import IssueSearch from './IssueSearch';
import IssueCreateForm from './IssueCreateForm'; import IssueCreate from './IssueCreate';
import ProjectSettings from './ProjectSettings'; import ProjectSettings from './ProjectSettings';
import { ProjectPage } from './Styles'; import { ProjectPage } from './Styles';
@@ -49,6 +49,7 @@ const Project = () => {
{issueSearchModalHelpers.isOpen() && ( {issueSearchModalHelpers.isOpen() && (
<Modal <Modal
isOpen isOpen
testid="modal:issue-search"
variant="aside" variant="aside"
width={600} width={600}
onClose={issueSearchModalHelpers.close} onClose={issueSearchModalHelpers.close}
@@ -59,11 +60,12 @@ const Project = () => {
{issueCreateModalHelpers.isOpen() && ( {issueCreateModalHelpers.isOpen() && (
<Modal <Modal
isOpen isOpen
testid="modal:issue-create"
width={800} width={800}
withCloseIcon={false} withCloseIcon={false}
onClose={issueCreateModalHelpers.close} onClose={issueCreateModalHelpers.close}
renderContent={modal => ( renderContent={modal => (
<IssueCreateForm <IssueCreate
project={project} project={project}
fetchProject={fetchProject} fetchProject={fetchProject}
onCreate={() => history.push(`${match.url}/board`)} onCreate={() => history.push(`${match.url}/board`)}

View File

@@ -8,6 +8,7 @@ import App from 'App';
ReactDOM.render(<App />, document.getElementById('root')); 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 // 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 // TODO: UPDATE FORMIK TO FIX SETFIELDVALUE TO EMPTY ARRAY ISSUE https://github.com/jaredpalmer/formik/pull/2144
// REFACTOR HTML TO USE SEMANTIC ELEMENTS // REFACTOR HTML TO USE SEMANTIC ELEMENTS

View File

@@ -18,12 +18,19 @@ const defaultProps = {
}; };
const Avatar = ({ className, avatarUrl, name, size, ...otherProps }) => { const Avatar = ({ className, avatarUrl, name, size, ...otherProps }) => {
const sharedProps = {
className,
size,
'data-testid': name ? `avatar:${name}` : 'avatar',
...otherProps,
};
if (avatarUrl) { if (avatarUrl) {
return <Image className={className} size={size} avatarUrl={avatarUrl} {...otherProps} />; return <Image avatarUrl={avatarUrl} {...sharedProps} />;
} }
return ( return (
<Letter className={className} size={size} color={getColorFromName(name)} {...otherProps}> <Letter color={getColorFromName(name)} {...sharedProps}>
<span>{name.charAt(0)}</span> <span>{name.charAt(0)}</span>
</Letter> </Letter>
); );

View File

@@ -47,8 +47,9 @@ const ConfirmModal = ({
return ( return (
<StyledConfirmModal <StyledConfirmModal
withCloseIcon={false}
className={className} className={className}
testid="modal:confirm"
withCloseIcon={false}
renderLink={renderLink} renderLink={renderLink}
renderContent={modal => ( renderContent={modal => (
<> <>

View File

@@ -15,23 +15,29 @@ const propTypes = {
label: PropTypes.string, label: PropTypes.string,
tip: PropTypes.string, tip: PropTypes.string,
error: PropTypes.string, error: PropTypes.string,
name: PropTypes.string,
}; };
const defaultProps = { const defaultProps = {
className: undefined, className: undefined,
label: null, label: undefined,
tip: null, tip: undefined,
error: null, error: undefined,
name: undefined,
}; };
const generateField = FormComponent => { const generateField = FormComponent => {
const FieldComponent = ({ className, label, tip, error, ...otherProps }) => { const FieldComponent = ({ className, label, tip, error, name, ...otherProps }) => {
const fieldId = uniqueId('form-field-'); const fieldId = uniqueId('form-field-');
return ( 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>} {label && <FieldLabel htmlFor={fieldId}>{label}</FieldLabel>}
<FormComponent id={fieldId} invalid={!!error} {...otherProps} /> <FormComponent id={fieldId} invalid={!!error} name={name} {...otherProps} />
{tip && <FieldTip>{tip}</FieldTip>} {tip && <FieldTip>{tip}</FieldTip>}
{error && <FieldError>{error}</FieldError>} {error && <FieldError>{error}</FieldError>}
</StyledField> </StyledField>

View File

@@ -53,7 +53,9 @@ const defaultProps = {
top: 0, 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.propTypes = propTypes;
Icon.defaultProps = defaultProps; Icon.defaultProps = defaultProps;

View File

@@ -9,6 +9,7 @@ import { ScrollOverlay, ClickableOverlay, StyledModal, CloseIcon } from './Style
const propTypes = { const propTypes = {
className: PropTypes.string, className: PropTypes.string,
testid: PropTypes.string,
variant: PropTypes.oneOf(['center', 'aside']), variant: PropTypes.oneOf(['center', 'aside']),
width: PropTypes.number, width: PropTypes.number,
withCloseIcon: PropTypes.bool, withCloseIcon: PropTypes.bool,
@@ -20,6 +21,7 @@ const propTypes = {
const defaultProps = { const defaultProps = {
className: undefined, className: undefined,
testid: 'modal',
variant: 'center', variant: 'center',
width: 600, width: 600,
withCloseIcon: true, withCloseIcon: true,
@@ -30,6 +32,7 @@ const defaultProps = {
const Modal = ({ const Modal = ({
className, className,
testid,
variant, variant,
width, width,
withCloseIcon, withCloseIcon,
@@ -63,9 +66,16 @@ const Modal = ({
{isOpen && {isOpen &&
ReactDOM.createPortal( ReactDOM.createPortal(
<ScrollOverlay data-jira-modal="true"> <ScrollOverlay>
<ClickableOverlay variant={variant} ref={$clickableOverlayRef}> <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} />} {withCloseIcon && <CloseIcon type="close" variant={variant} onClick={closeModal} />}
{renderContent({ close: closeModal })} {renderContent({ close: closeModal })}
</StyledModal> </StyledModal>

View File

@@ -192,6 +192,7 @@ const SelectDropdown = ({
<Option <Option
key={option.value} key={option.value}
data-select-option-value={option.value} data-select-option-value={option.value}
data-testid={`select-option:${option.label}`}
onMouseEnter={handleOptionMouseEnter} onMouseEnter={handleOptionMouseEnter}
onClick={() => selectOptionValue(option.value)} onClick={() => selectOptionValue(option.value)}
> >

View File

@@ -15,6 +15,10 @@ export const StyledSelect = styled.div`
width: 100%; width: 100%;
border: 1px solid ${color.borderLightest}; border: 1px solid ${color.borderLightest};
background: ${color.backgroundLightest}; background: ${color.backgroundLightest};
transition: background 0.1s;
&:hover {
background: ${color.backgroundLight};
}
`} `}
&:focus { &:focus {
outline: none; outline: none;

View File

@@ -20,6 +20,7 @@ const propTypes = {
className: PropTypes.string, className: PropTypes.string,
variant: PropTypes.oneOf(['normal', 'empty']), variant: PropTypes.oneOf(['normal', 'empty']),
dropdownWidth: PropTypes.number, dropdownWidth: PropTypes.number,
name: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]), value: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
defaultValue: PropTypes.any, defaultValue: PropTypes.any,
placeholder: PropTypes.string, placeholder: PropTypes.string,
@@ -37,6 +38,7 @@ const defaultProps = {
className: undefined, className: undefined,
variant: 'normal', variant: 'normal',
dropdownWidth: undefined, dropdownWidth: undefined,
name: undefined,
value: undefined, value: undefined,
defaultValue: undefined, defaultValue: undefined,
placeholder: 'Select', placeholder: 'Select',
@@ -52,6 +54,7 @@ const Select = ({
className, className,
variant, variant,
dropdownWidth, dropdownWidth,
name,
value: propsValue, value: propsValue,
defaultValue, defaultValue,
placeholder, placeholder,
@@ -140,7 +143,11 @@ const Select = ({
onKeyDown={handleFocusedSelectKeydown} onKeyDown={handleFocusedSelectKeydown}
invalid={invalid} invalid={invalid}
> >
<ValueContainer variant={variant} onClick={activateDropdown}> <ValueContainer
variant={variant}
data-testid={name ? `select:${name}` : 'select'}
onClick={activateDropdown}
>
{isValueEmpty && <Placeholder>{placeholder}</Placeholder>} {isValueEmpty && <Placeholder>{placeholder}</Placeholder>}
{!isValueEmpty && !isMulti && propsRenderValue {!isValueEmpty && !isMulti && propsRenderValue

View File

@@ -1,8 +1,11 @@
export const KeyCodes = { export const KeyCodes = {
ESCAPE: 27,
TAB: 9, TAB: 9,
ENTER: 13, ENTER: 13,
ESCAPE: 27,
SPACE: 32,
ARROW_LEFT: 37,
ARROW_UP: 38, ARROW_UP: 38,
ARROW_RIGHT: 39,
ARROW_DOWN: 40, ARROW_DOWN: 40,
M: 77, M: 77,
}; };

View File

@@ -12,7 +12,6 @@ const useQuery = (url, propsVariables = {}, options = {}) => {
data: null, data: null,
error: null, error: null,
isLoading: !lazy, isLoading: !lazy,
wasCalled: !lazy,
variables: {}, variables: {},
}); });

View File

@@ -4,7 +4,7 @@ import useDeepCompareMemoize from 'shared/hooks/deepCompareMemoize';
const useOnOutsideClick = ( const useOnOutsideClick = (
$ignoredElementRefs, $ignoredElementRefs,
shouldListen, isListening,
onOutsideClick, onOutsideClick,
$listeningElementRef, $listeningElementRef,
) => { ) => {
@@ -17,19 +17,19 @@ const useOnOutsideClick = (
}; };
const handleMouseUp = event => { const handleMouseUp = event => {
const isAnyIgnoredElementParentOfTarget = $ignoredElementRefsMemoized.some( const isAnyIgnoredElementAncestorOfTarget = $ignoredElementRefsMemoized.some(
$elementRef => $elementRef =>
$elementRef.current.contains($mouseDownTargetRef.current) || $elementRef.current.contains($mouseDownTargetRef.current) ||
$elementRef.current.contains(event.target), $elementRef.current.contains(event.target),
); );
if (event.button === 0 && !isAnyIgnoredElementParentOfTarget) { if (event.button === 0 && !isAnyIgnoredElementAncestorOfTarget) {
onOutsideClick(); onOutsideClick();
} }
}; };
const $listeningElement = ($listeningElementRef || {}).current || document; const $listeningElement = ($listeningElementRef || {}).current || document;
if (shouldListen) { if (isListening) {
$listeningElement.addEventListener('mousedown', handleMouseDown); $listeningElement.addEventListener('mousedown', handleMouseDown);
$listeningElement.addEventListener('mouseup', handleMouseUp); $listeningElement.addEventListener('mouseup', handleMouseUp);
} }
@@ -38,7 +38,7 @@ const useOnOutsideClick = (
$listeningElement.removeEventListener('mousedown', handleMouseDown); $listeningElement.removeEventListener('mousedown', handleMouseDown);
$listeningElement.removeEventListener('mouseup', handleMouseUp); $listeningElement.removeEventListener('mouseup', handleMouseUp);
}; };
}, [$ignoredElementRefsMemoized, $listeningElementRef, shouldListen, onOutsideClick]); }, [$ignoredElementRefsMemoized, $listeningElementRef, isListening, onOutsideClick]);
}; };
export default useOnOutsideClick; export default useOnOutsideClick;