Repository: oldboyxx/jira_clone Branch: master Commit: 26a9e77b1789 Files: 209 Total size: 276.0 KB Directory structure: gitextract_pfax6a_i/ ├── .gitignore ├── .vscode/ │ └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── api/ │ ├── .eslintignore │ ├── .eslintrc.json │ ├── .prettierrc │ ├── README.md │ ├── 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/ │ │ ├── .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 │ ├── cypress.json │ ├── jest/ │ │ ├── fileMock.js │ │ └── styleMock.js │ ├── jest.config.js │ ├── jsconfig.json │ ├── package.json │ ├── server.js │ ├── src/ │ │ ├── App/ │ │ │ ├── BaseStyles.js │ │ │ ├── NormalizeStyles.js │ │ │ ├── Routes.jsx │ │ │ ├── Toast/ │ │ │ │ ├── Styles.js │ │ │ │ └── index.jsx │ │ │ ├── assets/ │ │ │ │ └── fonts/ │ │ │ │ ├── CircularStd-Black.otf │ │ │ │ ├── CircularStd-Bold.otf │ │ │ │ ├── CircularStd-Book.otf │ │ │ │ └── CircularStd-Medium.otf │ │ │ ├── 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 │ │ ├── index.html │ │ ├── index.jsx │ │ └── shared/ │ │ ├── components/ │ │ │ ├── AboutTooltip/ │ │ │ │ ├── Styles.js │ │ │ │ └── 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 │ │ │ │ └── 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 └── package.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # dependencies **/node_modules # misc **/.DS_Store # environment config **/.env # production **/build **/npm-debug.log* ================================================ FILE: .vscode/settings.json ================================================ { "eslint.workingDirectories": [ { "directory": "./client", "changeProcessCWD": true }, { "directory": "./api", "changeProcessCWD": true } ] } ================================================ FILE: CONTRIBUTING.md ================================================ I will not be accepting PR's on this repository. Feel free to fork and maintain your own version. ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2013-present, Yuxi (Evan) You Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

A simplified Jira clone built with React and Node

Auto formatted with Prettier, tested with Cypress 🎗

Visit the live app | View client | View API

