├── .gitignore ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── api ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .prettierrc ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── constants │ │ ├── issues.ts │ │ └── projects.ts │ ├── controllers │ │ ├── authentication.ts │ │ ├── comments.ts │ │ ├── issues.ts │ │ ├── projects.ts │ │ ├── test.ts │ │ └── users.ts │ ├── database │ │ ├── createConnection.ts │ │ ├── createGuestAccount.ts │ │ ├── createTestAccount.ts │ │ └── resetDatabase.ts │ ├── entities │ │ ├── Comment.ts │ │ ├── Issue.ts │ │ ├── Project.ts │ │ ├── User.ts │ │ └── index.ts │ ├── errors │ │ ├── asyncCatch.ts │ │ ├── customErrors.ts │ │ └── index.ts │ ├── index.ts │ ├── middleware │ │ ├── authentication.ts │ │ ├── errors.ts │ │ └── response.ts │ ├── routes.ts │ ├── serializers │ │ └── issues.ts │ ├── types │ │ ├── env.d.ts │ │ └── express.d.ts │ └── utils │ │ ├── authToken.ts │ │ ├── typeorm.ts │ │ └── validation.ts ├── tsconfig-paths.js └── tsconfig.json ├── client ├── .babelrc ├── .eslintrc.json ├── .prettierrc ├── README.md ├── cypress.json ├── cypress │ ├── .eslintrc.json │ ├── integration │ │ ├── authentication.spec.js │ │ ├── issueCreate.spec.js │ │ ├── issueDetails.spec.js │ │ ├── issueFilters.spec.js │ │ ├── issueSearch.spec.js │ │ ├── issuesDragDrop.spec.js │ │ └── projectSettings.spec.js │ ├── plugins │ │ └── index.js │ └── support │ │ ├── commands.js │ │ ├── index.js │ │ └── utils.js ├── jest.config.js ├── jest │ ├── fileMock.js │ └── styleMock.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── server.js ├── src │ ├── App │ │ ├── BaseStyles.js │ │ ├── NormalizeStyles.js │ │ ├── Routes.jsx │ │ ├── Toast │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── assets │ │ │ └── fonts │ │ │ │ ├── CircularStd-Black.eot │ │ │ │ ├── CircularStd-Black.otf │ │ │ │ ├── CircularStd-Black.svg │ │ │ │ ├── CircularStd-Black.ttf │ │ │ │ ├── CircularStd-Black.woff │ │ │ │ ├── CircularStd-Black.woff2 │ │ │ │ ├── CircularStd-Bold.eot │ │ │ │ ├── CircularStd-Bold.otf │ │ │ │ ├── CircularStd-Bold.svg │ │ │ │ ├── CircularStd-Bold.ttf │ │ │ │ ├── CircularStd-Bold.woff │ │ │ │ ├── CircularStd-Bold.woff2 │ │ │ │ ├── CircularStd-Book.eot │ │ │ │ ├── CircularStd-Book.otf │ │ │ │ ├── CircularStd-Book.svg │ │ │ │ ├── CircularStd-Book.ttf │ │ │ │ ├── CircularStd-Book.woff │ │ │ │ ├── CircularStd-Book.woff2 │ │ │ │ ├── CircularStd-Medium.eot │ │ │ │ ├── CircularStd-Medium.otf │ │ │ │ ├── CircularStd-Medium.svg │ │ │ │ ├── CircularStd-Medium.ttf │ │ │ │ ├── CircularStd-Medium.woff │ │ │ │ ├── CircularStd-Medium.woff2 │ │ │ │ ├── jira.svg │ │ │ │ ├── jira.ttf │ │ │ │ └── jira.woff │ │ ├── fontStyles.css │ │ └── index.jsx │ ├── Auth │ │ └── Authenticate.jsx │ ├── Project │ │ ├── Board │ │ │ ├── Filters │ │ │ │ ├── Styles.js │ │ │ │ └── index.jsx │ │ │ ├── Header │ │ │ │ ├── Styles.js │ │ │ │ └── index.jsx │ │ │ ├── IssueDetails │ │ │ │ ├── AssigneesReporter │ │ │ │ │ ├── Styles.js │ │ │ │ │ └── index.jsx │ │ │ │ ├── Comments │ │ │ │ │ ├── BodyForm │ │ │ │ │ │ ├── Styles.js │ │ │ │ │ │ └── index.jsx │ │ │ │ │ ├── Comment │ │ │ │ │ │ ├── Styles.js │ │ │ │ │ │ └── index.jsx │ │ │ │ │ ├── Create │ │ │ │ │ │ ├── ProTip │ │ │ │ │ │ │ ├── Styles.js │ │ │ │ │ │ │ └── index.jsx │ │ │ │ │ │ ├── Styles.js │ │ │ │ │ │ └── index.jsx │ │ │ │ │ ├── Styles.js │ │ │ │ │ └── index.jsx │ │ │ │ ├── Dates │ │ │ │ │ ├── Styles.js │ │ │ │ │ └── index.jsx │ │ │ │ ├── Delete.jsx │ │ │ │ ├── Description │ │ │ │ │ ├── Styles.js │ │ │ │ │ └── index.jsx │ │ │ │ ├── EstimateTracking │ │ │ │ │ ├── Styles.js │ │ │ │ │ ├── TrackingWidget │ │ │ │ │ │ ├── Styles.js │ │ │ │ │ │ └── index.jsx │ │ │ │ │ └── index.jsx │ │ │ │ ├── Loader.jsx │ │ │ │ ├── Priority │ │ │ │ │ ├── Styles.js │ │ │ │ │ └── index.jsx │ │ │ │ ├── Status │ │ │ │ │ ├── Styles.js │ │ │ │ │ └── index.jsx │ │ │ │ ├── Styles.js │ │ │ │ ├── Title │ │ │ │ │ ├── Styles.js │ │ │ │ │ └── index.jsx │ │ │ │ ├── Type │ │ │ │ │ ├── Styles.js │ │ │ │ │ └── index.jsx │ │ │ │ └── index.jsx │ │ │ ├── Lists │ │ │ │ ├── List │ │ │ │ │ ├── Issue │ │ │ │ │ │ ├── Styles.js │ │ │ │ │ │ └── index.jsx │ │ │ │ │ ├── Styles.js │ │ │ │ │ └── index.jsx │ │ │ │ ├── Styles.js │ │ │ │ └── index.jsx │ │ │ └── index.jsx │ │ ├── IssueCreate │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── IssueSearch │ │ │ ├── NoResultsSvg.jsx │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── NavbarLeft │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── ProjectSettings │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── Sidebar │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── Styles.js │ │ └── index.jsx │ ├── browserHistory.js │ ├── favicon.png │ ├── index.html │ ├── index.jsx │ └── shared │ │ ├── components │ │ ├── AboutTooltip │ │ │ ├── Styles.js │ │ │ ├── assets │ │ │ │ └── feedback.png │ │ │ └── index.jsx │ │ ├── Avatar │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── Breadcrumbs │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── Button │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── ConfirmModal │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── CopyLinkButton.jsx │ │ ├── DatePicker │ │ │ ├── DateSection.jsx │ │ │ ├── Styles.js │ │ │ ├── TimeSection.jsx │ │ │ └── index.jsx │ │ ├── Form │ │ │ ├── Field.jsx │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── Icon │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── Input │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── InputDebounced.jsx │ │ ├── IssuePriorityIcon │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── IssueTypeIcon │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── Logo.jsx │ │ ├── Modal │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── PageError │ │ │ ├── Styles.js │ │ │ ├── assets │ │ │ │ └── background-forest.jpg │ │ │ └── index.jsx │ │ ├── PageLoader │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── ProjectAvatar.jsx │ │ ├── Select │ │ │ ├── Dropdown.jsx │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── Spinner.jsx │ │ ├── TextEditedContent │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── TextEditor │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── Textarea │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ ├── Tooltip │ │ │ ├── Styles.js │ │ │ └── index.jsx │ │ └── index.js │ │ ├── constants │ │ ├── issues.js │ │ ├── keyCodes.js │ │ └── projects.js │ │ ├── hooks │ │ ├── api │ │ │ ├── index.js │ │ │ ├── mutation.js │ │ │ └── query.js │ │ ├── currentUser.js │ │ ├── deepCompareMemoize.js │ │ ├── mergeState.js │ │ ├── onEscapeKeyDown.js │ │ └── onOutsideClick.js │ │ └── utils │ │ ├── api.js │ │ ├── authToken.js │ │ ├── browser.js │ │ ├── dateTime.js │ │ ├── javascript.js │ │ ├── queryParamModal.js │ │ ├── styles.js │ │ ├── toast.js │ │ ├── url.js │ │ └── validation.js ├── webpack.config.js ├── webpack.config.production.js └── yarn.lock ├── package-lock.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | **/node_modules 3 | 4 | # misc 5 | **/.DS_Store 6 | 7 | # environment config 8 | **/.env 9 | 10 | # production 11 | **/build 12 | **/npm-debug.log* 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "directory": "./client", 5 | "changeProcessCWD": true 6 | }, 7 | { 8 | "directory": "./api", 9 | "changeProcessCWD": true 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | I will not be accepting PR's on this repository. Feel free to fork and maintain your own version. 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-present, Yuxi (Evan) You 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /api/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | DB_HOST=localhost 3 | DB_PORT=5432 4 | DB_USERNAME=your_database_username 5 | DB_PASSWORD=your_database_password 6 | DB_DATABASE=jira_development 7 | JWT_SECRET=development12345 8 | -------------------------------------------------------------------------------- /api/.eslintignore: -------------------------------------------------------------------------------- 1 | build/* 2 | tsconfig-paths.js 3 | -------------------------------------------------------------------------------- /api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json", 5 | "sourceType": "module", 6 | "ecmaVersion": 8 7 | }, 8 | "plugins": ["@typescript-eslint"], 9 | "env": { 10 | "node": true 11 | }, 12 | "extends": [ 13 | "airbnb-base", 14 | "plugin:import/typescript", 15 | "plugin:@typescript-eslint/recommended", 16 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 17 | "plugin:prettier/recommended", 18 | "prettier/@typescript-eslint" 19 | ], 20 | "rules": { 21 | "radix": 0, 22 | "no-restricted-syntax": 0, 23 | "no-await-in-loop": 0, 24 | "no-console": 0, 25 | "consistent-return": 0, 26 | "@typescript-eslint/no-unused-vars": 0, 27 | "@typescript-eslint/no-use-before-define": 0, 28 | "@typescript-eslint/no-explicit-any": 0, 29 | "import/prefer-default-export": 0, 30 | "import/no-cycle": 0 31 | }, 32 | "settings": { 33 | // Allows us to lint absolute imports within codebase 34 | "import/resolver": { 35 | "node": { 36 | "moduleDirectory": ["node_modules", "src/"] 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /api/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jira_api", 3 | "version": "1.0.0", 4 | "author": "Ivor Reic", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "nodemon --exec ts-node --files src/index.ts", 8 | "start:test": "cross-env NODE_ENV='test' DB_DATABASE='jira_test' npm start", 9 | "start:production": "pm2 start --name 'jira_api' node -- -r ./tsconfig-paths.js build/index.js", 10 | "build": "cd src && tsc", 11 | "pre-commit": "lint-staged" 12 | }, 13 | "dependencies": { 14 | "cors": "^2.8.5", 15 | "dotenv": "^8.2.0", 16 | "express": "^4.17.1", 17 | "express-async-handler": "^1.1.4", 18 | "faker": "^4.1.0", 19 | "jsonwebtoken": "^8.5.1", 20 | "lodash": "^4.17.15", 21 | "module-alias": "^2.2.2", 22 | "pg": "^7.14.0", 23 | "reflect-metadata": "^0.1.13", 24 | "striptags": "^3.1.1", 25 | "typeorm": "^0.2.20" 26 | }, 27 | "devDependencies": { 28 | "@types/cors": "^2.8.6", 29 | "@types/express": "^4.17.2", 30 | "@types/faker": "^4.1.7", 31 | "@types/jsonapi-serializer": "^3.6.2", 32 | "@types/jsonwebtoken": "^8.3.5", 33 | "@types/lodash": "^4.14.149", 34 | "@types/node": "^12.12.11", 35 | "@typescript-eslint/eslint-plugin": "^2.7.0", 36 | "@typescript-eslint/parser": "^2.7.0", 37 | "cross-env": "^6.0.3", 38 | "eslint": "^6.1.0", 39 | "eslint-config-airbnb-base": "^14.0.0", 40 | "eslint-config-prettier": "^6.7.0", 41 | "eslint-plugin-import": "^2.18.2", 42 | "eslint-plugin-prettier": "^3.1.1", 43 | "lint-staged": "^9.4.3", 44 | "nodemon": "^2.0.0", 45 | "prettier": "^1.19.1", 46 | "ts-node": "^8.5.2", 47 | "tsconfig-paths": "^3.9.0", 48 | "typescript": "^3.7.2" 49 | }, 50 | "_moduleDirectories": [ 51 | "src" 52 | ], 53 | "lint-staged": { 54 | "*.ts": [ 55 | "eslint --fix", 56 | "prettier --write", 57 | "git add" 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /api/src/constants/issues.ts: -------------------------------------------------------------------------------- 1 | export enum IssueType { 2 | TASK = 'task', 3 | BUG = 'bug', 4 | STORY = 'story', 5 | } 6 | 7 | export enum IssueStatus { 8 | BACKLOG = 'backlog', 9 | SELECTED = 'selected', 10 | INPROGRESS = 'inprogress', 11 | DONE = 'done', 12 | } 13 | 14 | export enum IssuePriority { 15 | HIGHEST = '5', 16 | HIGH = '4', 17 | MEDIUM = '3', 18 | LOW = '2', 19 | LOWEST = '1', 20 | } 21 | -------------------------------------------------------------------------------- /api/src/constants/projects.ts: -------------------------------------------------------------------------------- 1 | export enum ProjectCategory { 2 | SOFTWARE = 'software', 3 | MARKETING = 'marketing', 4 | BUSINESS = 'business', 5 | } 6 | -------------------------------------------------------------------------------- /api/src/controllers/authentication.ts: -------------------------------------------------------------------------------- 1 | import { catchErrors } from 'errors'; 2 | import { signToken } from 'utils/authToken'; 3 | import createAccount from 'database/createGuestAccount'; 4 | 5 | export const createGuestAccount = catchErrors(async (_req, res) => { 6 | const user = await createAccount(); 7 | res.respond({ 8 | authToken: signToken({ sub: user.id }), 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /api/src/controllers/comments.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from 'entities'; 2 | import { catchErrors } from 'errors'; 3 | import { updateEntity, deleteEntity, createEntity } from 'utils/typeorm'; 4 | 5 | export const create = catchErrors(async (req, res) => { 6 | const comment = await createEntity(Comment, req.body); 7 | res.respond({ comment }); 8 | }); 9 | 10 | export const update = catchErrors(async (req, res) => { 11 | const comment = await updateEntity(Comment, req.params.commentId, req.body); 12 | res.respond({ comment }); 13 | }); 14 | 15 | export const remove = catchErrors(async (req, res) => { 16 | const comment = await deleteEntity(Comment, req.params.commentId); 17 | res.respond({ comment }); 18 | }); 19 | -------------------------------------------------------------------------------- /api/src/controllers/issues.ts: -------------------------------------------------------------------------------- 1 | import { Issue } from 'entities'; 2 | import { catchErrors } from 'errors'; 3 | import { updateEntity, deleteEntity, createEntity, findEntityOrThrow } from 'utils/typeorm'; 4 | 5 | export const getProjectIssues = catchErrors(async (req, res) => { 6 | const { projectId } = req.currentUser; 7 | const { searchTerm } = req.query; 8 | 9 | let whereSQL = 'issue.projectId = :projectId'; 10 | 11 | if (searchTerm) { 12 | whereSQL += ' AND (issue.title ILIKE :searchTerm OR issue.descriptionText ILIKE :searchTerm)'; 13 | } 14 | 15 | const issues = await Issue.createQueryBuilder('issue') 16 | .select() 17 | .where(whereSQL, { projectId, searchTerm: `%${searchTerm}%` }) 18 | .getMany(); 19 | 20 | res.respond({ issues }); 21 | }); 22 | 23 | export const getIssueWithUsersAndComments = catchErrors(async (req, res) => { 24 | const issue = await findEntityOrThrow(Issue, req.params.issueId, { 25 | relations: ['users', 'comments', 'comments.user'], 26 | }); 27 | res.respond({ issue }); 28 | }); 29 | 30 | export const create = catchErrors(async (req, res) => { 31 | const listPosition = await calculateListPosition(req.body); 32 | const issue = await createEntity(Issue, { ...req.body, listPosition }); 33 | res.respond({ issue }); 34 | }); 35 | 36 | export const update = catchErrors(async (req, res) => { 37 | const issue = await updateEntity(Issue, req.params.issueId, req.body); 38 | res.respond({ issue }); 39 | }); 40 | 41 | export const remove = catchErrors(async (req, res) => { 42 | const issue = await deleteEntity(Issue, req.params.issueId); 43 | res.respond({ issue }); 44 | }); 45 | 46 | const calculateListPosition = async ({ projectId, status }: Issue): Promise => { 47 | const issues = await Issue.find({ projectId, status }); 48 | 49 | const listPositions = issues.map(({ listPosition }) => listPosition); 50 | 51 | if (listPositions.length > 0) { 52 | return Math.min(...listPositions) - 1; 53 | } 54 | return 1; 55 | }; 56 | -------------------------------------------------------------------------------- /api/src/controllers/projects.ts: -------------------------------------------------------------------------------- 1 | import { Project } from 'entities'; 2 | import { catchErrors } from 'errors'; 3 | import { findEntityOrThrow, updateEntity } from 'utils/typeorm'; 4 | import { issuePartial } from 'serializers/issues'; 5 | 6 | export const getProjectWithUsersAndIssues = catchErrors(async (req, res) => { 7 | const project = await findEntityOrThrow(Project, req.currentUser.projectId, { 8 | relations: ['users', 'issues'], 9 | }); 10 | res.respond({ 11 | project: { 12 | ...project, 13 | issues: project.issues.map(issuePartial), 14 | }, 15 | }); 16 | }); 17 | 18 | export const update = catchErrors(async (req, res) => { 19 | const project = await updateEntity(Project, req.currentUser.projectId, req.body); 20 | res.respond({ project }); 21 | }); 22 | -------------------------------------------------------------------------------- /api/src/controllers/test.ts: -------------------------------------------------------------------------------- 1 | import { catchErrors } from 'errors'; 2 | import { signToken } from 'utils/authToken'; 3 | import resetTestDatabase from 'database/resetDatabase'; 4 | import createTestAccount from 'database/createTestAccount'; 5 | 6 | export const resetDatabase = catchErrors(async (_req, res) => { 7 | await resetTestDatabase(); 8 | res.respond(true); 9 | }); 10 | 11 | export const createAccount = catchErrors(async (_req, res) => { 12 | const user = await createTestAccount(); 13 | res.respond({ 14 | authToken: signToken({ sub: user.id }), 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /api/src/controllers/users.ts: -------------------------------------------------------------------------------- 1 | import { catchErrors } from 'errors'; 2 | 3 | export const getCurrentUser = catchErrors((req, res) => { 4 | res.respond({ currentUser: req.currentUser }); 5 | }); 6 | -------------------------------------------------------------------------------- /api/src/database/createConnection.ts: -------------------------------------------------------------------------------- 1 | import { createConnection, Connection } from 'typeorm'; 2 | 3 | import * as entities from 'entities'; 4 | 5 | const createDatabaseConnection = (): Promise => 6 | createConnection({ 7 | type: 'postgres', 8 | host: process.env.DB_HOST, 9 | port: Number(process.env.DB_PORT), 10 | username: process.env.DB_USERNAME, 11 | password: process.env.DB_PASSWORD, 12 | database: process.env.DB_DATABASE, 13 | entities: Object.values(entities), 14 | synchronize: true, 15 | }); 16 | 17 | export default createDatabaseConnection; 18 | -------------------------------------------------------------------------------- /api/src/database/createTestAccount.ts: -------------------------------------------------------------------------------- 1 | import { Comment, Issue, Project, User } from 'entities'; 2 | import { ProjectCategory } from 'constants/projects'; 3 | import { IssueType, IssueStatus, IssuePriority } from 'constants/issues'; 4 | import { createEntity } from 'utils/typeorm'; 5 | 6 | const seedUsers = (): Promise => { 7 | const users = [ 8 | createEntity(User, { 9 | email: 'gaben@jira.test', 10 | name: 'Gaben', 11 | avatarUrl: 'https://i.ibb.co/6RJ5hq6/gaben.jpg', 12 | }), 13 | createEntity(User, { 14 | email: 'yoda@jira.test', 15 | name: 'Yoda', 16 | avatarUrl: 'https://i.ibb.co/6n0hLML/baby-yoda.jpg', 17 | }), 18 | ]; 19 | return Promise.all(users); 20 | }; 21 | 22 | const seedProject = (users: User[]): Promise => 23 | createEntity(Project, { 24 | name: 'Project name', 25 | url: 'https://www.testurl.com', 26 | description: 'Project description', 27 | category: ProjectCategory.SOFTWARE, 28 | users, 29 | }); 30 | 31 | const seedIssues = (project: Project): Promise => { 32 | const { users } = project; 33 | 34 | const issues = [ 35 | createEntity(Issue, { 36 | title: 'Issue title 1', 37 | type: IssueType.TASK, 38 | status: IssueStatus.BACKLOG, 39 | priority: IssuePriority.LOWEST, 40 | listPosition: 1, 41 | reporterId: users[0].id, 42 | project, 43 | }), 44 | createEntity(Issue, { 45 | title: 'Issue title 2', 46 | type: IssueType.TASK, 47 | status: IssueStatus.BACKLOG, 48 | priority: IssuePriority.MEDIUM, 49 | listPosition: 2, 50 | estimate: 5, 51 | description: 'Issue description 2', 52 | reporterId: users[0].id, 53 | users: [users[0]], 54 | project, 55 | }), 56 | createEntity(Issue, { 57 | title: 'Issue title 3', 58 | type: IssueType.STORY, 59 | status: IssueStatus.SELECTED, 60 | priority: IssuePriority.HIGH, 61 | listPosition: 3, 62 | estimate: 10, 63 | description: 'Issue description 3', 64 | reporterId: users[0].id, 65 | users: [users[0], users[1]], 66 | project, 67 | }), 68 | ]; 69 | return Promise.all(issues); 70 | }; 71 | 72 | const seedComments = (issue: Issue, user: User): Promise => 73 | createEntity(Comment, { 74 | body: 'Comment body', 75 | issueId: issue.id, 76 | userId: user.id, 77 | }); 78 | 79 | const createTestAccount = async (): Promise => { 80 | const users = await seedUsers(); 81 | const project = await seedProject(users); 82 | const issues = await seedIssues(project); 83 | await seedComments(issues[0], project.users[0]); 84 | return users[0]; 85 | }; 86 | 87 | export default createTestAccount; 88 | -------------------------------------------------------------------------------- /api/src/database/resetDatabase.ts: -------------------------------------------------------------------------------- 1 | import { getConnection } from 'typeorm'; 2 | 3 | const resetDatabase = async (): Promise => { 4 | const connection = getConnection(); 5 | await connection.dropDatabase(); 6 | await connection.synchronize(); 7 | }; 8 | 9 | export default resetDatabase; 10 | -------------------------------------------------------------------------------- /api/src/entities/Comment.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Entity, 4 | Column, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | ManyToOne, 9 | } from 'typeorm'; 10 | 11 | import is from 'utils/validation'; 12 | import { Issue, User } from '.'; 13 | 14 | @Entity() 15 | class Comment extends BaseEntity { 16 | static validations = { 17 | body: [is.required(), is.maxLength(50000)], 18 | }; 19 | 20 | @PrimaryGeneratedColumn() 21 | id: number; 22 | 23 | @Column('text') 24 | body: string; 25 | 26 | @CreateDateColumn({ type: 'timestamp' }) 27 | createdAt: Date; 28 | 29 | @UpdateDateColumn({ type: 'timestamp' }) 30 | updatedAt: Date; 31 | 32 | @ManyToOne( 33 | () => User, 34 | user => user.comments, 35 | ) 36 | user: User; 37 | 38 | @Column('integer') 39 | userId: number; 40 | 41 | @ManyToOne( 42 | () => Issue, 43 | issue => issue.comments, 44 | { onDelete: 'CASCADE' }, 45 | ) 46 | issue: Issue; 47 | 48 | @Column('integer') 49 | issueId: number; 50 | } 51 | 52 | export default Comment; 53 | -------------------------------------------------------------------------------- /api/src/entities/Issue.ts: -------------------------------------------------------------------------------- 1 | import striptags from 'striptags'; 2 | import { 3 | BaseEntity, 4 | Entity, 5 | Column, 6 | PrimaryGeneratedColumn, 7 | CreateDateColumn, 8 | UpdateDateColumn, 9 | ManyToOne, 10 | OneToMany, 11 | ManyToMany, 12 | JoinTable, 13 | RelationId, 14 | BeforeUpdate, 15 | BeforeInsert, 16 | } from 'typeorm'; 17 | 18 | import is from 'utils/validation'; 19 | import { IssueType, IssueStatus, IssuePriority } from 'constants/issues'; 20 | import { Comment, Project, User } from '.'; 21 | 22 | @Entity() 23 | class Issue extends BaseEntity { 24 | static validations = { 25 | title: [is.required(), is.maxLength(200)], 26 | type: [is.required(), is.oneOf(Object.values(IssueType))], 27 | status: [is.required(), is.oneOf(Object.values(IssueStatus))], 28 | priority: [is.required(), is.oneOf(Object.values(IssuePriority))], 29 | listPosition: is.required(), 30 | reporterId: is.required(), 31 | }; 32 | 33 | @PrimaryGeneratedColumn() 34 | id: number; 35 | 36 | @Column('varchar') 37 | title: string; 38 | 39 | @Column('varchar') 40 | type: IssueType; 41 | 42 | @Column('varchar') 43 | status: IssueStatus; 44 | 45 | @Column('varchar') 46 | priority: IssuePriority; 47 | 48 | @Column('double precision') 49 | listPosition: number; 50 | 51 | @Column('text', { nullable: true }) 52 | description: string | null; 53 | 54 | @Column('text', { nullable: true }) 55 | descriptionText: string | null; 56 | 57 | @Column('integer', { nullable: true }) 58 | estimate: number | null; 59 | 60 | @Column('integer', { nullable: true }) 61 | timeSpent: number | null; 62 | 63 | @Column('integer', { nullable: true }) 64 | timeRemaining: number | null; 65 | 66 | @CreateDateColumn({ type: 'timestamp' }) 67 | createdAt: Date; 68 | 69 | @UpdateDateColumn({ type: 'timestamp' }) 70 | updatedAt: Date; 71 | 72 | @Column('integer') 73 | reporterId: number; 74 | 75 | @ManyToOne( 76 | () => Project, 77 | project => project.issues, 78 | ) 79 | project: Project; 80 | 81 | @Column('integer') 82 | projectId: number; 83 | 84 | @OneToMany( 85 | () => Comment, 86 | comment => comment.issue, 87 | ) 88 | comments: Comment[]; 89 | 90 | @ManyToMany( 91 | () => User, 92 | user => user.issues, 93 | ) 94 | @JoinTable() 95 | users: User[]; 96 | 97 | @RelationId((issue: Issue) => issue.users) 98 | userIds: number[]; 99 | 100 | @BeforeInsert() 101 | @BeforeUpdate() 102 | setDescriptionText = (): void => { 103 | if (this.description) { 104 | this.descriptionText = striptags(this.description); 105 | } 106 | }; 107 | } 108 | 109 | export default Issue; 110 | -------------------------------------------------------------------------------- /api/src/entities/Project.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Entity, 4 | Column, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | OneToMany, 9 | } from 'typeorm'; 10 | 11 | import is from 'utils/validation'; 12 | import { ProjectCategory } from 'constants/projects'; 13 | import { Issue, User } from '.'; 14 | 15 | @Entity() 16 | class Project extends BaseEntity { 17 | static validations = { 18 | name: [is.required(), is.maxLength(100)], 19 | url: is.url(), 20 | category: [is.required(), is.oneOf(Object.values(ProjectCategory))], 21 | }; 22 | 23 | @PrimaryGeneratedColumn() 24 | id: number; 25 | 26 | @Column('varchar') 27 | name: string; 28 | 29 | @Column('varchar', { nullable: true }) 30 | url: string | null; 31 | 32 | @Column('text', { nullable: true }) 33 | description: string | null; 34 | 35 | @Column('varchar') 36 | category: ProjectCategory; 37 | 38 | @CreateDateColumn({ type: 'timestamp' }) 39 | createdAt: Date; 40 | 41 | @UpdateDateColumn({ type: 'timestamp' }) 42 | updatedAt: Date; 43 | 44 | @OneToMany( 45 | () => Issue, 46 | issue => issue.project, 47 | ) 48 | issues: Issue[]; 49 | 50 | @OneToMany( 51 | () => User, 52 | user => user.project, 53 | ) 54 | users: User[]; 55 | } 56 | 57 | export default Project; 58 | -------------------------------------------------------------------------------- /api/src/entities/User.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Entity, 4 | Column, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | OneToMany, 9 | ManyToMany, 10 | ManyToOne, 11 | RelationId, 12 | } from 'typeorm'; 13 | 14 | import is from 'utils/validation'; 15 | import { Comment, Issue, Project } from '.'; 16 | 17 | @Entity() 18 | class User extends BaseEntity { 19 | static validations = { 20 | name: [is.required(), is.maxLength(100)], 21 | email: [is.required(), is.email(), is.maxLength(200)], 22 | }; 23 | 24 | @PrimaryGeneratedColumn() 25 | id: number; 26 | 27 | @Column('varchar') 28 | name: string; 29 | 30 | @Column('varchar') 31 | email: string; 32 | 33 | @Column('varchar', { length: 2000 }) 34 | avatarUrl: string; 35 | 36 | @CreateDateColumn({ type: 'timestamp' }) 37 | createdAt: Date; 38 | 39 | @UpdateDateColumn({ type: 'timestamp' }) 40 | updatedAt: Date; 41 | 42 | @OneToMany( 43 | () => Comment, 44 | comment => comment.user, 45 | ) 46 | comments: Comment[]; 47 | 48 | @ManyToMany( 49 | () => Issue, 50 | issue => issue.users, 51 | ) 52 | issues: Issue[]; 53 | 54 | @ManyToOne( 55 | () => Project, 56 | project => project.users, 57 | ) 58 | project: Project; 59 | 60 | @RelationId((user: User) => user.project) 61 | projectId: number; 62 | } 63 | 64 | export default User; 65 | -------------------------------------------------------------------------------- /api/src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Comment } from './Comment'; 2 | export { default as Issue } from './Issue'; 3 | export { default as Project } from './Project'; 4 | export { default as User } from './User'; 5 | -------------------------------------------------------------------------------- /api/src/errors/asyncCatch.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | 3 | export const catchErrors = (requestHandler: RequestHandler): RequestHandler => { 4 | return async (req, res, next): Promise => { 5 | try { 6 | return await requestHandler(req, res, next); 7 | } catch (error) { 8 | next(error); 9 | } 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /api/src/errors/customErrors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | 3 | type ErrorData = { [key: string]: any }; 4 | 5 | export class CustomError extends Error { 6 | constructor( 7 | public message: string, 8 | public code: string | number = 'INTERNAL_ERROR', 9 | public status: number = 500, 10 | public data: ErrorData = {}, 11 | ) { 12 | super(); 13 | } 14 | } 15 | 16 | export class RouteNotFoundError extends CustomError { 17 | constructor(originalUrl: string) { 18 | super(`Route '${originalUrl}' does not exist.`, 'ROUTE_NOT_FOUND', 404); 19 | } 20 | } 21 | 22 | export class EntityNotFoundError extends CustomError { 23 | constructor(entityName: string) { 24 | super(`${entityName} not found.`, 'ENTITY_NOT_FOUND', 404); 25 | } 26 | } 27 | 28 | export class BadUserInputError extends CustomError { 29 | constructor(errorData: ErrorData) { 30 | super('There were validation errors.', 'BAD_USER_INPUT', 400, errorData); 31 | } 32 | } 33 | 34 | export class InvalidTokenError extends CustomError { 35 | constructor(message = 'Authentication token is invalid.') { 36 | super(message, 'INVALID_TOKEN', 401); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './customErrors'; 2 | export { catchErrors } from './asyncCatch'; 3 | -------------------------------------------------------------------------------- /api/src/index.ts: -------------------------------------------------------------------------------- 1 | import 'module-alias/register'; 2 | import 'dotenv/config'; 3 | import 'reflect-metadata'; 4 | import express from 'express'; 5 | import cors from 'cors'; 6 | 7 | import createDatabaseConnection from 'database/createConnection'; 8 | import { addRespondToResponse } from 'middleware/response'; 9 | import { authenticateUser } from 'middleware/authentication'; 10 | import { handleError } from 'middleware/errors'; 11 | import { RouteNotFoundError } from 'errors'; 12 | 13 | import { attachPublicRoutes, attachPrivateRoutes } from './routes'; 14 | 15 | const establishDatabaseConnection = async (): Promise => { 16 | try { 17 | await createDatabaseConnection(); 18 | } catch (error) { 19 | console.log(error); 20 | } 21 | }; 22 | 23 | const initializeExpress = (): void => { 24 | const app = express(); 25 | 26 | app.use(cors()); 27 | app.use(express.json()); 28 | app.use(express.urlencoded()); 29 | 30 | app.use(addRespondToResponse); 31 | 32 | attachPublicRoutes(app); 33 | 34 | app.use('/', authenticateUser); 35 | 36 | attachPrivateRoutes(app); 37 | 38 | app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl))); 39 | app.use(handleError); 40 | 41 | app.listen(process.env.PORT || 3000); 42 | }; 43 | 44 | const initializeApp = async (): Promise => { 45 | await establishDatabaseConnection(); 46 | initializeExpress(); 47 | }; 48 | 49 | initializeApp(); 50 | -------------------------------------------------------------------------------- /api/src/middleware/authentication.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | import { verifyToken } from 'utils/authToken'; 4 | import { catchErrors, InvalidTokenError } from 'errors'; 5 | import { User } from 'entities'; 6 | 7 | export const authenticateUser = catchErrors(async (req, _res, next) => { 8 | const token = getAuthTokenFromRequest(req); 9 | if (!token) { 10 | throw new InvalidTokenError('Authentication token not found.'); 11 | } 12 | const userId = verifyToken(token).sub; 13 | if (!userId) { 14 | throw new InvalidTokenError('Authentication token is invalid.'); 15 | } 16 | const user = await User.findOne(userId); 17 | if (!user) { 18 | throw new InvalidTokenError('Authentication token is invalid: User not found.'); 19 | } 20 | req.currentUser = user; 21 | next(); 22 | }); 23 | 24 | const getAuthTokenFromRequest = (req: Request): string | null => { 25 | const header = req.get('Authorization') || ''; 26 | const [bearer, token] = header.split(' '); 27 | return bearer === 'Bearer' && token ? token : null; 28 | }; 29 | -------------------------------------------------------------------------------- /api/src/middleware/errors.ts: -------------------------------------------------------------------------------- 1 | import { ErrorRequestHandler } from 'express'; 2 | import { pick } from 'lodash'; 3 | 4 | import { CustomError } from 'errors'; 5 | 6 | export const handleError: ErrorRequestHandler = (error, _req, res, _next) => { 7 | console.error(error); 8 | 9 | const isErrorSafeForClient = error instanceof CustomError; 10 | 11 | const clientError = isErrorSafeForClient 12 | ? pick(error, ['message', 'code', 'status', 'data']) 13 | : { 14 | message: 'Something went wrong, please contact our support.', 15 | code: 'INTERNAL_ERROR', 16 | status: 500, 17 | data: {}, 18 | }; 19 | 20 | res.status(clientError.status).send({ error: clientError }); 21 | }; 22 | -------------------------------------------------------------------------------- /api/src/middleware/response.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | 3 | export const addRespondToResponse: RequestHandler = (_req, res, next) => { 4 | res.respond = (data): void => { 5 | res.status(200).send(data); 6 | }; 7 | next(); 8 | }; 9 | -------------------------------------------------------------------------------- /api/src/routes.ts: -------------------------------------------------------------------------------- 1 | import * as authentication from 'controllers/authentication'; 2 | import * as comments from 'controllers/comments'; 3 | import * as issues from 'controllers/issues'; 4 | import * as projects from 'controllers/projects'; 5 | import * as test from 'controllers/test'; 6 | import * as users from 'controllers/users'; 7 | 8 | export const attachPublicRoutes = (app: any): void => { 9 | if (process.env.NODE_ENV === 'test') { 10 | app.delete('/test/reset-database', test.resetDatabase); 11 | app.post('/test/create-account', test.createAccount); 12 | } 13 | 14 | app.post('/authentication/guest', authentication.createGuestAccount); 15 | }; 16 | 17 | export const attachPrivateRoutes = (app: any): void => { 18 | app.post('/comments', comments.create); 19 | app.put('/comments/:commentId', comments.update); 20 | app.delete('/comments/:commentId', comments.remove); 21 | 22 | app.get('/issues', issues.getProjectIssues); 23 | app.get('/issues/:issueId', issues.getIssueWithUsersAndComments); 24 | app.post('/issues', issues.create); 25 | app.put('/issues/:issueId', issues.update); 26 | app.delete('/issues/:issueId', issues.remove); 27 | 28 | app.get('/project', projects.getProjectWithUsersAndIssues); 29 | app.put('/project', projects.update); 30 | 31 | app.get('/currentUser', users.getCurrentUser); 32 | }; 33 | -------------------------------------------------------------------------------- /api/src/serializers/issues.ts: -------------------------------------------------------------------------------- 1 | import { pick } from 'lodash'; 2 | 3 | import { Issue } from 'entities'; 4 | 5 | export const issuePartial = (issue: Issue): Partial => 6 | pick(issue, [ 7 | 'id', 8 | 'title', 9 | 'type', 10 | 'status', 11 | 'priority', 12 | 'listPosition', 13 | 'createdAt', 14 | 'updatedAt', 15 | 'userIds', 16 | ]); 17 | -------------------------------------------------------------------------------- /api/src/types/env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | export interface ProcessEnv { 3 | DB_HOST: string; 4 | DB_PORT: string; 5 | DB_USERNAME: string; 6 | DB_PASSWORD: string; 7 | DB_DATABASE: string; 8 | JWT_SECRET: string; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /api/src/types/express.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | export interface Response { 3 | respond: (data: any) => void; 4 | } 5 | export interface Request { 6 | currentUser: import('entities').User; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /api/src/utils/authToken.ts: -------------------------------------------------------------------------------- 1 | import jwt, { SignOptions } from 'jsonwebtoken'; 2 | import { isPlainObject } from 'lodash'; 3 | 4 | import { InvalidTokenError } from 'errors'; 5 | 6 | export const signToken = (payload: object, options?: SignOptions): string => 7 | jwt.sign(payload, process.env.JWT_SECRET, { 8 | expiresIn: '180 days', 9 | ...options, 10 | }); 11 | 12 | export const verifyToken = (token: string): { [key: string]: any } => { 13 | try { 14 | const payload = jwt.verify(token, process.env.JWT_SECRET); 15 | 16 | if (isPlainObject(payload)) { 17 | return payload as { [key: string]: any }; 18 | } 19 | throw new Error(); 20 | } catch (error) { 21 | throw new InvalidTokenError(); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /api/src/utils/typeorm.ts: -------------------------------------------------------------------------------- 1 | import { FindOneOptions } from 'typeorm/find-options/FindOneOptions'; 2 | 3 | import { Project, User, Issue, Comment } from 'entities'; 4 | import { EntityNotFoundError, BadUserInputError } from 'errors'; 5 | import { generateErrors } from 'utils/validation'; 6 | 7 | type EntityConstructor = typeof Project | typeof User | typeof Issue | typeof Comment; 8 | type EntityInstance = Project | User | Issue | Comment; 9 | 10 | const entities: { [key: string]: EntityConstructor } = { Comment, Issue, Project, User }; 11 | 12 | export const findEntityOrThrow = async ( 13 | Constructor: T, 14 | id: number | string, 15 | options?: FindOneOptions, 16 | ): Promise> => { 17 | const instance = await Constructor.findOne(id, options); 18 | if (!instance) { 19 | throw new EntityNotFoundError(Constructor.name); 20 | } 21 | return instance; 22 | }; 23 | 24 | export const validateAndSaveEntity = async (instance: T): Promise => { 25 | const Constructor = entities[instance.constructor.name]; 26 | 27 | if ('validations' in Constructor) { 28 | const errorFields = generateErrors(instance, Constructor.validations); 29 | 30 | if (Object.keys(errorFields).length > 0) { 31 | throw new BadUserInputError({ fields: errorFields }); 32 | } 33 | } 34 | return instance.save() as Promise; 35 | }; 36 | 37 | export const createEntity = async ( 38 | Constructor: T, 39 | input: Partial>, 40 | ): Promise> => { 41 | const instance = Constructor.create(input); 42 | return validateAndSaveEntity(instance as InstanceType); 43 | }; 44 | 45 | export const updateEntity = async ( 46 | Constructor: T, 47 | id: number | string, 48 | input: Partial>, 49 | ): Promise> => { 50 | const instance = await findEntityOrThrow(Constructor, id); 51 | Object.assign(instance, input); 52 | return validateAndSaveEntity(instance); 53 | }; 54 | 55 | export const deleteEntity = async ( 56 | Constructor: T, 57 | id: number | string, 58 | ): Promise> => { 59 | const instance = await findEntityOrThrow(Constructor, id); 60 | await instance.remove(); 61 | return instance; 62 | }; 63 | -------------------------------------------------------------------------------- /api/src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | type Value = any; 2 | type ErrorMessage = false | string; 3 | type FieldValues = { [key: string]: Value }; 4 | type Validator = (value: Value, fieldValues?: FieldValues) => ErrorMessage; 5 | type FieldValidators = { [key: string]: Validator | Validator[] }; 6 | type FieldErrors = { [key: string]: string }; 7 | 8 | const is = { 9 | match: (testFn: Function, message = '') => ( 10 | value: Value, 11 | fieldValues: FieldValues, 12 | ): ErrorMessage => !testFn(value, fieldValues) && message, 13 | 14 | required: () => (value: Value): ErrorMessage => 15 | isNilOrEmptyString(value) && 'This field is required', 16 | 17 | minLength: (min: number) => (value: Value): ErrorMessage => 18 | !!value && value.length < min && `Must be at least ${min} characters`, 19 | 20 | maxLength: (max: number) => (value: Value): ErrorMessage => 21 | !!value && value.length > max && `Must be at most ${max} characters`, 22 | 23 | oneOf: (arr: any[]) => (value: Value): ErrorMessage => 24 | !!value && !arr.includes(value) && `Must be one of: ${arr.join(', ')}`, 25 | 26 | notEmptyArray: () => (value: Value): ErrorMessage => 27 | Array.isArray(value) && value.length === 0 && 'Please add at least one item', 28 | 29 | email: () => (value: Value): ErrorMessage => 30 | !!value && !/.+@.+\..+/.test(value) && 'Must be a valid email', 31 | 32 | url: () => (value: Value): ErrorMessage => 33 | !!value && 34 | // eslint-disable-next-line no-useless-escape 35 | !/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/.test(value) && 36 | 'Must be a valid URL', 37 | }; 38 | 39 | const isNilOrEmptyString = (value: Value): boolean => 40 | value === undefined || value === null || value === ''; 41 | 42 | export const generateErrors = ( 43 | fieldValues: FieldValues, 44 | fieldValidators: FieldValidators, 45 | ): FieldErrors => { 46 | const fieldErrors: FieldErrors = {}; 47 | 48 | Object.entries(fieldValidators).forEach(([fieldName, validators]) => { 49 | [validators].flat().forEach(validator => { 50 | const errorMessage = validator(fieldValues[fieldName], fieldValues); 51 | 52 | if (errorMessage !== false && !fieldErrors[fieldName]) { 53 | fieldErrors[fieldName] = errorMessage; 54 | } 55 | }); 56 | }); 57 | return fieldErrors; 58 | }; 59 | 60 | export default is; 61 | -------------------------------------------------------------------------------- /api/tsconfig-paths.js: -------------------------------------------------------------------------------- 1 | const tsConfigPaths = require('tsconfig-paths'); 2 | 3 | const tsConfig = require('./tsconfig.json'); 4 | 5 | // Typescript compiler doesn't rewrite absolute paths back to relative 6 | // when compiling production code to /build. Instead we have to use 7 | // tsconfig-paths to do that job when we run our production start script. 8 | // https://github.com/microsoft/TypeScript/issues/10866 9 | tsConfigPaths.register({ 10 | baseUrl: tsConfig.compilerOptions.outDir, 11 | paths: tsConfig.compilerOptions.paths, 12 | }); 13 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "es2019", "esnext.asynciterable"], 6 | "sourceMap": true, 7 | "outDir": "./build", 8 | "removeComments": true, 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "strictFunctionTypes": true, 13 | "strictBindCallApply": true, 14 | "strictPropertyInitialization": false, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": false, 20 | "noFallthroughCasesInSwitch": true, 21 | "moduleResolution": "node", 22 | "baseUrl": "src", 23 | "paths": { 24 | "*": ["./*"] 25 | }, 26 | "types": ["node"], 27 | "allowSyntheticDefaultImports": true, 28 | "esModuleInterop": true, 29 | "experimentalDecorators": true, 30 | "emitDecoratorMetadata": true, 31 | "forceConsistentCasingInFileNames": true 32 | }, 33 | "exclude": ["node_modules"], 34 | "include": ["./src/**/*.ts"] 35 | } 36 | -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "entry", 7 | "corejs": 3 8 | } 9 | ], 10 | "@babel/react" 11 | ], 12 | "plugins": [ 13 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 14 | "@babel/plugin-proposal-export-namespace-from", 15 | "@babel/plugin-syntax-dynamic-import", 16 | ["@babel/plugin-proposal-class-properties", { "loose": true }] 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "sourceType": "module", 5 | "ecmaVersion": 8, 6 | "ecmaFeatures": { 7 | "jsx": true 8 | } 9 | }, 10 | "env": { 11 | "browser": true, 12 | "jest": true 13 | }, 14 | "extends": ["airbnb", "prettier", "prettier/react"], 15 | "plugins": ["react-hooks"], 16 | "rules": { 17 | "react-hooks/rules-of-hooks": "error", 18 | "react-hooks/exhaustive-deps": "warn", 19 | "radix": 0, 20 | "no-restricted-syntax": 0, 21 | "no-await-in-loop": 0, 22 | "no-console": 0, 23 | "consistent-return": 0, 24 | "no-param-reassign": [2, { "props": false }], 25 | "no-return-assign": [2, "except-parens"], 26 | "no-use-before-define": 0, 27 | "import/prefer-default-export": 0, 28 | "import/no-cycle": 0, 29 | "react/no-array-index-key": 0, 30 | "react/forbid-prop-types": 0, 31 | "react/prop-types": [2, { "skipUndeclared": true }], 32 | "react/jsx-fragments": [2, "element"], 33 | "react/state-in-constructor": 0, 34 | "react/jsx-props-no-spreading": 0, 35 | "jsx-a11y/click-events-have-key-events": 0 36 | }, 37 | "settings": { 38 | // Allows us to lint absolute imports within codebase 39 | "import/resolver": { 40 | "node": { 41 | "moduleDirectory": ["node_modules", "src/"] 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Project structure 🏗 2 | 3 | I've used this architecture on multiple larger projects in the past and it performed really well. 4 | 5 | There are two special root folders in `src`: `App` and `shared` (described below). All other root folders in `src` (in our case only two: `Auth` and `Project`) should follow the structure of the routes. We can call these folders modules. 6 | 7 | The main rule to follow: **Files from one module can only import from ancestor folders within the same module or from `src/shared`.** This makes the codebase easier to understand, and if you're fiddling with code in one module, you will never introduce a bug in another module. 8 | 9 |
10 | 11 | | File or folder | Description | 12 | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 13 | | `src/index.jsx` | The entry file. This is where we import babel polyfills and render the App into the root DOM node. | 14 | | `src/index.html` | The only HTML file in our App. All scripts and styles will be injected here by Webpack. | 15 | | `src/App` | Main application routes, components that need to be mounted at all times regardless of current route, global css styles, fonts, etc. Basically anything considered global / ancestor of all modules. | 16 | | `src/Auth` | Authentication module | 17 | | `src/Project` | Project module | 18 | | `src/shared` | Components, constants, utils, hooks, styles etc. that can be used anywhere in the codebase. Any module is allowed to import from shared. | 19 | -------------------------------------------------------------------------------- /client/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:8080", 3 | "viewportHeight": 800, 4 | "viewportWidth": 1440, 5 | "env": { 6 | "apiBaseUrl": "http://localhost:3000" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/cypress/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:cypress/recommended"], 3 | "rules": { 4 | "no-unused-expressions": 0 // chai assertions trigger this rule 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /client/cypress/integration/authentication.spec.js: -------------------------------------------------------------------------------- 1 | import { testid } from '../support/utils'; 2 | 3 | describe('Authentication', () => { 4 | beforeEach(() => { 5 | cy.resetDatabase(); 6 | cy.visit('/'); 7 | }); 8 | 9 | it('creates guest account if user has no auth token', () => { 10 | cy.window() 11 | .its('localStorage.authToken') 12 | .should('be.undefined'); 13 | 14 | cy.window() 15 | .its('localStorage.authToken') 16 | .should('be.a', 'string') 17 | .and('not.be.empty'); 18 | 19 | cy.get(testid`list-issue`).should('have.length', 8); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /client/cypress/integration/issueCreate.spec.js: -------------------------------------------------------------------------------- 1 | import { testid } from '../support/utils'; 2 | 3 | describe('Issue create', () => { 4 | beforeEach(() => { 5 | cy.resetDatabase(); 6 | cy.createTestAccount(); 7 | cy.visit('/project/settings?modal-issue-create=true'); 8 | }); 9 | 10 | it('validates form and creates issue successfully', () => { 11 | cy.get(testid`modal:issue-create`).within(() => { 12 | cy.get('button[type="submit"]').click(); 13 | cy.get(testid`form-field:title`).should('contain', 'This field is required'); 14 | 15 | cy.selectOption('type', 'Story'); 16 | cy.get('input[name="title"]').type('TEST_TITLE'); 17 | cy.get('.ql-editor').type('TEST_DESCRIPTION'); 18 | cy.selectOption('reporterId', 'Yoda'); 19 | cy.selectOption('userIds', 'Gaben', 'Yoda'); 20 | cy.selectOption('priority', 'High'); 21 | 22 | cy.get('button[type="submit"]').click(); 23 | }); 24 | 25 | cy.get(testid`modal:issue-create`).should('not.exist'); 26 | cy.contains('Issue has been successfully created.').should('exist'); 27 | cy.location('pathname').should('equal', '/project/board'); 28 | cy.location('search').should('be.empty'); 29 | 30 | cy.contains(testid`list-issue`, 'TEST_TITLE') 31 | .should('have.descendants', testid`avatar:Gaben`) 32 | .and('have.descendants', testid`avatar:Yoda`) 33 | .and('have.descendants', testid`icon:story`); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /client/cypress/integration/issueFilters.spec.js: -------------------------------------------------------------------------------- 1 | import { testid } from '../support/utils'; 2 | 3 | describe('Issue filters', () => { 4 | beforeEach(() => { 5 | cy.resetDatabase(); 6 | cy.createTestAccount(); 7 | cy.visit('/project/board'); 8 | }); 9 | 10 | it('filters issues', () => { 11 | getSearchInput().debounced('type', 'Issue title 1'); 12 | assertIssuesCount(1); 13 | getSearchInput().debounced('clear'); 14 | assertIssuesCount(3); 15 | 16 | getUserAvatar().click(); 17 | assertIssuesCount(2); 18 | getUserAvatar().click(); 19 | assertIssuesCount(3); 20 | 21 | getMyOnlyButton().click(); 22 | assertIssuesCount(2); 23 | getMyOnlyButton().click(); 24 | assertIssuesCount(3); 25 | 26 | getRecentButton().click(); 27 | assertIssuesCount(3); 28 | }); 29 | 30 | const getSearchInput = () => cy.get(testid`board-filters`).find('input'); 31 | const getUserAvatar = () => cy.get(testid`board-filters`).find(testid`avatar:Gaben`); 32 | const getMyOnlyButton = () => cy.get(testid`board-filters`).contains('Only My Issues'); 33 | const getRecentButton = () => cy.get(testid`board-filters`).contains('Recently Updated'); 34 | const assertIssuesCount = count => cy.get(testid`list-issue`).should('have.length', count); 35 | }); 36 | -------------------------------------------------------------------------------- /client/cypress/integration/issueSearch.spec.js: -------------------------------------------------------------------------------- 1 | import { testid } from '../support/utils'; 2 | 3 | describe('Issue search', () => { 4 | beforeEach(() => { 5 | cy.resetDatabase(); 6 | cy.createTestAccount(); 7 | cy.visit('/project/board?modal-issue-search=true'); 8 | }); 9 | 10 | it('displays recent issues if search input is empty', () => { 11 | getIssueSearchModal().within(() => { 12 | cy.contains('Recent Issues').should('exist'); 13 | getIssues().should('have.length', 3); 14 | 15 | cy.get('input').debounced('type', 'anything'); 16 | cy.contains('Recent Issues').should('not.exist'); 17 | 18 | cy.get('input').debounced('clear'); 19 | cy.contains('Recent Issues').should('exist'); 20 | getIssues().should('have.length', 3); 21 | }); 22 | }); 23 | 24 | it('displays matching issues successfully', () => { 25 | getIssueSearchModal().within(() => { 26 | cy.get('input').debounced('type', 'Issue'); 27 | getIssues().should('have.length', 3); 28 | 29 | cy.get('input').debounced('type', ' description'); 30 | getIssues().should('have.length', 2); 31 | 32 | cy.get('input').debounced('type', ' 3'); 33 | getIssues().should('have.length', 1); 34 | 35 | cy.contains('Matching Issues').should('exist'); 36 | }); 37 | }); 38 | 39 | it('displays message if no results were found', () => { 40 | getIssueSearchModal().within(() => { 41 | cy.get('input').debounced('type', 'gibberish'); 42 | 43 | getIssues().should('not.exist'); 44 | cy.contains("We couldn't find anything matching your search").should('exist'); 45 | }); 46 | }); 47 | 48 | const getIssueSearchModal = () => cy.get(testid`modal:issue-search`); 49 | const getIssues = () => cy.get('a[href*="/project/board/issues/"]'); 50 | }); 51 | -------------------------------------------------------------------------------- /client/cypress/integration/issuesDragDrop.spec.js: -------------------------------------------------------------------------------- 1 | import { KeyCodes } from 'shared/constants/keyCodes'; 2 | 3 | import { testid } from '../support/utils'; 4 | 5 | describe('Issues drag & drop', () => { 6 | beforeEach(() => { 7 | cy.resetDatabase(); 8 | cy.createTestAccount(); 9 | cy.visit('/project/board'); 10 | }); 11 | 12 | it('moves issue between different lists', () => { 13 | cy.get(testid`board-list:backlog`).should('contain', firstIssueTitle); 14 | cy.get(testid`board-list:selected`).should('not.contain', firstIssueTitle); 15 | moveFirstIssue(KeyCodes.ARROW_RIGHT); 16 | 17 | cy.assertReloadAssert(() => { 18 | cy.get(testid`board-list:backlog`).should('not.contain', firstIssueTitle); 19 | cy.get(testid`board-list:selected`).should('contain', firstIssueTitle); 20 | }); 21 | }); 22 | 23 | it('moves issue within a single list', () => { 24 | getIssueAtIndex(0).should('contain', firstIssueTitle); 25 | getIssueAtIndex(1).should('contain', secondIssueTitle); 26 | moveFirstIssue(KeyCodes.ARROW_DOWN); 27 | 28 | cy.assertReloadAssert(() => { 29 | getIssueAtIndex(0).should('contain', secondIssueTitle); 30 | getIssueAtIndex(1).should('contain', firstIssueTitle); 31 | }); 32 | }); 33 | 34 | const firstIssueTitle = 'Issue title 1'; 35 | const secondIssueTitle = 'Issue title 2'; 36 | 37 | const getIssueAtIndex = index => cy.get(testid`list-issue`).eq(index); 38 | 39 | const moveFirstIssue = directionKeyCode => { 40 | cy.waitForXHR('PUT', '/issues/**', () => { 41 | getIssueAtIndex(0) 42 | .focus() 43 | .trigger('keydown', { keyCode: KeyCodes.SPACE }) 44 | .trigger('keydown', { keyCode: directionKeyCode, force: true }) 45 | .trigger('keydown', { keyCode: KeyCodes.SPACE, force: true }); 46 | }); 47 | }; 48 | }); 49 | -------------------------------------------------------------------------------- /client/cypress/integration/projectSettings.spec.js: -------------------------------------------------------------------------------- 1 | import { testid } from '../support/utils'; 2 | 3 | describe('Project settings', () => { 4 | beforeEach(() => { 5 | cy.resetDatabase(); 6 | cy.createTestAccount(); 7 | cy.visit('/project/settings'); 8 | }); 9 | 10 | it('should display current values in form', () => { 11 | cy.get('input[name="name"]').should('have.value', 'Project name'); 12 | cy.get('input[name="url"]').should('have.value', 'https://www.testurl.com'); 13 | cy.get('.ql-editor').should('contain', 'Project description'); 14 | cy.selectShouldContain('category', 'Software'); 15 | }); 16 | 17 | it('validates form and updates project successfully', () => { 18 | cy.get('input[name="name"]').clear(); 19 | cy.get('button[type="submit"]').click(); 20 | cy.get(testid`form-field:name`).should('contain', 'This field is required'); 21 | 22 | cy.get('input[name="name"]').type('TEST_NAME'); 23 | cy.get(testid`form-field:name`).should('not.contain', 'This field is required'); 24 | 25 | cy.selectOption('category', 'Business'); 26 | cy.get('button[type="submit"]').click(); 27 | cy.contains('Changes have been saved successfully.').should('exist'); 28 | 29 | cy.reload(); 30 | 31 | cy.get('input[name="name"]').should('have.value', 'TEST_NAME'); 32 | cy.selectShouldContain('category', 'Business'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /client/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | 4 | // *********************************************************** 5 | // This example plugins/index.js can be used to load plugins 6 | // 7 | // You can change the location of this file or turn off loading 8 | // the plugins file with the 'pluginsFile' configuration option. 9 | // 10 | // You can read more here: 11 | // https://on.cypress.io/plugins-guide 12 | // *********************************************************** 13 | 14 | // This function is called when a project is opened or re-opened (e.g. due to 15 | // the project's config changing) 16 | 17 | const webpack = require('@cypress/webpack-preprocessor'); 18 | const webpackOptions = require('../../webpack.config.js'); 19 | 20 | module.exports = on => { 21 | on('file:preprocessor', webpack({ webpackOptions })); 22 | }; 23 | -------------------------------------------------------------------------------- /client/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | import './commands'; 17 | -------------------------------------------------------------------------------- /client/cypress/support/utils.js: -------------------------------------------------------------------------------- 1 | export const testid = (strings, ...values) => { 2 | const id = strings.map((str, index) => str + (values[index] || '')).join(''); 3 | return `[data-testid="${id}"]`; 4 | }; 5 | -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['*', 'js', 'jsx'], 3 | moduleDirectories: ['src', 'node_modules'], 4 | moduleNameMapper: { 5 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 6 | '/jest/fileMock.js', 7 | '\\.(css|scss|less)$': '/jest/styleMock.js', 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /client/jest/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /client/jest/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /client/jsconfig.json: -------------------------------------------------------------------------------- 1 | // This config allows VSCode intellisense to work with absolute "src" imports and jsx files 2 | { 3 | "compilerOptions": { 4 | "baseUrl": "./src", 5 | "jsx": "react" 6 | }, 7 | "include": ["src/**/*", "cypress/**/*.js", "./node_modules/cypress"] 8 | } 9 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jira_client", 3 | "version": "1.0.0", 4 | "author": "Ivor Reic", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "webpack-dev-server", 8 | "start:production": "pm2 start --name 'jira_client' server.js", 9 | "test:jest": "jest", 10 | "test:cypress": "node_modules/.bin/cypress open", 11 | "build": "rm -rf build && webpack --config webpack.config.production.js --progress", 12 | "pre-commit": "lint-staged" 13 | }, 14 | "devDependencies": { 15 | "@babel/core": "^7.7.4", 16 | "@babel/plugin-proposal-class-properties": "^7.7.4", 17 | "@babel/plugin-proposal-decorators": "^7.7.4", 18 | "@babel/plugin-proposal-export-namespace-from": "^7.7.4", 19 | "@babel/plugin-syntax-dynamic-import": "^7.7.4", 20 | "@babel/preset-env": "^7.7.4", 21 | "@babel/preset-react": "^7.7.4", 22 | "@cypress/webpack-preprocessor": "^4.1.1", 23 | "babel-eslint": "^10.0.3", 24 | "babel-loader": "^8.0.6", 25 | "css-loader": "^3.3.2", 26 | "cypress": "^3.8.1", 27 | "eslint": "^6.1.0", 28 | "eslint-config-airbnb": "^18.0.1", 29 | "eslint-config-prettier": "^6.7.0", 30 | "eslint-plugin-cypress": "^2.8.1", 31 | "eslint-plugin-import": "^2.18.2", 32 | "eslint-plugin-jsx-a11y": "^6.2.3", 33 | "eslint-plugin-react": "^7.17.0", 34 | "eslint-plugin-react-hooks": "^1.7.0", 35 | "file-loader": "^5.0.2", 36 | "html-webpack-plugin": "^3.2.0", 37 | "jest": "^24.9.0", 38 | "lint-staged": "^9.5.0", 39 | "prettier": "^1.19.1", 40 | "style-loader": "^1.0.1", 41 | "url-loader": "^3.0.0", 42 | "webpack": "^4.41.2", 43 | "webpack-cli": "^3.3.10", 44 | "webpack-dev-server": "^3.9.0" 45 | }, 46 | "dependencies": { 47 | "@4tw/cypress-drag-drop": "^1.3.0", 48 | "axios": "^0.19.0", 49 | "color": "^3.1.2", 50 | "compression": "^1.7.4", 51 | "core-js": "^3.4.7", 52 | "express": "^4.17.1", 53 | "express-history-api-fallback": "^2.2.1", 54 | "formik": "^2.1.1", 55 | "history": "^4.10.1", 56 | "jwt-decode": "^2.2.0", 57 | "lodash": "^4.17.15", 58 | "moment": "^2.24.0", 59 | "prop-types": "^15.7.2", 60 | "query-string": "^6.9.0", 61 | "quill": "^1.3.7", 62 | "react": "^16.12.0", 63 | "react-beautiful-dnd": "^12.2.0", 64 | "react-content-loader": "^4.3.3", 65 | "react-dom": "^16.12.0", 66 | "react-router-dom": "^5.1.2", 67 | "react-textarea-autosize": "^7.1.2", 68 | "react-transition-group": "^4.3.0", 69 | "regenerator-runtime": "^0.13.3", 70 | "styled-components": "^4.4.1", 71 | "sweet-pubsub": "^1.1.2" 72 | }, 73 | "lint-staged": { 74 | "*.{js,jsx}": [ 75 | "eslint --fix", 76 | "prettier --write", 77 | "git add" 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /client/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const fallback = require('express-history-api-fallback'); 3 | const compression = require('compression'); 4 | 5 | const app = express(); 6 | 7 | app.use(compression()); 8 | 9 | app.use(express.static(`${__dirname}/build`)); 10 | 11 | app.use(fallback(`${__dirname}/build/index.html`)); 12 | 13 | app.listen(process.env.PORT || 8081); 14 | -------------------------------------------------------------------------------- /client/src/App/BaseStyles.js: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | import { color, font, mixin } from 'shared/utils/styles'; 4 | 5 | export default createGlobalStyle` 6 | html, body, #root { 7 | height: 100%; 8 | min-height: 100%; 9 | min-width: 768px; 10 | } 11 | 12 | body { 13 | color: ${color.textDarkest}; 14 | -webkit-tap-highlight-color: transparent; 15 | line-height: 1.2; 16 | ${font.size(16)} 17 | ${font.regular} 18 | } 19 | 20 | #root { 21 | display: flex; 22 | flex-direction: column; 23 | } 24 | 25 | button, 26 | input, 27 | optgroup, 28 | select, 29 | textarea { 30 | ${font.regular} 31 | } 32 | 33 | *, *:after, *:before, input[type="search"] { 34 | box-sizing: border-box; 35 | } 36 | 37 | a { 38 | color: inherit; 39 | text-decoration: none; 40 | } 41 | 42 | ul { 43 | list-style: none; 44 | } 45 | 46 | ul, li, ol, dd, h1, h2, h3, h4, h5, h6, p { 47 | padding: 0; 48 | margin: 0; 49 | } 50 | 51 | h1, h2, h3, h4, h5, h6, strong { 52 | ${font.bold} 53 | } 54 | 55 | button { 56 | background: none; 57 | border: none; 58 | } 59 | 60 | /* Workaround for IE11 focus highlighting for select elements */ 61 | select::-ms-value { 62 | background: none; 63 | color: #42413d; 64 | } 65 | 66 | [role="button"], button, input, select, textarea { 67 | outline: none; 68 | &:focus { 69 | outline: none; 70 | } 71 | &:disabled { 72 | opacity: 1; 73 | } 74 | } 75 | [role="button"], button, input, textarea { 76 | appearance: none; 77 | } 78 | select:-moz-focusring { 79 | color: transparent; 80 | text-shadow: 0 0 0 #000; 81 | } 82 | select::-ms-expand { 83 | display: none; 84 | } 85 | select option { 86 | color: ${color.textDarkest}; 87 | } 88 | 89 | p { 90 | line-height: 1.4285; 91 | a { 92 | ${mixin.link()} 93 | } 94 | } 95 | 96 | textarea { 97 | line-height: 1.4285; 98 | } 99 | 100 | body, select { 101 | -webkit-font-smoothing: antialiased; 102 | -moz-osx-font-smoothing: grayscale; 103 | } 104 | 105 | html { 106 | touch-action: manipulation; 107 | } 108 | 109 | ${mixin.placeholderColor(color.textLight)} 110 | `; 111 | -------------------------------------------------------------------------------- /client/src/App/Routes.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router, Switch, Route, Redirect } from 'react-router-dom'; 3 | 4 | import history from 'browserHistory'; 5 | import Project from 'Project'; 6 | import Authenticate from 'Auth/Authenticate'; 7 | import PageError from 'shared/components/PageError'; 8 | 9 | const Routes = () => ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | 20 | export default Routes; 21 | -------------------------------------------------------------------------------- /client/src/App/Toast/Styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { color, font, mixin, zIndexValues } from 'shared/utils/styles'; 4 | import { Icon } from 'shared/components'; 5 | 6 | export const Container = styled.div` 7 | z-index: ${zIndexValues.modal + 1}; 8 | position: fixed; 9 | right: 30px; 10 | top: 50px; 11 | `; 12 | 13 | export const StyledToast = styled.div` 14 | position: relative; 15 | margin-bottom: 5px; 16 | width: 300px; 17 | padding: 15px 20px; 18 | border-radius: 3px; 19 | color: #fff; 20 | background: ${props => color[props.type]}; 21 | cursor: pointer; 22 | transition: all 0.15s; 23 | ${mixin.clearfix} 24 | ${mixin.hardwareAccelerate} 25 | 26 | &.jira-toast-enter, 27 | &.jira-toast-exit.jira-toast-exit-active { 28 | opacity: 0; 29 | right: -10px; 30 | } 31 | 32 | &.jira-toast-exit, 33 | &.jira-toast-enter.jira-toast-enter-active { 34 | opacity: 1; 35 | right: 0; 36 | } 37 | `; 38 | 39 | export const CloseIcon = styled(Icon)` 40 | position: absolute; 41 | top: 13px; 42 | right: 14px; 43 | font-size: 22px; 44 | cursor: pointer; 45 | color: #fff; 46 | `; 47 | 48 | export const Title = styled.div` 49 | padding-right: 22px; 50 | ${font.size(15)} 51 | ${font.medium} 52 | `; 53 | 54 | export const Message = styled.div` 55 | padding: 8px 10px 0 0; 56 | white-space: pre-wrap; 57 | ${font.size(14)} 58 | ${font.medium} 59 | `; 60 | -------------------------------------------------------------------------------- /client/src/App/Toast/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { CSSTransition, TransitionGroup } from 'react-transition-group'; 3 | import pubsub from 'sweet-pubsub'; 4 | import { uniqueId } from 'lodash'; 5 | 6 | import { Container, StyledToast, CloseIcon, Title, Message } from './Styles'; 7 | 8 | const Toast = () => { 9 | const [toasts, setToasts] = useState([]); 10 | 11 | useEffect(() => { 12 | const addToast = ({ type = 'success', title, message, duration = 5 }) => { 13 | const id = uniqueId('toast-'); 14 | 15 | setToasts(currentToasts => [...currentToasts, { id, type, title, message }]); 16 | 17 | if (duration) { 18 | setTimeout(() => removeToast(id), duration * 1000); 19 | } 20 | }; 21 | 22 | pubsub.on('toast', addToast); 23 | 24 | return () => { 25 | pubsub.off('toast', addToast); 26 | }; 27 | }, []); 28 | 29 | const removeToast = id => { 30 | setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id)); 31 | }; 32 | 33 | return ( 34 | 35 | 36 | {toasts.map(toast => ( 37 | 38 | removeToast(toast.id)}> 39 | 40 | {toast.title && {toast.title}} 41 | {toast.message && {toast.message}} 42 | 43 | 44 | ))} 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default Toast; 51 | -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Black.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Black.eot -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Black.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Black.otf -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Black.ttf -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Black.woff -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Black.woff2 -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Bold.eot -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Bold.otf -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Bold.ttf -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Bold.woff -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Bold.woff2 -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Book.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Book.eot -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Book.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Book.otf -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Book.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Book.ttf -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Book.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Book.woff -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Book.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Book.woff2 -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Medium.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Medium.eot -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Medium.otf -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Medium.ttf -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Medium.woff -------------------------------------------------------------------------------- /client/src/App/assets/fonts/CircularStd-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/CircularStd-Medium.woff2 -------------------------------------------------------------------------------- /client/src/App/assets/fonts/jira.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/jira.ttf -------------------------------------------------------------------------------- /client/src/App/assets/fonts/jira.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldboyxx/jira_clone/26a9e77b1789fef9cb43edb5d6018cf1663cf035/client/src/App/assets/fonts/jira.woff -------------------------------------------------------------------------------- /client/src/App/fontStyles.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'CircularStdBlack'; 3 | src: url('./assets/fonts/CircularStd-Black.woff2') format('woff2'), 4 | url('./assets/fonts/CircularStd-Black.woff') format('woff'); 5 | font-weight: normal; 6 | font-style: normal; 7 | } 8 | @font-face { 9 | font-family: 'CircularStdBold'; 10 | src: url('./assets/fonts/CircularStd-Bold.woff2') format('woff2'), 11 | url('./assets/fonts/CircularStd-Bold.woff') format('woff'); 12 | font-weight: normal; 13 | font-style: normal; 14 | } 15 | @font-face { 16 | font-family: 'CircularStdMedium'; 17 | src: url('./assets/fonts/CircularStd-Medium.woff2') format('woff2'), 18 | url('./assets/fonts/CircularStd-Medium.woff') format('woff'); 19 | font-weight: normal; 20 | font-style: normal; 21 | } 22 | @font-face { 23 | font-family: 'CircularStdBook'; 24 | src: url('./assets/fonts/CircularStd-Book.woff2') format('woff2'), 25 | url('./assets/fonts/CircularStd-Book.woff') format('woff'); 26 | font-weight: normal; 27 | font-style: normal; 28 | } 29 | @font-face { 30 | font-family: 'jira'; 31 | src: url('./assets/fonts/jira.woff') format('truetype'), 32 | url('./assets/fonts/jira.ttf') format('woff'), url('./assets/fonts/jira.svg#jira') format('svg'); 33 | font-weight: normal; 34 | font-style: normal; 35 | } 36 | -------------------------------------------------------------------------------- /client/src/App/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | 3 | import NormalizeStyles from './NormalizeStyles'; 4 | import BaseStyles from './BaseStyles'; 5 | import Toast from './Toast'; 6 | import Routes from './Routes'; 7 | 8 | // We're importing .css because @font-face in styled-components causes font files 9 | // to be constantly re-requested from the server (which causes screen flicker) 10 | // https://github.com/styled-components/styled-components/issues/1593 11 | import './fontStyles.css'; 12 | 13 | const App = () => ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /client/src/Auth/Authenticate.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | 4 | import api from 'shared/utils/api'; 5 | import toast from 'shared/utils/toast'; 6 | import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken'; 7 | import { PageLoader } from 'shared/components'; 8 | 9 | const Authenticate = () => { 10 | const history = useHistory(); 11 | 12 | useEffect(() => { 13 | const createGuestAccount = async () => { 14 | try { 15 | const { authToken } = await api.post('/authentication/guest'); 16 | storeAuthToken(authToken); 17 | history.push('/'); 18 | } catch (error) { 19 | toast.error(error); 20 | } 21 | }; 22 | 23 | if (!getStoredAuthToken()) { 24 | createGuestAccount(); 25 | } 26 | }, [history]); 27 | 28 | return ; 29 | }; 30 | 31 | export default Authenticate; 32 | -------------------------------------------------------------------------------- /client/src/Project/Board/Filters/Styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { color, font, mixin } from 'shared/utils/styles'; 4 | import { InputDebounced, Avatar, Button } from 'shared/components'; 5 | 6 | export const Filters = styled.div` 7 | display: flex; 8 | align-items: center; 9 | margin-top: 24px; 10 | `; 11 | 12 | export const SearchInput = styled(InputDebounced)` 13 | margin-right: 18px; 14 | width: 160px; 15 | `; 16 | 17 | export const Avatars = styled.div` 18 | display: flex; 19 | flex-direction: row-reverse; 20 | margin: 0 12px 0 2px; 21 | `; 22 | 23 | export const AvatarIsActiveBorder = styled.div` 24 | display: inline-flex; 25 | margin-left: -2px; 26 | border-radius: 50%; 27 | transition: transform 0.1s; 28 | ${mixin.clickable}; 29 | ${props => props.isActive && `box-shadow: 0 0 0 4px ${color.primary}`} 30 | &:hover { 31 | transform: translateY(-5px); 32 | } 33 | `; 34 | 35 | export const StyledAvatar = styled(Avatar)` 36 | box-shadow: 0 0 0 2px #fff; 37 | `; 38 | 39 | export const StyledButton = styled(Button)` 40 | margin-left: 6px; 41 | `; 42 | 43 | export const ClearAll = styled.div` 44 | height: 32px; 45 | line-height: 32px; 46 | margin-left: 15px; 47 | padding-left: 12px; 48 | border-left: 1px solid ${color.borderLightest}; 49 | color: ${color.textDark}; 50 | ${font.size(14.5)} 51 | ${mixin.clickable} 52 | &:hover { 53 | color: ${color.textMedium}; 54 | } 55 | `; 56 | -------------------------------------------------------------------------------- /client/src/Project/Board/Filters/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { xor } from 'lodash'; 4 | 5 | import { 6 | Filters, 7 | SearchInput, 8 | Avatars, 9 | AvatarIsActiveBorder, 10 | StyledAvatar, 11 | StyledButton, 12 | ClearAll, 13 | } from './Styles'; 14 | 15 | const propTypes = { 16 | projectUsers: PropTypes.array.isRequired, 17 | defaultFilters: PropTypes.object.isRequired, 18 | filters: PropTypes.object.isRequired, 19 | mergeFilters: PropTypes.func.isRequired, 20 | }; 21 | 22 | const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, mergeFilters }) => { 23 | const { searchTerm, userIds, myOnly, recent } = filters; 24 | 25 | const areFiltersCleared = !searchTerm && userIds.length === 0 && !myOnly && !recent; 26 | 27 | return ( 28 | 29 | mergeFilters({ searchTerm: value })} 33 | /> 34 | 35 | {projectUsers.map(user => ( 36 | 37 | mergeFilters({ userIds: xor(userIds, [user.id]) })} 41 | /> 42 | 43 | ))} 44 | 45 | mergeFilters({ myOnly: !myOnly })} 49 | > 50 | Only My Issues 51 | 52 | mergeFilters({ recent: !recent })} 56 | > 57 | Recently Updated 58 | 59 | {!areFiltersCleared && ( 60 | mergeFilters(defaultFilters)}>Clear all 61 | )} 62 | 63 | ); 64 | }; 65 | 66 | ProjectBoardFilters.propTypes = propTypes; 67 | 68 | export default ProjectBoardFilters; 69 | -------------------------------------------------------------------------------- /client/src/Project/Board/Header/Styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { font } from 'shared/utils/styles'; 4 | 5 | export const Header = styled.div` 6 | margin-top: 6px; 7 | display: flex; 8 | justify-content: space-between; 9 | `; 10 | 11 | export const BoardName = styled.div` 12 | ${font.size(24)} 13 | ${font.medium} 14 | `; 15 | -------------------------------------------------------------------------------- /client/src/Project/Board/Header/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Button } from 'shared/components'; 4 | 5 | import { Header, BoardName } from './Styles'; 6 | 7 | const ProjectBoardHeader = () => ( 8 |
9 | Kanban board 10 | 11 | 12 | 13 |
14 | ); 15 | 16 | export default ProjectBoardHeader; 17 | -------------------------------------------------------------------------------- /client/src/Project/Board/IssueDetails/AssigneesReporter/Styles.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | import { color, font, mixin } from 'shared/utils/styles'; 4 | 5 | export const User = styled.div` 6 | display: flex; 7 | align-items: center; 8 | ${mixin.clickable} 9 | ${props => 10 | props.isSelectValue && 11 | css` 12 | margin: 0 10px ${props.withBottomMargin ? 5 : 0}px 0; 13 | padding: 4px 8px; 14 | border-radius: 4px; 15 | background: ${color.backgroundLight}; 16 | transition: background 0.1s; 17 | &:hover { 18 | background: ${color.backgroundMedium}; 19 | } 20 | `} 21 | `; 22 | 23 | export const Username = styled.div` 24 | padding: 0 3px 0 8px; 25 | ${font.size(14.5)} 26 | `; 27 | -------------------------------------------------------------------------------- /client/src/Project/Board/IssueDetails/AssigneesReporter/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Avatar, Select, Icon } from 'shared/components'; 5 | 6 | import { SectionTitle } from '../Styles'; 7 | import { User, Username } from './Styles'; 8 | 9 | const propTypes = { 10 | issue: PropTypes.object.isRequired, 11 | updateIssue: PropTypes.func.isRequired, 12 | projectUsers: PropTypes.array.isRequired, 13 | }; 14 | 15 | const ProjectBoardIssueDetailsAssigneesReporter = ({ issue, updateIssue, projectUsers }) => { 16 | const getUserById = userId => projectUsers.find(user => user.id === userId); 17 | 18 | const userOptions = projectUsers.map(user => ({ value: user.id, label: user.name })); 19 | 20 | return ( 21 | 22 | Assignees 23 | updateIssue({ reporterId: userId })} 49 | renderValue={({ value: userId }) => renderUser(getUserById(userId), true)} 50 | renderOption={({ value: userId }) => renderUser(getUserById(userId))} 51 | /> 52 | 53 | ); 54 | }; 55 | 56 | const renderUser = (user, isSelectValue, removeOptionValue) => ( 57 | removeOptionValue && removeOptionValue()} 62 | > 63 | 64 | {user.name} 65 | {removeOptionValue && } 66 | 67 | ); 68 | 69 | ProjectBoardIssueDetailsAssigneesReporter.propTypes = propTypes; 70 | 71 | export default ProjectBoardIssueDetailsAssigneesReporter; 72 | -------------------------------------------------------------------------------- /client/src/Project/Board/IssueDetails/Comments/BodyForm/Styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { Button } from 'shared/components'; 4 | 5 | export const Actions = styled.div` 6 | display: flex; 7 | padding-top: 10px; 8 | `; 9 | 10 | export const FormButton = styled(Button)` 11 | margin-right: 6px; 12 | `; 13 | -------------------------------------------------------------------------------- /client/src/Project/Board/IssueDetails/Comments/BodyForm/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useRef } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Textarea } from 'shared/components'; 5 | 6 | import { Actions, FormButton } from './Styles'; 7 | 8 | const propTypes = { 9 | value: PropTypes.string.isRequired, 10 | onChange: PropTypes.func.isRequired, 11 | isWorking: PropTypes.bool.isRequired, 12 | onSubmit: PropTypes.func.isRequired, 13 | onCancel: PropTypes.func.isRequired, 14 | }; 15 | 16 | const ProjectBoardIssueDetailsCommentsBodyForm = ({ 17 | value, 18 | onChange, 19 | isWorking, 20 | onSubmit, 21 | onCancel, 22 | }) => { 23 | const $textareaRef = useRef(); 24 | 25 | const handleSubmit = () => { 26 | if ($textareaRef.current.value.trim()) { 27 | onSubmit(); 28 | } 29 | }; 30 | 31 | return ( 32 | 33 |