![Tech logos](https://i.ibb.co/DVFj8PL/tech-icons.jpg) ![App screenshot](https://i.ibb.co/W3qVvCn/jira-optimized.jpg) ## What is this and who is it for 🤷‍♀️ I do React consulting and this is a showcase product I've built in my spare time. It's a very good example of modern, real-world React codebase. There are many showcase/example React projects out there but most of them are way too simple. I like to think that this codebase contains enough complexity to offer valuable insights to React developers of all skill levels while still being _relatively_ easy to understand. ## Features - Proven, scalable, and easy to understand project structure - Written in modern React, only functional components with hooks - A variety of custom light-weight UI components such as datepicker, modal, various form elements etc - Simple local React state management, without redux, mobx, or similar - Custom webpack setup, without create-react-app or similar - Client written in Babel powered JavaScript - API written in TypeScript and using TypeORM ## Setting up development environment 🛠 - Install [postgreSQL](https://www.postgresql.org/) if you don't have it already and create a database named `jira_development`. - `git clone https://github.com/oldboyxx/jira_clone.git` - Create an empty `.env` file in `/api`, copy `/api/.env.example` contents into it, and fill in your database username and password. - `npm run install-dependencies` - `cd api && npm start` - `cd client && npm start` in another terminal tab - App should now be running on `http://localhost:8080/` ## Running cypress end-to-end tests 🚥 - Set up development environment - Create a database named `jira_test` and start the api with `cd api && npm run start:test` - `cd client && npm run test:cypress` ## What's missing? There are features missing from this showcase product which should exist in a real product: ### Migrations 🗄 We're currently using TypeORM's `synchronize` feature which auto creates the database schema on every application launch. It's fine to do this in a showcase product or during early development while the product is not used by anyone, but before going live with a real product, we should [introduce migrations](https://github.com/typeorm/typeorm/blob/master/docs/migrations.md). ### Proper authentication system 🔐 We currently auto create an auth token and seed a project with issues and users for anyone who visits the API without valid credentials. In a real product we'd want to implement a proper [email and password authentication system](https://www.google.com/search?q=email+and+password+authentication+node+js&oq=email+and+password+authentication+node+js). ### Accessibility ♿ Not all components have properly defined [aria attributes](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA), visual focus indicators etc. Most early stage companies tend to ignore this aspect of their product but in many cases they shouldn't, especially once their userbase starts growing. ### Unit/Integration tests 🧪 Both Client and API are currently tested through [end-to-end Cypress tests](https://github.com/oldboyxx/jira_clone/tree/master/client/cypress/integration). That's good enough for a relatively simple application such as this, even if it was a real product. However, as the app grows in complexity, it might be wise to start writing additional unit/integration tests. ## Contributing I will not be accepting PR's on this repository. Feel free to fork and maintain your own version. ## License [MIT](https://opensource.org/licenses/MIT)

Visit the live app | View client | View API

================================================ FILE: api/.eslintignore ================================================ build/* tsconfig-paths.js ================================================ FILE: api/.eslintrc.json ================================================ { "parser": "@typescript-eslint/parser", "parserOptions": { "project": "./tsconfig.json", "sourceType": "module", "ecmaVersion": 8 }, "plugins": ["@typescript-eslint"], "env": { "node": true }, "extends": [ "airbnb-base", "plugin:import/typescript", "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking", "plugin:prettier/recommended", "prettier/@typescript-eslint" ], "rules": { "radix": 0, "no-restricted-syntax": 0, "no-await-in-loop": 0, "no-console": 0, "consistent-return": 0, "@typescript-eslint/no-unused-vars": 0, "@typescript-eslint/no-use-before-define": 0, "@typescript-eslint/no-explicit-any": 0, "import/prefer-default-export": 0, "import/no-cycle": 0 }, "settings": { // Allows us to lint absolute imports within codebase "import/resolver": { "node": { "moduleDirectory": ["node_modules", "src/"] } } } } ================================================ FILE: api/.prettierrc ================================================ { "printWidth": 100, "singleQuote": true, "trailingComma": "all" } ================================================ FILE: api/README.md ================================================ # Project structure 🏗 The API codebase is fairly simple and should be easy enough to understand.
| File or folder | Description | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/index.ts` | The entry file. This is where we setup middleware, attach routes, initialize database and express. | | `src/routes.ts` | This is where we define all routes, both public and private. | | `src/constants` | Constants are values that never change and are used in multiple places across the codebase. | | `src/controllers` | Controllers listen to client's requests and work with entities and the database to fetch, add, update, or delete data. | | `src/database` | Database related code and seeds go here. | | `src/entities` | This is where we put TypeORM entities, you could think of them as models. We define columns, relations, validations for each database entity. | | `src/errors` | This is where we define custom errors. The `catchErrors` function helps us avoid repetitive `try/catch` blocks within controllers. | | `src/middleware` | Middleware functions can modify request and response objects, end the request-response cycle, etc. For example `authenticateUser` method verifies the authorization token and attaches `currentUser` to the request object. | | `src/serializers` | Serializers transform the data fetched from the database before it's sent to the client. | | `src/utils` | Utility(helper) functions that are used in multiple places across the codebase. For example `utils/typeorm.ts` functions help us validate data and avoid writing repetitive code. | ================================================ FILE: api/package.json ================================================ { "name": "jira_api", "version": "1.0.0", "author": "Ivor Reic", "license": "MIT", "scripts": { "start": "nodemon --exec ts-node --files src/index.ts", "start:test": "cross-env NODE_ENV='test' DB_DATABASE='jira_test' npm start", "start:production": "pm2 start --name 'jira_api' node -- -r ./tsconfig-paths.js build/index.js", "build": "cd src && tsc", "pre-commit": "lint-staged" }, "dependencies": { "cors": "^2.8.5", "dotenv": "^8.2.0", "express": "^4.17.1", "express-async-handler": "^1.1.4", "faker": "^4.1.0", "jsonwebtoken": "^8.5.1", "lodash": "^4.17.15", "module-alias": "^2.2.2", "pg": "^7.14.0", "reflect-metadata": "^0.1.13", "striptags": "^3.1.1", "typeorm": "^0.2.20" }, "devDependencies": { "@types/cors": "^2.8.6", "@types/express": "^4.17.2", "@types/faker": "^4.1.7", "@types/jsonapi-serializer": "^3.6.2", "@types/jsonwebtoken": "^8.3.5", "@types/lodash": "^4.14.149", "@types/node": "^12.12.11", "@typescript-eslint/eslint-plugin": "^2.7.0", "@typescript-eslint/parser": "^2.7.0", "cross-env": "^6.0.3", "eslint": "^6.1.0", "eslint-config-airbnb-base": "^14.0.0", "eslint-config-prettier": "^6.7.0", "eslint-plugin-import": "^2.18.2", "eslint-plugin-prettier": "^3.1.1", "lint-staged": "^9.4.3", "nodemon": "^2.0.0", "prettier": "^1.19.1", "ts-node": "^8.5.2", "tsconfig-paths": "^3.9.0", "typescript": "^3.7.2" }, "_moduleDirectories": [ "src" ], "lint-staged": { "*.ts": [ "eslint --fix", "prettier --write", "git add" ] } } ================================================ FILE: api/src/constants/issues.ts ================================================ export enum IssueType { TASK = 'task', BUG = 'bug', STORY = 'story', } export enum IssueStatus { BACKLOG = 'backlog', SELECTED = 'selected', INPROGRESS = 'inprogress', DONE = 'done', } export enum IssuePriority { HIGHEST = '5', HIGH = '4', MEDIUM = '3', LOW = '2', LOWEST = '1', } ================================================ FILE: api/src/constants/projects.ts ================================================ export enum ProjectCategory { SOFTWARE = 'software', MARKETING = 'marketing', BUSINESS = 'business', } ================================================ FILE: api/src/controllers/authentication.ts ================================================ import { catchErrors } from 'errors'; import { signToken } from 'utils/authToken'; import createAccount from 'database/createGuestAccount'; export const createGuestAccount = catchErrors(async (_req, res) => { const user = await createAccount(); res.respond({ authToken: signToken({ sub: user.id }), }); }); ================================================ FILE: api/src/controllers/comments.ts ================================================ import { Comment } from 'entities'; import { catchErrors } from 'errors'; import { updateEntity, deleteEntity, createEntity } from 'utils/typeorm'; export const create = catchErrors(async (req, res) => { const comment = await createEntity(Comment, req.body); res.respond({ comment }); }); export const update = catchErrors(async (req, res) => { const comment = await updateEntity(Comment, req.params.commentId, req.body); res.respond({ comment }); }); export const remove = catchErrors(async (req, res) => { const comment = await deleteEntity(Comment, req.params.commentId); res.respond({ comment }); }); ================================================ FILE: api/src/controllers/issues.ts ================================================ import { Issue } from 'entities'; import { catchErrors } from 'errors'; import { updateEntity, deleteEntity, createEntity, findEntityOrThrow } from 'utils/typeorm'; export const getProjectIssues = catchErrors(async (req, res) => { const { projectId } = req.currentUser; const { searchTerm } = req.query; let whereSQL = 'issue.projectId = :projectId'; if (searchTerm) { whereSQL += ' AND (issue.title ILIKE :searchTerm OR issue.descriptionText ILIKE :searchTerm)'; } const issues = await Issue.createQueryBuilder('issue') .select() .where(whereSQL, { projectId, searchTerm: `%${searchTerm}%` }) .getMany(); res.respond({ issues }); }); export const getIssueWithUsersAndComments = catchErrors(async (req, res) => { const issue = await findEntityOrThrow(Issue, req.params.issueId, { relations: ['users', 'comments', 'comments.user'], }); res.respond({ issue }); }); export const create = catchErrors(async (req, res) => { const listPosition = await calculateListPosition(req.body); const issue = await createEntity(Issue, { ...req.body, listPosition }); res.respond({ issue }); }); export const update = catchErrors(async (req, res) => { const issue = await updateEntity(Issue, req.params.issueId, req.body); res.respond({ issue }); }); export const remove = catchErrors(async (req, res) => { const issue = await deleteEntity(Issue, req.params.issueId); res.respond({ issue }); }); const calculateListPosition = async ({ projectId, status }: Issue): Promise => { const issues = await Issue.find({ projectId, status }); const listPositions = issues.map(({ listPosition }) => listPosition); if (listPositions.length > 0) { return Math.min(...listPositions) - 1; } return 1; }; ================================================ FILE: api/src/controllers/projects.ts ================================================ import { Project } from 'entities'; import { catchErrors } from 'errors'; import { findEntityOrThrow, updateEntity } from 'utils/typeorm'; import { issuePartial } from 'serializers/issues'; export const getProjectWithUsersAndIssues = catchErrors(async (req, res) => { const project = await findEntityOrThrow(Project, req.currentUser.projectId, { relations: ['users', 'issues'], }); res.respond({ project: { ...project, issues: project.issues.map(issuePartial), }, }); }); export const update = catchErrors(async (req, res) => { const project = await updateEntity(Project, req.currentUser.projectId, req.body); res.respond({ project }); }); ================================================ FILE: api/src/controllers/test.ts ================================================ import { catchErrors } from 'errors'; import { signToken } from 'utils/authToken'; import resetTestDatabase from 'database/resetDatabase'; import createTestAccount from 'database/createTestAccount'; export const resetDatabase = catchErrors(async (_req, res) => { await resetTestDatabase(); res.respond(true); }); export const createAccount = catchErrors(async (_req, res) => { const user = await createTestAccount(); res.respond({ authToken: signToken({ sub: user.id }), }); }); ================================================ FILE: api/src/controllers/users.ts ================================================ import { catchErrors } from 'errors'; export const getCurrentUser = catchErrors((req, res) => { res.respond({ currentUser: req.currentUser }); }); ================================================ FILE: api/src/database/createConnection.ts ================================================ import { createConnection, Connection } from 'typeorm'; import * as entities from 'entities'; const createDatabaseConnection = (): Promise => createConnection({ type: 'postgres', host: process.env.DB_HOST, port: Number(process.env.DB_PORT), username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, entities: Object.values(entities), synchronize: true, }); export default createDatabaseConnection; ================================================ FILE: api/src/database/createGuestAccount.ts ================================================ import { Comment, Issue, Project, User } from 'entities'; import { ProjectCategory } from 'constants/projects'; import { IssueType, IssueStatus, IssuePriority } from 'constants/issues'; import { createEntity } from 'utils/typeorm'; const seedUsers = (): Promise => { const users = [ createEntity(User, { email: 'rick@jira.guest', name: 'Pickle Rick', avatarUrl: 'https://i.ibb.co/7JM1P2r/picke-rick.jpg', }), createEntity(User, { email: 'yoda@jira.guest', name: 'Baby Yoda', avatarUrl: 'https://i.ibb.co/6n0hLML/baby-yoda.jpg', }), createEntity(User, { email: 'gaben@jira.guest', name: 'Lord Gaben', avatarUrl: 'https://i.ibb.co/6RJ5hq6/gaben.jpg', }), ]; return Promise.all(users); }; const seedProject = (users: User[]): Promise => createEntity(Project, { name: 'singularity 1.0', url: 'https://www.atlassian.com/software/jira', description: 'Plan, track, and manage your agile and software development projects in Jira. Customize your workflow, collaborate, and release great software.', category: ProjectCategory.SOFTWARE, users, }); const seedIssues = (project: Project): Promise => { const { users } = project; const issues = [ createEntity(Issue, { title: 'This is an issue of type: Task.', type: IssueType.TASK, status: IssueStatus.BACKLOG, priority: IssuePriority.HIGH, listPosition: 1, description: `

Your teams can collaborate in Jira applications by breaking down pieces of work into issues. Issues can represent tasks, software bugs, feature requests or any other type of project work.


Jira Software (software projects) issue types:


Bug 🐞

A bug is a problem which impairs or prevents the functions of a product.


Story 📗

A user story is the smallest unit of work that needs to be done.


Task 🗳

A task represents work that needs to be done.

`, estimate: 8, timeSpent: 4, reporterId: users[1].id, project, users: [users[0]], }), createEntity(Issue, { title: "Click on an issue to see what's behind it.", type: IssueType.TASK, status: IssueStatus.BACKLOG, priority: IssuePriority.LOW, listPosition: 2, description: `

Key terms to know


Issues

A Jira 'issue' refers to a single work item of any type or size that is tracked from creation to completion. For example, an issue could be a feature being developed by a software team, a to-do item for a marketing team, or a contract that needs to be written by a legal team.


Projects

A project is, quite simply, a collection of issues that are held in common by purpose or context. Issues grouped into projects can be configured in a variety of ways, ranging from visibility restrictions to available workflows.


Workflows

Workflows represent the sequential path an issues takes from creation to completion. A basic workflow might look something like this:


Jira workflow diagram


In this case, Open, Done, and the labels in between represent the status an issue can take, while the arrows represent potential transitions from one status to another.


Agile

Agile is not a Jira Software-specific term. It's a work philosophy that originated in the software development field and has since expanded to a variety of other industries. While we won't belabor the definition here (there are great resources for that!), agile emphasizes an iterative approach to work informed by customer feedback where delivery occurs incrementally and continuously. The ideal agile team can move quickly and adapt to changing requirements without missing much of a beat.


Server

With Jira Software Server, you host Jira Software on your own hardware and customize your setup however you'd like. This is generally the best option for teams who need to manage all the details, have stricter requirements for data governance, and don't mind the additional complexity of hosting themselves.


Data Center


Data Center


With Jira Software Data Center, you can host Jira Software on your own hardware or with IaaS vendors like AWS and Azure. This is generally the best option for enterprise teams who need uninterrupted access to Jira Software and performance at scale.


`, estimate: 5, timeSpent: 2, reporterId: users[2].id, project, users: [users[0]], }), createEntity(Issue, { title: 'Try dragging issues to different columns to transition their status.', type: IssueType.STORY, status: IssueStatus.BACKLOG, priority: IssuePriority.MEDIUM, listPosition: 3, description: `

An issue's status indicates its current place in the project's workflow. Here's a list of the statuses that come with JIRA products, depending on what projects you've created on your site.


Jira software issue statuses:


Backlog

The issue is waiting to be picked up in a future sprint.


Selected

The issue is open and ready for the assignee to start work on it.


In Progress

This issue is being actively worked on at the moment by the assignee.


Done

Work has finished on the issue.

`, estimate: 15, timeSpent: 12, reporterId: users[1].id, project, }), createEntity(Issue, { title: 'You can use rich text with images in issue descriptions.', type: IssueType.STORY, status: IssueStatus.BACKLOG, priority: IssuePriority.LOWEST, listPosition: 4, description: `

🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🍈 🍒 🍑 🍍 🥭 🥥 🥝 🍅 🍆 🥑 🥦 🥒 🥬 🌶 🌽 🥕 🥔 🍠 🥐 🍞 🥖 🥨 🥯 🧀 🥚 🍳 🥞 🥓 🥩 🍗 🍖 🌭 🍔 🍟 🍕 🥪 🥙 🌮 🌯 🥗 🥘 🥫 🍝 🍜 🍲 🍛 🍣 🍱 🥟 🍤 🍙 🍚 🍘 🍥 🥮 🥠 🍢 🍡 🍧 🍨 🍦 🥧 🍰 🎂 🍮 🍭 🍬 🍫 🍿 🧂 🍩 🍪 🌰 🥜 🍯 🥛 🍼 ☕️ 🍵 🥤 🍶 🍺 🍻 🥂 🍷 🥃 🍸 🍹 🍾 🥄 🍴 🍽 🥣 🥡 🥢

`, estimate: 4, timeSpent: 4, reporterId: users[0].id, project, users: [users[2]], }), createEntity(Issue, { title: 'Each issue can be assigned priority from lowest to highest.', type: IssueType.TASK, status: IssueStatus.SELECTED, priority: IssuePriority.HIGHEST, listPosition: 5, description: `

An issue's priority indicates its relative importance. The default priorities are listed below. Both the priorities and their meanings can be customized by your administrator to suit your organization. Learn more about configuring priorities and their descriptions.


Jira software issue priorities:


Highest ⬆️

This problem will block progress.


High ⬆️

Serious problem that could block progress.


Medium ⬆️

Has the potential to affect progress.


Low ⬇️

Minor problem or easily worked around.


Lowest ⬇️

Trivial problem with little or no impact on progress.

`, estimate: 4, timeSpent: 1, reporterId: users[2].id, project, }), createEntity(Issue, { title: 'Each issue has a single reporter but can have multiple assignees.', type: IssueType.STORY, status: IssueStatus.SELECTED, priority: IssuePriority.HIGH, listPosition: 6, description: `

Try assigning Pickle Rick to this issue. 🥒 🥒 🥒


`, estimate: 6, timeSpent: 3, reporterId: users[1].id, project, users: [users[1], users[2]], }), createEntity(Issue, { title: 'You can track how many hours were spent working on an issue, and how many hours remain.', type: IssueType.TASK, status: IssueStatus.INPROGRESS, priority: IssuePriority.LOWEST, listPosition: 7, description: `

Before you start work on an issue, you can set a time or other type of estimate to calculate how much work you believe it'll take to resolve it. Once you've started to work on a specific issue, log time to keep a record of it.


  • Open the issue and select ••• > Time tracking
  • Fill in the Time Spent field
  • Fill in the Time Remaining field and click Save


That's it!

💯💯

`, estimate: 12, timeSpent: 11, reporterId: users[0].id, project, }), createEntity(Issue, { title: 'Try leaving a comment on this issue.', type: IssueType.TASK, status: IssueStatus.DONE, priority: IssuePriority.MEDIUM, listPosition: 7, description: `

Adding comments to an issue is a useful way to record additional detail about an issue, and collaborate with team members. Comments are shown in the Comments section when you view an issue.


  1. Open the issue on which to add your comment.
  2. Click the Add a comment button.
  3. (tick) Keyboard shortcutm
  4. In the Comment text box, type your comment, using as many lines as you require. (tick) 
  5. Click the Save button to save the comment.
`, estimate: 10, timeSpent: 2, reporterId: users[0].id, project, users: [users[1]], }), ]; return Promise.all(issues); }; const seedComments = (issues: Issue[], users: User[]): Promise => { const comments = [ createEntity(Comment, { body: 'An old silent pond...\nA frog jumps into the pond,\nsplash! Silence again.', issueId: issues[0].id, userId: users[2].id, }), createEntity(Comment, { body: 'Autumn moonlight-\na worm digs silently\ninto the chestnut.', issueId: issues[1].id, userId: users[2].id, }), createEntity(Comment, { body: 'In the twilight rain\nthese brilliant-hued hibiscus -\nA lovely sunset.', issueId: issues[2].id, userId: users[2].id, }), createEntity(Comment, { body: 'A summer river being crossed\nhow pleasing\nwith sandals in my hands!', issueId: issues[3].id, userId: users[2].id, }), createEntity(Comment, { body: "Light of the moon\nMoves west, flowers' shadows\nCreep eastward.", issueId: issues[4].id, userId: users[2].id, }), createEntity(Comment, { body: 'In the moonlight,\nThe color and scent of the wisteria\nSeems far away.', issueId: issues[5].id, userId: users[2].id, }), createEntity(Comment, { body: 'O snail\nClimb Mount Fuji,\nBut slowly, slowly!', issueId: issues[6].id, userId: users[2].id, }), createEntity(Comment, { body: 'Everything I touch\nwith tenderness, alas,\npricks like a bramble.', issueId: issues[7].id, userId: users[2].id, }), ]; return Promise.all(comments); }; const createGuestAccount = async (): Promise => { const users = await seedUsers(); const project = await seedProject(users); const issues = await seedIssues(project); await seedComments(issues, project.users); return users[2]; }; export default createGuestAccount; ================================================ FILE: api/src/database/createTestAccount.ts ================================================ import { Comment, Issue, Project, User } from 'entities'; import { ProjectCategory } from 'constants/projects'; import { IssueType, IssueStatus, IssuePriority } from 'constants/issues'; import { createEntity } from 'utils/typeorm'; const seedUsers = (): Promise => { const users = [ createEntity(User, { email: 'gaben@jira.test', name: 'Gaben', avatarUrl: 'https://i.ibb.co/6RJ5hq6/gaben.jpg', }), createEntity(User, { email: 'yoda@jira.test', name: 'Yoda', avatarUrl: 'https://i.ibb.co/6n0hLML/baby-yoda.jpg', }), ]; return Promise.all(users); }; const seedProject = (users: User[]): Promise => createEntity(Project, { name: 'Project name', url: 'https://www.testurl.com', description: 'Project description', category: ProjectCategory.SOFTWARE, users, }); const seedIssues = (project: Project): Promise => { const { users } = project; const issues = [ createEntity(Issue, { title: 'Issue title 1', type: IssueType.TASK, status: IssueStatus.BACKLOG, priority: IssuePriority.LOWEST, listPosition: 1, reporterId: users[0].id, project, }), createEntity(Issue, { title: 'Issue title 2', type: IssueType.TASK, status: IssueStatus.BACKLOG, priority: IssuePriority.MEDIUM, listPosition: 2, estimate: 5, description: 'Issue description 2', reporterId: users[0].id, users: [users[0]], project, }), createEntity(Issue, { title: 'Issue title 3', type: IssueType.STORY, status: IssueStatus.SELECTED, priority: IssuePriority.HIGH, listPosition: 3, estimate: 10, description: 'Issue description 3', reporterId: users[0].id, users: [users[0], users[1]], project, }), ]; return Promise.all(issues); }; const seedComments = (issue: Issue, user: User): Promise => createEntity(Comment, { body: 'Comment body', issueId: issue.id, userId: user.id, }); const createTestAccount = async (): Promise => { const users = await seedUsers(); const project = await seedProject(users); const issues = await seedIssues(project); await seedComments(issues[0], project.users[0]); return users[0]; }; export default createTestAccount; ================================================ FILE: api/src/database/resetDatabase.ts ================================================ import { getConnection } from 'typeorm'; const resetDatabase = async (): Promise => { const connection = getConnection(); await connection.dropDatabase(); await connection.synchronize(); }; export default resetDatabase; ================================================ FILE: api/src/entities/Comment.ts ================================================ import { BaseEntity, Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, } from 'typeorm'; import is from 'utils/validation'; import { Issue, User } from '.'; @Entity() class Comment extends BaseEntity { static validations = { body: [is.required(), is.maxLength(50000)], }; @PrimaryGeneratedColumn() id: number; @Column('text') body: string; @CreateDateColumn({ type: 'timestamp' }) createdAt: Date; @UpdateDateColumn({ type: 'timestamp' }) updatedAt: Date; @ManyToOne( () => User, user => user.comments, ) user: User; @Column('integer') userId: number; @ManyToOne( () => Issue, issue => issue.comments, { onDelete: 'CASCADE' }, ) issue: Issue; @Column('integer') issueId: number; } export default Comment; ================================================ FILE: api/src/entities/Issue.ts ================================================ import striptags from 'striptags'; import { BaseEntity, Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany, ManyToMany, JoinTable, RelationId, BeforeUpdate, BeforeInsert, } from 'typeorm'; import is from 'utils/validation'; import { IssueType, IssueStatus, IssuePriority } from 'constants/issues'; import { Comment, Project, User } from '.'; @Entity() class Issue extends BaseEntity { static validations = { title: [is.required(), is.maxLength(200)], type: [is.required(), is.oneOf(Object.values(IssueType))], status: [is.required(), is.oneOf(Object.values(IssueStatus))], priority: [is.required(), is.oneOf(Object.values(IssuePriority))], listPosition: is.required(), reporterId: is.required(), }; @PrimaryGeneratedColumn() id: number; @Column('varchar') title: string; @Column('varchar') type: IssueType; @Column('varchar') status: IssueStatus; @Column('varchar') priority: IssuePriority; @Column('double precision') listPosition: number; @Column('text', { nullable: true }) description: string | null; @Column('text', { nullable: true }) descriptionText: string | null; @Column('integer', { nullable: true }) estimate: number | null; @Column('integer', { nullable: true }) timeSpent: number | null; @Column('integer', { nullable: true }) timeRemaining: number | null; @CreateDateColumn({ type: 'timestamp' }) createdAt: Date; @UpdateDateColumn({ type: 'timestamp' }) updatedAt: Date; @Column('integer') reporterId: number; @ManyToOne( () => Project, project => project.issues, ) project: Project; @Column('integer') projectId: number; @OneToMany( () => Comment, comment => comment.issue, ) comments: Comment[]; @ManyToMany( () => User, user => user.issues, ) @JoinTable() users: User[]; @RelationId((issue: Issue) => issue.users) userIds: number[]; @BeforeInsert() @BeforeUpdate() setDescriptionText = (): void => { if (this.description) { this.descriptionText = striptags(this.description); } }; } export default Issue; ================================================ FILE: api/src/entities/Project.ts ================================================ import { BaseEntity, Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany, } from 'typeorm'; import is from 'utils/validation'; import { ProjectCategory } from 'constants/projects'; import { Issue, User } from '.'; @Entity() class Project extends BaseEntity { static validations = { name: [is.required(), is.maxLength(100)], url: is.url(), category: [is.required(), is.oneOf(Object.values(ProjectCategory))], }; @PrimaryGeneratedColumn() id: number; @Column('varchar') name: string; @Column('varchar', { nullable: true }) url: string | null; @Column('text', { nullable: true }) description: string | null; @Column('varchar') category: ProjectCategory; @CreateDateColumn({ type: 'timestamp' }) createdAt: Date; @UpdateDateColumn({ type: 'timestamp' }) updatedAt: Date; @OneToMany( () => Issue, issue => issue.project, ) issues: Issue[]; @OneToMany( () => User, user => user.project, ) users: User[]; } export default Project; ================================================ FILE: api/src/entities/User.ts ================================================ import { BaseEntity, Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany, ManyToMany, ManyToOne, RelationId, } from 'typeorm'; import is from 'utils/validation'; import { Comment, Issue, Project } from '.'; @Entity() class User extends BaseEntity { static validations = { name: [is.required(), is.maxLength(100)], email: [is.required(), is.email(), is.maxLength(200)], }; @PrimaryGeneratedColumn() id: number; @Column('varchar') name: string; @Column('varchar') email: string; @Column('varchar', { length: 2000 }) avatarUrl: string; @CreateDateColumn({ type: 'timestamp' }) createdAt: Date; @UpdateDateColumn({ type: 'timestamp' }) updatedAt: Date; @OneToMany( () => Comment, comment => comment.user, ) comments: Comment[]; @ManyToMany( () => Issue, issue => issue.users, ) issues: Issue[]; @ManyToOne( () => Project, project => project.users, ) project: Project; @RelationId((user: User) => user.project) projectId: number; } export default User; ================================================ FILE: api/src/entities/index.ts ================================================ export { default as Comment } from './Comment'; export { default as Issue } from './Issue'; export { default as Project } from './Project'; export { default as User } from './User'; ================================================ FILE: api/src/errors/asyncCatch.ts ================================================ import { RequestHandler } from 'express'; export const catchErrors = (requestHandler: RequestHandler): RequestHandler => { return async (req, res, next): Promise => { try { return await requestHandler(req, res, next); } catch (error) { next(error); } }; }; ================================================ FILE: api/src/errors/customErrors.ts ================================================ /* eslint-disable max-classes-per-file */ type ErrorData = { [key: string]: any }; export class CustomError extends Error { constructor( public message: string, public code: string | number = 'INTERNAL_ERROR', public status: number = 500, public data: ErrorData = {}, ) { super(); } } export class RouteNotFoundError extends CustomError { constructor(originalUrl: string) { super(`Route '${originalUrl}' does not exist.`, 'ROUTE_NOT_FOUND', 404); } } export class EntityNotFoundError extends CustomError { constructor(entityName: string) { super(`${entityName} not found.`, 'ENTITY_NOT_FOUND', 404); } } export class BadUserInputError extends CustomError { constructor(errorData: ErrorData) { super('There were validation errors.', 'BAD_USER_INPUT', 400, errorData); } } export class InvalidTokenError extends CustomError { constructor(message = 'Authentication token is invalid.') { super(message, 'INVALID_TOKEN', 401); } } ================================================ FILE: api/src/errors/index.ts ================================================ export * from './customErrors'; export { catchErrors } from './asyncCatch'; ================================================ FILE: api/src/index.ts ================================================ import 'module-alias/register'; import 'dotenv/config'; import 'reflect-metadata'; import express from 'express'; import cors from 'cors'; import createDatabaseConnection from 'database/createConnection'; import { addRespondToResponse } from 'middleware/response'; import { authenticateUser } from 'middleware/authentication'; import { handleError } from 'middleware/errors'; import { RouteNotFoundError } from 'errors'; import { attachPublicRoutes, attachPrivateRoutes } from './routes'; const establishDatabaseConnection = async (): Promise => { try { await createDatabaseConnection(); } catch (error) { console.log(error); } }; const initializeExpress = (): void => { const app = express(); app.use(cors()); app.use(express.json()); app.use(express.urlencoded()); app.use(addRespondToResponse); attachPublicRoutes(app); app.use('/', authenticateUser); attachPrivateRoutes(app); app.use((req, _res, next) => next(new RouteNotFoundError(req.originalUrl))); app.use(handleError); app.listen(process.env.PORT || 3000); }; const initializeApp = async (): Promise => { await establishDatabaseConnection(); initializeExpress(); }; initializeApp(); ================================================ FILE: api/src/middleware/authentication.ts ================================================ import { Request } from 'express'; import { verifyToken } from 'utils/authToken'; import { catchErrors, InvalidTokenError } from 'errors'; import { User } from 'entities'; export const authenticateUser = catchErrors(async (req, _res, next) => { const token = getAuthTokenFromRequest(req); if (!token) { throw new InvalidTokenError('Authentication token not found.'); } const userId = verifyToken(token).sub; if (!userId) { throw new InvalidTokenError('Authentication token is invalid.'); } const user = await User.findOne(userId); if (!user) { throw new InvalidTokenError('Authentication token is invalid: User not found.'); } req.currentUser = user; next(); }); const getAuthTokenFromRequest = (req: Request): string | null => { const header = req.get('Authorization') || ''; const [bearer, token] = header.split(' '); return bearer === 'Bearer' && token ? token : null; }; ================================================ FILE: api/src/middleware/errors.ts ================================================ import { ErrorRequestHandler } from 'express'; import { pick } from 'lodash'; import { CustomError } from 'errors'; export const handleError: ErrorRequestHandler = (error, _req, res, _next) => { console.error(error); const isErrorSafeForClient = error instanceof CustomError; const clientError = isErrorSafeForClient ? pick(error, ['message', 'code', 'status', 'data']) : { message: 'Something went wrong, please contact our support.', code: 'INTERNAL_ERROR', status: 500, data: {}, }; res.status(clientError.status).send({ error: clientError }); }; ================================================ FILE: api/src/middleware/response.ts ================================================ import { RequestHandler } from 'express'; export const addRespondToResponse: RequestHandler = (_req, res, next) => { res.respond = (data): void => { res.status(200).send(data); }; next(); }; ================================================ FILE: api/src/routes.ts ================================================ import * as authentication from 'controllers/authentication'; import * as comments from 'controllers/comments'; import * as issues from 'controllers/issues'; import * as projects from 'controllers/projects'; import * as test from 'controllers/test'; import * as users from 'controllers/users'; export const attachPublicRoutes = (app: any): void => { if (process.env.NODE_ENV === 'test') { app.delete('/test/reset-database', test.resetDatabase); app.post('/test/create-account', test.createAccount); } app.post('/authentication/guest', authentication.createGuestAccount); }; export const attachPrivateRoutes = (app: any): void => { app.post('/comments', comments.create); app.put('/comments/:commentId', comments.update); app.delete('/comments/:commentId', comments.remove); app.get('/issues', issues.getProjectIssues); app.get('/issues/:issueId', issues.getIssueWithUsersAndComments); app.post('/issues', issues.create); app.put('/issues/:issueId', issues.update); app.delete('/issues/:issueId', issues.remove); app.get('/project', projects.getProjectWithUsersAndIssues); app.put('/project', projects.update); app.get('/currentUser', users.getCurrentUser); }; ================================================ FILE: api/src/serializers/issues.ts ================================================ import { pick } from 'lodash'; import { Issue } from 'entities'; export const issuePartial = (issue: Issue): Partial => pick(issue, [ 'id', 'title', 'type', 'status', 'priority', 'listPosition', 'createdAt', 'updatedAt', 'userIds', ]); ================================================ FILE: api/src/types/env.d.ts ================================================ declare namespace NodeJS { export interface ProcessEnv { DB_HOST: string; DB_PORT: string; DB_USERNAME: string; DB_PASSWORD: string; DB_DATABASE: string; JWT_SECRET: string; } } ================================================ FILE: api/src/types/express.d.ts ================================================ declare namespace Express { export interface Response { respond: (data: any) => void; } export interface Request { currentUser: import('entities').User; } } ================================================ FILE: api/src/utils/authToken.ts ================================================ import jwt, { SignOptions } from 'jsonwebtoken'; import { isPlainObject } from 'lodash'; import { InvalidTokenError } from 'errors'; export const signToken = (payload: object, options?: SignOptions): string => jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '180 days', ...options, }); export const verifyToken = (token: string): { [key: string]: any } => { try { const payload = jwt.verify(token, process.env.JWT_SECRET); if (isPlainObject(payload)) { return payload as { [key: string]: any }; } throw new Error(); } catch (error) { throw new InvalidTokenError(); } }; ================================================ FILE: api/src/utils/typeorm.ts ================================================ import { FindOneOptions } from 'typeorm/find-options/FindOneOptions'; import { Project, User, Issue, Comment } from 'entities'; import { EntityNotFoundError, BadUserInputError } from 'errors'; import { generateErrors } from 'utils/validation'; type EntityConstructor = typeof Project | typeof User | typeof Issue | typeof Comment; type EntityInstance = Project | User | Issue | Comment; const entities: { [key: string]: EntityConstructor } = { Comment, Issue, Project, User }; export const findEntityOrThrow = async ( Constructor: T, id: number | string, options?: FindOneOptions, ): Promise> => { const instance = await Constructor.findOne(id, options); if (!instance) { throw new EntityNotFoundError(Constructor.name); } return instance; }; export const validateAndSaveEntity = async (instance: T): Promise => { const Constructor = entities[instance.constructor.name]; if ('validations' in Constructor) { const errorFields = generateErrors(instance, Constructor.validations); if (Object.keys(errorFields).length > 0) { throw new BadUserInputError({ fields: errorFields }); } } return instance.save() as Promise; }; export const createEntity = async ( Constructor: T, input: Partial>, ): Promise> => { const instance = Constructor.create(input); return validateAndSaveEntity(instance as InstanceType); }; export const updateEntity = async ( Constructor: T, id: number | string, input: Partial>, ): Promise> => { const instance = await findEntityOrThrow(Constructor, id); Object.assign(instance, input); return validateAndSaveEntity(instance); }; export const deleteEntity = async ( Constructor: T, id: number | string, ): Promise> => { const instance = await findEntityOrThrow(Constructor, id); await instance.remove(); return instance; }; ================================================ FILE: api/src/utils/validation.ts ================================================ type Value = any; type ErrorMessage = false | string; type FieldValues = { [key: string]: Value }; type Validator = (value: Value, fieldValues?: FieldValues) => ErrorMessage; type FieldValidators = { [key: string]: Validator | Validator[] }; type FieldErrors = { [key: string]: string }; const is = { match: (testFn: Function, message = '') => ( value: Value, fieldValues: FieldValues, ): ErrorMessage => !testFn(value, fieldValues) && message, required: () => (value: Value): ErrorMessage => isNilOrEmptyString(value) && 'This field is required', minLength: (min: number) => (value: Value): ErrorMessage => !!value && value.length < min && `Must be at least ${min} characters`, maxLength: (max: number) => (value: Value): ErrorMessage => !!value && value.length > max && `Must be at most ${max} characters`, oneOf: (arr: any[]) => (value: Value): ErrorMessage => !!value && !arr.includes(value) && `Must be one of: ${arr.join(', ')}`, notEmptyArray: () => (value: Value): ErrorMessage => Array.isArray(value) && value.length === 0 && 'Please add at least one item', email: () => (value: Value): ErrorMessage => !!value && !/.+@.+\..+/.test(value) && 'Must be a valid email', url: () => (value: Value): ErrorMessage => !!value && // eslint-disable-next-line no-useless-escape !/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/.test(value) && 'Must be a valid URL', }; const isNilOrEmptyString = (value: Value): boolean => value === undefined || value === null || value === ''; export const generateErrors = ( fieldValues: FieldValues, fieldValidators: FieldValidators, ): FieldErrors => { const fieldErrors: FieldErrors = {}; Object.entries(fieldValidators).forEach(([fieldName, validators]) => { [validators].flat().forEach(validator => { const errorMessage = validator(fieldValues[fieldName], fieldValues); if (errorMessage !== false && !fieldErrors[fieldName]) { fieldErrors[fieldName] = errorMessage; } }); }); return fieldErrors; }; export default is; ================================================ FILE: api/tsconfig-paths.js ================================================ const tsConfigPaths = require('tsconfig-paths'); const tsConfig = require('./tsconfig.json'); // Typescript compiler doesn't rewrite absolute paths back to relative // when compiling production code to /build. Instead we have to use // tsconfig-paths to do that job when we run our production start script. // https://github.com/microsoft/TypeScript/issues/10866 tsConfigPaths.register({ baseUrl: tsConfig.compilerOptions.outDir, paths: tsConfig.compilerOptions.paths, }); ================================================ FILE: api/tsconfig.json ================================================ { "compilerOptions": { "target": "es6", "module": "commonjs", "lib": ["dom", "es6", "es2017", "es2019", "esnext.asynciterable"], "sourceMap": true, "outDir": "./build", "removeComments": true, "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": false, "noImplicitThis": true, "alwaysStrict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": false, "noFallthroughCasesInSwitch": true, "moduleResolution": "node", "baseUrl": "src", "paths": { "*": ["./*"] }, "types": ["node"], "allowSyntheticDefaultImports": true, "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "forceConsistentCasingInFileNames": true }, "exclude": ["node_modules"], "include": ["./src/**/*.ts"] } ================================================ FILE: client/.babelrc ================================================ { "presets": [ [ "@babel/preset-env", { "useBuiltIns": "entry", "corejs": 3 } ], "@babel/react" ], "plugins": [ ["@babel/plugin-proposal-decorators", { "legacy": true }], "@babel/plugin-proposal-export-namespace-from", "@babel/plugin-syntax-dynamic-import", ["@babel/plugin-proposal-class-properties", { "loose": true }] ] } ================================================ FILE: client/.eslintrc.json ================================================ { "parser": "babel-eslint", "parserOptions": { "sourceType": "module", "ecmaVersion": 8, "ecmaFeatures": { "jsx": true } }, "env": { "browser": true, "jest": true }, "extends": ["airbnb", "prettier", "prettier/react"], "plugins": ["react-hooks"], "rules": { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "radix": 0, "no-restricted-syntax": 0, "no-await-in-loop": 0, "no-console": 0, "consistent-return": 0, "no-param-reassign": [2, { "props": false }], "no-return-assign": [2, "except-parens"], "no-use-before-define": 0, "import/prefer-default-export": 0, "import/no-cycle": 0, "react/no-array-index-key": 0, "react/forbid-prop-types": 0, "react/prop-types": [2, { "skipUndeclared": true }], "react/jsx-fragments": [2, "element"], "react/state-in-constructor": 0, "react/jsx-props-no-spreading": 0, "jsx-a11y/click-events-have-key-events": 0 }, "settings": { // Allows us to lint absolute imports within codebase "import/resolver": { "node": { "moduleDirectory": ["node_modules", "src/"] } } } } ================================================ FILE: client/.prettierrc ================================================ { "printWidth": 100, "singleQuote": true, "trailingComma": "all" } ================================================ FILE: client/README.md ================================================ # Project structure 🏗 I've used this architecture on multiple larger projects in the past and it performed really well. There are two special root folders in `src`: `App` and `shared` (described below). All other root folders in `src` (in our case only two: `Auth` and `Project`) should follow the structure of the routes. We can call these folders modules. The main rule to follow: **Files from one module can only import from ancestor folders within the same module or from `src/shared`.** This makes the codebase easier to understand, and if you're fiddling with code in one module, you will never introduce a bug in another module.
| File or folder | Description | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/index.jsx` | The entry file. This is where we import babel polyfills and render the App into the root DOM node. | | `src/index.html` | The only HTML file in our App. All scripts and styles will be injected here by Webpack. | | `src/App` | Main application routes, components that need to be mounted at all times regardless of current route, global css styles, fonts, etc. Basically anything considered global / ancestor of all modules. | | `src/Auth` | Authentication module | | `src/Project` | Project module | | `src/shared` | Components, constants, utils, hooks, styles etc. that can be used anywhere in the codebase. Any module is allowed to import from shared. | ================================================ FILE: client/cypress/.eslintrc.json ================================================ { "extends": ["plugin:cypress/recommended"], "rules": { "no-unused-expressions": 0 // chai assertions trigger this rule } } ================================================ FILE: client/cypress/integration/authentication.spec.js ================================================ import { testid } from '../support/utils'; describe('Authentication', () => { beforeEach(() => { cy.resetDatabase(); cy.visit('/'); }); it('creates guest account if user has no auth token', () => { cy.window() .its('localStorage.authToken') .should('be.undefined'); cy.window() .its('localStorage.authToken') .should('be.a', 'string') .and('not.be.empty'); cy.get(testid`list-issue`).should('have.length', 8); }); }); ================================================ FILE: client/cypress/integration/issueCreate.spec.js ================================================ import { testid } from '../support/utils'; describe('Issue create', () => { beforeEach(() => { cy.resetDatabase(); cy.createTestAccount(); cy.visit('/project/settings?modal-issue-create=true'); }); it('validates form and creates issue successfully', () => { cy.get(testid`modal:issue-create`).within(() => { cy.get('button[type="submit"]').click(); cy.get(testid`form-field:title`).should('contain', 'This field is required'); cy.selectOption('type', 'Story'); cy.get('input[name="title"]').type('TEST_TITLE'); cy.get('.ql-editor').type('TEST_DESCRIPTION'); cy.selectOption('reporterId', 'Yoda'); cy.selectOption('userIds', 'Gaben', 'Yoda'); cy.selectOption('priority', 'High'); cy.get('button[type="submit"]').click(); }); cy.get(testid`modal:issue-create`).should('not.exist'); cy.contains('Issue has been successfully created.').should('exist'); cy.location('pathname').should('equal', '/project/board'); cy.location('search').should('be.empty'); cy.contains(testid`list-issue`, 'TEST_TITLE') .should('have.descendants', testid`avatar:Gaben`) .and('have.descendants', testid`avatar:Yoda`) .and('have.descendants', testid`icon:story`); }); }); ================================================ FILE: client/cypress/integration/issueDetails.spec.js ================================================ import { testid } from '../support/utils'; describe('Issue details', () => { beforeEach(() => { cy.resetDatabase(); cy.createTestAccount(); cy.visit('/project/board'); getListIssue().click(); // open issue details modal }); it('updates type, status, assignees, reporter, priority successfully', () => { getIssueDetailsModal().within(() => { cy.selectOption('type', 'Story'); cy.selectOption('status', 'Done'); cy.selectOption('assignees', 'Gaben', 'Yoda'); cy.selectOption('reporter', 'Yoda'); cy.selectOption('priority', 'Medium'); }); cy.assertReloadAssert(() => { getIssueDetailsModal().within(() => { cy.selectShouldContain('type', 'Story'); cy.selectShouldContain('status', 'Done'); cy.selectShouldContain('assignees', 'Gaben', 'Yoda'); cy.selectShouldContain('reporter', 'Yoda'); cy.selectShouldContain('priority', 'Medium'); }); getListIssue() .should('have.descendants', testid`avatar:Gaben`) .and('have.descendants', testid`avatar:Yoda`) .and('have.descendants', testid`icon:story`); }); }); it('updates title, description successfully', () => { getIssueDetailsModal().within(() => { cy.get('textarea[placeholder="Short summary"]') .clear() .type('TEST_TITLE') .blur(); cy.contains('Add a description...') .click() .should('not.exist'); cy.get('.ql-editor').type('TEST_DESCRIPTION'); cy.contains('button', 'Save') .click() .should('not.exist'); }); cy.assertReloadAssert(() => { getIssueDetailsModal().within(() => { cy.get('textarea[placeholder="Short summary"]').should('have.value', 'TEST_TITLE'); cy.get('.ql-editor').should('contain', 'TEST_DESCRIPTION'); }); cy.get(testid`list-issue`).should('contain', 'TEST_TITLE'); }); }); it('updates estimate, time tracking successfully', () => { getIssueDetailsModal().within(() => { getNumberInputAtIndex(0).debounced('type', '10'); cy.contains('10h estimated').click(); // open tracking modal }); cy.get(testid`modal:tracking`).within(() => { cy.contains('No time logged').should('exist'); getNumberInputAtIndex(0).debounced('type', 1); cy.get('div[width="10"]').should('exist'); // tracking bar getNumberInputAtIndex(1).debounced('type', 2); cy.contains('button', 'Done') .click() .should('not.exist'); }); cy.assertReloadAssert(() => { getIssueDetailsModal().within(() => { getNumberInputAtIndex(0).should('have.value', '10'); cy.contains('1h logged').should('exist'); cy.contains('2h remaining').should('exist'); cy.get('div[width*="33.3333"]').should('exist'); }); }); }); it('deletes an issue successfully', () => { getIssueDetailsModal() .find(`button ${testid`icon:trash`}`) .click(); cy.get(testid`modal:confirm`) .contains('button', 'Delete issue') .click(); cy.assertReloadAssert(() => { getIssueDetailsModal().should('not.exist'); getListIssue().should('not.exist'); }); }); it('creates a comment successfully', () => { getIssueDetailsModal().within(() => { cy.contains('Add a comment...') .click() .should('not.exist'); cy.get('textarea[placeholder="Add a comment..."]').type('TEST_COMMENT'); cy.contains('button', 'Save') .click() .should('not.exist'); cy.contains('Add a comment...').should('exist'); cy.get(testid`issue-comment`).should('contain', 'TEST_COMMENT'); }); }); it('edits a comment successfully', () => { getIssueDetailsModal().within(() => { cy.get(testid`issue-comment`) .contains('Edit') .click() .should('not.exist'); cy.get('textarea[placeholder="Add a comment..."]') .should('have.value', 'Comment body') .clear() .type('TEST_COMMENT_EDITED'); cy.contains('button', 'Save') .click() .should('not.exist'); cy.get(testid`issue-comment`) .should('contain', 'Edit') .and('contain', 'TEST_COMMENT_EDITED'); }); }); it('deletes a comment successfully', () => { getIssueDetailsModal() .find(testid`issue-comment`) .contains('Delete') .click(); cy.get(testid`modal:confirm`) .contains('button', 'Delete comment') .click() .should('not.exist'); getIssueDetailsModal() .find(testid`issue-comment`) .should('not.exist'); }); const getIssueDetailsModal = () => cy.get(testid`modal:issue-details`); const getListIssue = () => cy.contains(testid`list-issue`, 'Issue title 1'); const getNumberInputAtIndex = index => cy.get('input[placeholder="Number"]').eq(index); }); ================================================ FILE: client/cypress/integration/issueFilters.spec.js ================================================ import { testid } from '../support/utils'; describe('Issue filters', () => { beforeEach(() => { cy.resetDatabase(); cy.createTestAccount(); cy.visit('/project/board'); }); it('filters issues', () => { getSearchInput().debounced('type', 'Issue title 1'); assertIssuesCount(1); getSearchInput().debounced('clear'); assertIssuesCount(3); getUserAvatar().click(); assertIssuesCount(2); getUserAvatar().click(); assertIssuesCount(3); getMyOnlyButton().click(); assertIssuesCount(2); getMyOnlyButton().click(); assertIssuesCount(3); getRecentButton().click(); assertIssuesCount(3); }); const getSearchInput = () => cy.get(testid`board-filters`).find('input'); const getUserAvatar = () => cy.get(testid`board-filters`).find(testid`avatar:Gaben`); const getMyOnlyButton = () => cy.get(testid`board-filters`).contains('Only My Issues'); const getRecentButton = () => cy.get(testid`board-filters`).contains('Recently Updated'); const assertIssuesCount = count => cy.get(testid`list-issue`).should('have.length', count); }); ================================================ FILE: client/cypress/integration/issueSearch.spec.js ================================================ import { testid } from '../support/utils'; describe('Issue search', () => { beforeEach(() => { cy.resetDatabase(); cy.createTestAccount(); cy.visit('/project/board?modal-issue-search=true'); }); it('displays recent issues if search input is empty', () => { getIssueSearchModal().within(() => { cy.contains('Recent Issues').should('exist'); getIssues().should('have.length', 3); cy.get('input').debounced('type', 'anything'); cy.contains('Recent Issues').should('not.exist'); cy.get('input').debounced('clear'); cy.contains('Recent Issues').should('exist'); getIssues().should('have.length', 3); }); }); it('displays matching issues successfully', () => { getIssueSearchModal().within(() => { cy.get('input').debounced('type', 'Issue'); getIssues().should('have.length', 3); cy.get('input').debounced('type', ' description'); getIssues().should('have.length', 2); cy.get('input').debounced('type', ' 3'); getIssues().should('have.length', 1); cy.contains('Matching Issues').should('exist'); }); }); it('displays message if no results were found', () => { getIssueSearchModal().within(() => { cy.get('input').debounced('type', 'gibberish'); getIssues().should('not.exist'); cy.contains("We couldn't find anything matching your search").should('exist'); }); }); const getIssueSearchModal = () => cy.get(testid`modal:issue-search`); const getIssues = () => cy.get('a[href*="/project/board/issues/"]'); }); ================================================ FILE: client/cypress/integration/issuesDragDrop.spec.js ================================================ import { KeyCodes } from 'shared/constants/keyCodes'; import { testid } from '../support/utils'; describe('Issues drag & drop', () => { beforeEach(() => { cy.resetDatabase(); cy.createTestAccount(); cy.visit('/project/board'); }); it('moves issue between different lists', () => { cy.get(testid`board-list:backlog`).should('contain', firstIssueTitle); cy.get(testid`board-list:selected`).should('not.contain', firstIssueTitle); moveFirstIssue(KeyCodes.ARROW_RIGHT); cy.assertReloadAssert(() => { cy.get(testid`board-list:backlog`).should('not.contain', firstIssueTitle); cy.get(testid`board-list:selected`).should('contain', firstIssueTitle); }); }); it('moves issue within a single list', () => { getIssueAtIndex(0).should('contain', firstIssueTitle); getIssueAtIndex(1).should('contain', secondIssueTitle); moveFirstIssue(KeyCodes.ARROW_DOWN); cy.assertReloadAssert(() => { getIssueAtIndex(0).should('contain', secondIssueTitle); getIssueAtIndex(1).should('contain', firstIssueTitle); }); }); const firstIssueTitle = 'Issue title 1'; const secondIssueTitle = 'Issue title 2'; const getIssueAtIndex = index => cy.get(testid`list-issue`).eq(index); const moveFirstIssue = directionKeyCode => { cy.waitForXHR('PUT', '/issues/**', () => { getIssueAtIndex(0) .focus() .trigger('keydown', { keyCode: KeyCodes.SPACE }) .trigger('keydown', { keyCode: directionKeyCode, force: true }) .trigger('keydown', { keyCode: KeyCodes.SPACE, force: true }); }); }; }); ================================================ FILE: client/cypress/integration/projectSettings.spec.js ================================================ import { testid } from '../support/utils'; describe('Project settings', () => { beforeEach(() => { cy.resetDatabase(); cy.createTestAccount(); cy.visit('/project/settings'); }); it('should display current values in form', () => { cy.get('input[name="name"]').should('have.value', 'Project name'); cy.get('input[name="url"]').should('have.value', 'https://www.testurl.com'); cy.get('.ql-editor').should('contain', 'Project description'); cy.selectShouldContain('category', 'Software'); }); it('validates form and updates project successfully', () => { cy.get('input[name="name"]').clear(); cy.get('button[type="submit"]').click(); cy.get(testid`form-field:name`).should('contain', 'This field is required'); cy.get('input[name="name"]').type('TEST_NAME'); cy.get(testid`form-field:name`).should('not.contain', 'This field is required'); cy.selectOption('category', 'Business'); cy.get('button[type="submit"]').click(); cy.contains('Changes have been saved successfully.').should('exist'); cy.reload(); cy.get('input[name="name"]').should('have.value', 'TEST_NAME'); cy.selectShouldContain('category', 'Business'); }); }); ================================================ FILE: client/cypress/plugins/index.js ================================================ /* eslint-disable global-require */ /* eslint-disable import/no-extraneous-dependencies */ // *********************************************************** // This example plugins/index.js can be used to load plugins // // You can change the location of this file or turn off loading // the plugins file with the 'pluginsFile' configuration option. // // You can read more here: // https://on.cypress.io/plugins-guide // *********************************************************** // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) const webpack = require('@cypress/webpack-preprocessor'); const webpackOptions = require('../../webpack.config.js'); module.exports = on => { on('file:preprocessor', webpack({ webpackOptions })); }; ================================================ FILE: client/cypress/support/commands.js ================================================ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import '@4tw/cypress-drag-drop'; import { objectToQueryString } from 'shared/utils/url'; import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken'; import { testid } from './utils'; Cypress.Commands.add('selectOption', (selectName, ...optionLabels) => { optionLabels.forEach(optionLabel => { cy.get(testid`select:${selectName}`).click('bottomRight'); cy.get(testid`select-option:${optionLabel}`).click(); }); }); Cypress.Commands.add('selectShouldContain', (selectName, ...optionLabels) => { optionLabels.forEach(optionLabel => { cy.get(testid`select:${selectName}`).should('contain', optionLabel); }); }); // We don't want to waste time when running tests on cypress waiting for debounced // inputs. We can use tick() to speed up time and trigger onChange immediately. Cypress.Commands.add('debounced', { prevSubject: true }, (input, action, value) => { cy.clock(); cy.wrap(input)[action](value); cy.tick(1000); }); // Sometimes cypress fails to properly wait for api requests to finish which results // in flaky tests, and in those cases we need to explicitly tell it to wait // https://docs.cypress.io/guides/guides/network-requests.html#Flake Cypress.Commands.add('waitForXHR', (method, url, funcThatTriggersXHR) => { const alias = method + url; cy.server(); cy.route(method, url).as(alias); funcThatTriggersXHR(); cy.wait(`@${alias}`); }); // We're using optimistic updates (not waiting for API response before updating // the local data and UI) in a lot of places in the app. That's why we want to assert // both the immediate local UI change in the first assert, and if the change was // successfully persisted by the API in the second assert after page reload Cypress.Commands.add('assertReloadAssert', assertFunc => { assertFunc(); cy.reload(); assertFunc(); }); Cypress.Commands.add('apiRequest', (method, url, variables = {}, options = {}) => { cy.request({ method, url: `${Cypress.env('apiBaseUrl')}${url}`, qs: method === 'GET' ? objectToQueryString(variables) : undefined, body: method !== 'GET' ? variables : undefined, headers: { 'Content-Type': 'application/json', Authorization: getStoredAuthToken() ? `Bearer ${getStoredAuthToken()}` : undefined, }, ...options, }); }); Cypress.Commands.add('resetDatabase', () => { cy.apiRequest('DELETE', '/test/reset-database'); }); Cypress.Commands.add('createTestAccount', () => { cy.apiRequest('POST', '/test/create-account').then(response => { storeAuthToken(response.body.authToken); }); }); ================================================ FILE: client/cypress/support/index.js ================================================ // *********************************************************** // This example support/index.js is processed and // loaded automatically before your test files. // // This is a great place to put global configuration and // behavior that modifies Cypress. // // You can change the location of this file or turn off // automatically serving support files with the // 'supportFile' configuration option. // // You can read more here: // https://on.cypress.io/configuration // *********************************************************** import './commands'; ================================================ FILE: client/cypress/support/utils.js ================================================ export const testid = (strings, ...values) => { const id = strings.map((str, index) => str + (values[index] || '')).join(''); return `[data-testid="${id}"]`; }; ================================================ FILE: client/cypress.json ================================================ { "baseUrl": "http://localhost:8080", "viewportHeight": 800, "viewportWidth": 1440, "env": { "apiBaseUrl": "http://localhost:3000" } } ================================================ FILE: client/jest/fileMock.js ================================================ module.exports = 'test-file-stub'; ================================================ FILE: client/jest/styleMock.js ================================================ module.exports = {}; ================================================ FILE: client/jest.config.js ================================================ module.exports = { moduleFileExtensions: ['*', 'js', 'jsx'], moduleDirectories: ['src', 'node_modules'], moduleNameMapper: { '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/jest/fileMock.js', '\\.(css|scss|less)$': '/jest/styleMock.js', }, }; ================================================ FILE: client/jsconfig.json ================================================ // This config allows VSCode intellisense to work with absolute "src" imports and jsx files { "compilerOptions": { "baseUrl": "./src", "jsx": "react" }, "include": ["src/**/*", "cypress/**/*.js", "./node_modules/cypress"] } ================================================ FILE: client/package.json ================================================ { "name": "jira_client", "version": "1.0.0", "author": "Ivor Reic", "license": "MIT", "scripts": { "start": "webpack-dev-server", "start:production": "pm2 start --name 'jira_client' server.js", "test:jest": "jest", "test:cypress": "node_modules/.bin/cypress open", "build": "rm -rf build && webpack --config webpack.config.production.js --progress", "pre-commit": "lint-staged" }, "devDependencies": { "@babel/core": "^7.7.4", "@babel/plugin-proposal-class-properties": "^7.7.4", "@babel/plugin-proposal-decorators": "^7.7.4", "@babel/plugin-proposal-export-namespace-from": "^7.7.4", "@babel/plugin-syntax-dynamic-import": "^7.7.4", "@babel/preset-env": "^7.7.4", "@babel/preset-react": "^7.7.4", "@cypress/webpack-preprocessor": "^4.1.1", "babel-eslint": "^10.0.3", "babel-loader": "^8.0.6", "css-loader": "^3.3.2", "cypress": "^3.8.1", "eslint": "^6.1.0", "eslint-config-airbnb": "^18.0.1", "eslint-config-prettier": "^6.7.0", "eslint-plugin-cypress": "^2.8.1", "eslint-plugin-import": "^2.18.2", "eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-react": "^7.17.0", "eslint-plugin-react-hooks": "^1.7.0", "file-loader": "^5.0.2", "html-webpack-plugin": "^3.2.0", "jest": "^24.9.0", "lint-staged": "^9.5.0", "prettier": "^1.19.1", "style-loader": "^1.0.1", "url-loader": "^3.0.0", "webpack": "^4.41.2", "webpack-cli": "^3.3.10", "webpack-dev-server": "^3.9.0" }, "dependencies": { "@4tw/cypress-drag-drop": "^1.3.0", "axios": "^0.19.0", "color": "^3.1.2", "compression": "^1.7.4", "core-js": "^3.4.7", "express": "^4.17.1", "express-history-api-fallback": "^2.2.1", "formik": "^2.1.1", "history": "^4.10.1", "jwt-decode": "^2.2.0", "lodash": "^4.17.15", "moment": "^2.24.0", "prop-types": "^15.7.2", "query-string": "^6.9.0", "quill": "^1.3.7", "react": "^16.12.0", "react-beautiful-dnd": "^12.2.0", "react-content-loader": "^4.3.3", "react-dom": "^16.12.0", "react-router-dom": "^5.1.2", "react-textarea-autosize": "^7.1.2", "react-transition-group": "^4.3.0", "regenerator-runtime": "^0.13.3", "styled-components": "^4.4.1", "sweet-pubsub": "^1.1.2" }, "lint-staged": { "*.{js,jsx}": [ "eslint --fix", "prettier --write", "git add" ] } } ================================================ FILE: client/server.js ================================================ const express = require('express'); const fallback = require('express-history-api-fallback'); const compression = require('compression'); const app = express(); app.use(compression()); app.use(express.static(`${__dirname}/build`)); app.use(fallback(`${__dirname}/build/index.html`)); app.listen(process.env.PORT || 8081); ================================================ FILE: client/src/App/BaseStyles.js ================================================ import { createGlobalStyle } from 'styled-components'; import { color, font, mixin } from 'shared/utils/styles'; export default createGlobalStyle` html, body, #root { height: 100%; min-height: 100%; min-width: 768px; } body { color: ${color.textDarkest}; -webkit-tap-highlight-color: transparent; line-height: 1.2; ${font.size(16)} ${font.regular} } #root { display: flex; flex-direction: column; } button, input, optgroup, select, textarea { ${font.regular} } *, *:after, *:before, input[type="search"] { box-sizing: border-box; } a { color: inherit; text-decoration: none; } ul { list-style: none; } ul, li, ol, dd, h1, h2, h3, h4, h5, h6, p { padding: 0; margin: 0; } h1, h2, h3, h4, h5, h6, strong { ${font.bold} } button { background: none; border: none; } /* Workaround for IE11 focus highlighting for select elements */ select::-ms-value { background: none; color: #42413d; } [role="button"], button, input, select, textarea { outline: none; &:focus { outline: none; } &:disabled { opacity: 1; } } [role="button"], button, input, textarea { appearance: none; } select:-moz-focusring { color: transparent; text-shadow: 0 0 0 #000; } select::-ms-expand { display: none; } select option { color: ${color.textDarkest}; } p { line-height: 1.4285; a { ${mixin.link()} } } textarea { line-height: 1.4285; } body, select { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } html { touch-action: manipulation; } ${mixin.placeholderColor(color.textLight)} `; ================================================ FILE: client/src/App/NormalizeStyles.js ================================================ import { createGlobalStyle } from 'styled-components'; /** DO NOT ALTER THIS FILE. It is a copy of https://necolas.github.io/normalize.css/ */ export default createGlobalStyle` html { line-height: 1.15; -webkit-text-size-adjust: 100%; } body { margin: 0; } main { display: block; } h1 { font-size: 2em; margin: 0.67em 0; } hr { box-sizing: content-box; height: 0; overflow: visible; } pre { font-family: monospace, monospace; font-size: 1em; } a { background-color: transparent; } abbr[title] { border-bottom: none; text-decoration: underline; text-decoration: underline dotted; } b, strong { font-weight: bolder; } code, kbd, samp { font-family: monospace, monospace; font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } img { border-style: none; } button, input, optgroup, select, textarea { font-family: inherit; font-size: 100%; line-height: 1.15; margin: 0; } button, input { overflow: visible; } button, select { text-transform: none; } button, [type="button"], [type="reset"], [type="submit"] { -webkit-appearance: button; } button::-moz-focus-inner, [type="button"]::-moz-focus-inner, [type="reset"]::-moz-focus-inner, [type="submit"]::-moz-focus-inner { border-style: none; padding: 0; } button:-moz-focusring, [type="button"]:-moz-focusring, [type="reset"]:-moz-focusring, [type="submit"]:-moz-focusring { outline: 1px dotted ButtonText; } fieldset { padding: 0.35em 0.75em 0.625em; } legend { box-sizing: border-box; color: inherit; display: table; max-width: 100%; padding: 0; white-space: normal; } progress { vertical-align: baseline; } textarea { overflow: auto; } [type="checkbox"], [type="radio"] { box-sizing: border-box; padding: 0; } [type="number"]::-webkit-inner-spin-button, [type="number"]::-webkit-outer-spin-button { height: auto; } [type="search"] { -webkit-appearance: textfield; outline-offset: -2px; } [type="search"]::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-file-upload-button { -webkit-appearance: button; font: inherit; } details { display: block; } summary { display: list-item; } template { display: none; } [hidden] { display: none; } `; ================================================ FILE: client/src/App/Routes.jsx ================================================ import React from 'react'; import { Router, Switch, Route, Redirect } from 'react-router-dom'; import history from 'browserHistory'; import Project from 'Project'; import Authenticate from 'Auth/Authenticate'; import PageError from 'shared/components/PageError'; const Routes = () => ( ); export default Routes; ================================================ FILE: client/src/App/Toast/Styles.js ================================================ import styled from 'styled-components'; import { color, font, mixin, zIndexValues } from 'shared/utils/styles'; import { Icon } from 'shared/components'; export const Container = styled.div` z-index: ${zIndexValues.modal + 1}; position: fixed; right: 30px; top: 50px; `; export const StyledToast = styled.div` position: relative; margin-bottom: 5px; width: 300px; padding: 15px 20px; border-radius: 3px; color: #fff; background: ${props => color[props.type]}; cursor: pointer; transition: all 0.15s; ${mixin.clearfix} ${mixin.hardwareAccelerate} &.jira-toast-enter, &.jira-toast-exit.jira-toast-exit-active { opacity: 0; right: -10px; } &.jira-toast-exit, &.jira-toast-enter.jira-toast-enter-active { opacity: 1; right: 0; } `; export const CloseIcon = styled(Icon)` position: absolute; top: 13px; right: 14px; font-size: 22px; cursor: pointer; color: #fff; `; export const Title = styled.div` padding-right: 22px; ${font.size(15)} ${font.medium} `; export const Message = styled.div` padding: 8px 10px 0 0; white-space: pre-wrap; ${font.size(14)} ${font.medium} `; ================================================ FILE: client/src/App/Toast/index.jsx ================================================ import React, { useState, useEffect } from 'react'; import { CSSTransition, TransitionGroup } from 'react-transition-group'; import pubsub from 'sweet-pubsub'; import { uniqueId } from 'lodash'; import { Container, StyledToast, CloseIcon, Title, Message } from './Styles'; const Toast = () => { const [toasts, setToasts] = useState([]); useEffect(() => { const addToast = ({ type = 'success', title, message, duration = 5 }) => { const id = uniqueId('toast-'); setToasts(currentToasts => [...currentToasts, { id, type, title, message }]); if (duration) { setTimeout(() => removeToast(id), duration * 1000); } }; pubsub.on('toast', addToast); return () => { pubsub.off('toast', addToast); }; }, []); const removeToast = id => { setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id)); }; return ( {toasts.map(toast => ( removeToast(toast.id)}> {toast.title && {toast.title}} {toast.message && {toast.message}} ))} ); }; export default Toast; ================================================ FILE: client/src/App/fontStyles.css ================================================ @font-face { font-family: 'CircularStdBlack'; src: url('./assets/fonts/CircularStd-Black.woff2') format('woff2'), url('./assets/fonts/CircularStd-Black.woff') format('woff'); font-weight: normal; font-style: normal; } @font-face { font-family: 'CircularStdBold'; src: url('./assets/fonts/CircularStd-Bold.woff2') format('woff2'), url('./assets/fonts/CircularStd-Bold.woff') format('woff'); font-weight: normal; font-style: normal; } @font-face { font-family: 'CircularStdMedium'; src: url('./assets/fonts/CircularStd-Medium.woff2') format('woff2'), url('./assets/fonts/CircularStd-Medium.woff') format('woff'); font-weight: normal; font-style: normal; } @font-face { font-family: 'CircularStdBook'; src: url('./assets/fonts/CircularStd-Book.woff2') format('woff2'), url('./assets/fonts/CircularStd-Book.woff') format('woff'); font-weight: normal; font-style: normal; } @font-face { font-family: 'jira'; src: url('./assets/fonts/jira.woff') format('truetype'), url('./assets/fonts/jira.ttf') format('woff'), url('./assets/fonts/jira.svg#jira') format('svg'); font-weight: normal; font-style: normal; } ================================================ FILE: client/src/App/index.jsx ================================================ import React, { Fragment } from 'react'; import NormalizeStyles from './NormalizeStyles'; import BaseStyles from './BaseStyles'; import Toast from './Toast'; import Routes from './Routes'; // We're importing .css because @font-face in styled-components causes font files // to be constantly re-requested from the server (which causes screen flicker) // https://github.com/styled-components/styled-components/issues/1593 import './fontStyles.css'; const App = () => ( ); export default App; ================================================ FILE: client/src/Auth/Authenticate.jsx ================================================ import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import api from 'shared/utils/api'; import toast from 'shared/utils/toast'; import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken'; import { PageLoader } from 'shared/components'; const Authenticate = () => { const history = useHistory(); useEffect(() => { const createGuestAccount = async () => { try { const { authToken } = await api.post('/authentication/guest'); storeAuthToken(authToken); history.push('/'); } catch (error) { toast.error(error); } }; if (!getStoredAuthToken()) { createGuestAccount(); } }, [history]); return ; }; export default Authenticate; ================================================ FILE: client/src/Project/Board/Filters/Styles.js ================================================ import styled from 'styled-components'; import { color, font, mixin } from 'shared/utils/styles'; import { InputDebounced, Avatar, Button } from 'shared/components'; export const Filters = styled.div` display: flex; align-items: center; margin-top: 24px; `; export const SearchInput = styled(InputDebounced)` margin-right: 18px; width: 160px; `; export const Avatars = styled.div` display: flex; flex-direction: row-reverse; margin: 0 12px 0 2px; `; export const AvatarIsActiveBorder = styled.div` display: inline-flex; margin-left: -2px; border-radius: 50%; transition: transform 0.1s; ${mixin.clickable}; ${props => props.isActive && `box-shadow: 0 0 0 4px ${color.primary}`} &:hover { transform: translateY(-5px); } `; export const StyledAvatar = styled(Avatar)` box-shadow: 0 0 0 2px #fff; `; export const StyledButton = styled(Button)` margin-left: 6px; `; export const ClearAll = styled.div` height: 32px; line-height: 32px; margin-left: 15px; padding-left: 12px; border-left: 1px solid ${color.borderLightest}; color: ${color.textDark}; ${font.size(14.5)} ${mixin.clickable} &:hover { color: ${color.textMedium}; } `; ================================================ FILE: client/src/Project/Board/Filters/index.jsx ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { xor } from 'lodash'; import { Filters, SearchInput, Avatars, AvatarIsActiveBorder, StyledAvatar, StyledButton, ClearAll, } from './Styles'; const propTypes = { projectUsers: PropTypes.array.isRequired, defaultFilters: PropTypes.object.isRequired, filters: PropTypes.object.isRequired, mergeFilters: PropTypes.func.isRequired, }; const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, mergeFilters }) => { const { searchTerm, userIds, myOnly, recent } = filters; const areFiltersCleared = !searchTerm && userIds.length === 0 && !myOnly && !recent; return ( mergeFilters({ searchTerm: value })} /> {projectUsers.map(user => ( mergeFilters({ userIds: xor(userIds, [user.id]) })} /> ))} mergeFilters({ myOnly: !myOnly })} > Only My Issues mergeFilters({ recent: !recent })} > Recently Updated {!areFiltersCleared && ( mergeFilters(defaultFilters)}>Clear all )} ); }; ProjectBoardFilters.propTypes = propTypes; export default ProjectBoardFilters; ================================================ FILE: client/src/Project/Board/Header/Styles.js ================================================ import styled from 'styled-components'; import { font } from 'shared/utils/styles'; export const Header = styled.div` margin-top: 6px; display: flex; justify-content: space-between; `; export const BoardName = styled.div` ${font.size(24)} ${font.medium} `; ================================================ FILE: client/src/Project/Board/Header/index.jsx ================================================ import React from 'react'; import { Button } from 'shared/components'; import { Header, BoardName } from './Styles'; const ProjectBoardHeader = () => (
Kanban board
); export default ProjectBoardHeader; ================================================ FILE: client/src/Project/Board/IssueDetails/AssigneesReporter/Styles.js ================================================ import styled, { css } from 'styled-components'; import { color, font, mixin } from 'shared/utils/styles'; export const User = styled.div` display: flex; align-items: center; ${mixin.clickable} ${props => props.isSelectValue && css` margin: 0 10px ${props.withBottomMargin ? 5 : 0}px 0; padding: 4px 8px; border-radius: 4px; background: ${color.backgroundLight}; transition: background 0.1s; &:hover { background: ${color.backgroundMedium}; } `} `; export const Username = styled.div` padding: 0 3px 0 8px; ${font.size(14.5)} `; ================================================ FILE: client/src/Project/Board/IssueDetails/AssigneesReporter/index.jsx ================================================ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { Avatar, Select, Icon } from 'shared/components'; import { SectionTitle } from '../Styles'; import { User, Username } from './Styles'; const propTypes = { issue: PropTypes.object.isRequired, updateIssue: PropTypes.func.isRequired, projectUsers: PropTypes.array.isRequired, }; const ProjectBoardIssueDetailsAssigneesReporter = ({ issue, updateIssue, projectUsers }) => { const getUserById = userId => projectUsers.find(user => user.id === userId); const userOptions = projectUsers.map(user => ({ value: user.id, label: user.name })); return ( Assignees updateIssue({ reporterId: userId })} renderValue={({ value: userId }) => renderUser(getUserById(userId), true)} renderOption={({ value: userId }) => renderUser(getUserById(userId))} /> ); }; const renderUser = (user, isSelectValue, removeOptionValue) => ( removeOptionValue && removeOptionValue()} > {user.name} {removeOptionValue && } ); ProjectBoardIssueDetailsAssigneesReporter.propTypes = propTypes; export default ProjectBoardIssueDetailsAssigneesReporter; ================================================ FILE: client/src/Project/Board/IssueDetails/Comments/BodyForm/Styles.js ================================================ import styled from 'styled-components'; import { Button } from 'shared/components'; export const Actions = styled.div` display: flex; padding-top: 10px; `; export const FormButton = styled(Button)` margin-right: 6px; `; ================================================ FILE: client/src/Project/Board/IssueDetails/Comments/BodyForm/index.jsx ================================================ import React, { Fragment, useRef } from 'react'; import PropTypes from 'prop-types'; import { Textarea } from 'shared/components'; import { Actions, FormButton } from './Styles'; const propTypes = { value: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, isWorking: PropTypes.bool.isRequired, onSubmit: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, }; const ProjectBoardIssueDetailsCommentsBodyForm = ({ value, onChange, isWorking, onSubmit, onCancel, }) => { const $textareaRef = useRef(); const handleSubmit = () => { if ($textareaRef.current.value.trim()) { onSubmit(); } }; return (