diff --git a/api/src/controllers/issues.ts b/api/src/controllers/issues.ts index 24ed695..bab172f 100644 --- a/api/src/controllers/issues.ts +++ b/api/src/controllers/issues.ts @@ -9,7 +9,9 @@ const router = express.Router(); router.get( '/issues/:issueId', catchErrors(async (req, res) => { - const issue = await findEntityOrThrow(Issue, req.params.issueId); + const issue = await findEntityOrThrow(Issue, req.params.issueId, { + relations: ['users', 'comments'], + }); res.respond({ issue }); }), ); diff --git a/api/src/entities/Comment.ts b/api/src/entities/Comment.ts index 963adfb..a96d193 100644 --- a/api/src/entities/Comment.ts +++ b/api/src/entities/Comment.ts @@ -42,6 +42,7 @@ class Comment extends BaseEntity { @ManyToOne( () => Issue, issue => issue.comments, + { onDelete: 'CASCADE' }, ) issue: Issue; } diff --git a/api/src/entities/Issue.ts b/api/src/entities/Issue.ts index acff764..4f30a81 100644 --- a/api/src/entities/Issue.ts +++ b/api/src/entities/Issue.ts @@ -23,7 +23,7 @@ class Issue extends BaseEntity { type: [is.required(), is.oneOf(Object.values(IssueType))], status: [is.required(), is.oneOf(Object.values(IssueStatus))], priority: [is.required(), is.oneOf(Object.values(IssuePriority))], - description: is.maxLength(100000), + reporterId: is.required(), }; @PrimaryGeneratedColumn() diff --git a/client/package-lock.json b/client/package-lock.json index 943806f..740e5c4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -2212,6 +2212,11 @@ } } }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=" + }, "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -2599,6 +2604,34 @@ "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", "integrity": "sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU=" }, + "css-loader": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.3.2.tgz", + "integrity": "sha512-4XSiURS+YEK2fQhmSaM1onnUm0VKWNf6WWBYjkp9YbSDGCBTVZ5XOM6Gkxo8tLgQlzkZOBJvk9trHlDk4gjEYg==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "cssesc": "^3.0.0", + "icss-utils": "^4.1.1", + "loader-utils": "^1.2.3", + "normalize-path": "^3.0.0", + "postcss": "^7.0.23", + "postcss-modules-extract-imports": "^2.0.0", + "postcss-modules-local-by-default": "^3.0.2", + "postcss-modules-scope": "^2.1.1", + "postcss-modules-values": "^3.0.0", + "postcss-value-parser": "^4.0.2", + "schema-utils": "^2.6.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.2.tgz", + "integrity": "sha512-LmeoohTpp/K4UiyQCwuGWlONxXamGzCMtFxLq4W1nZVGIQLYvMCJx3yAF9qyyuFpflABI9yVdtJAqbihOsCsJQ==", + "dev": true + } + } + }, "css-select": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", @@ -2627,6 +2660,12 @@ "integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==", "dev": true }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, "csstype": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.7.tgz", @@ -2679,7 +2718,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", - "dev": true, "requires": { "is-arguments": "^1.0.4", "is-date-object": "^1.0.1", @@ -2709,7 +2747,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", - "dev": true, "requires": { "object-keys": "^1.0.12" } @@ -3721,6 +3758,11 @@ } } }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "extend-shallow": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", @@ -3824,6 +3866,11 @@ "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", "dev": true }, + "fast-diff": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", + "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==" + }, "fast-glob": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.1.1.tgz", @@ -4784,8 +4831,7 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "functional-red-black-tree": { "version": "1.0.1", @@ -4943,7 +4989,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -5266,6 +5311,15 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "icss-utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz", + "integrity": "sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==", + "dev": true, + "requires": { + "postcss": "^7.0.14" + } + }, "ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", @@ -5324,6 +5378,12 @@ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true }, + "indexes-of": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", + "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", + "dev": true + }, "infer-owner": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", @@ -5496,8 +5556,7 @@ "is-arguments": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", - "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==", - "dev": true + "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==" }, "is-arrayish": { "version": "0.2.1", @@ -5549,8 +5608,7 @@ "is-date-object": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", - "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", - "dev": true + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=" }, "is-descriptor": { "version": "0.1.6", @@ -5682,7 +5740,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", - "dev": true, "requires": { "has": "^1.0.1" } @@ -6910,14 +6967,12 @@ "object-is": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.0.1.tgz", - "integrity": "sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=", - "dev": true + "integrity": "sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=" }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" }, "object-visit": { "version": "1.0.1", @@ -7206,6 +7261,11 @@ "no-case": "^2.2.0" } }, + "parchment": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", + "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7407,6 +7467,94 @@ "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", "dev": true }, + "postcss": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.24.tgz", + "integrity": "sha512-Xl0XvdNWg+CblAXzNvbSOUvgJXwSjmbAKORqyw9V2AlHrm1js2gFw9y3jibBAhpKZi8b5JzJCVh/FyzPsTtgTA==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "postcss-modules-extract-imports": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz", + "integrity": "sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==", + "dev": true, + "requires": { + "postcss": "^7.0.5" + } + }, + "postcss-modules-local-by-default": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.2.tgz", + "integrity": "sha512-jM/V8eqM4oJ/22j0gx4jrp63GSvDH6v86OqyTHHUvk4/k1vceipZsaymiZ5PvocqZOl5SFHiFJqjs3la0wnfIQ==", + "dev": true, + "requires": { + "icss-utils": "^4.1.1", + "postcss": "^7.0.16", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.2.tgz", + "integrity": "sha512-LmeoohTpp/K4UiyQCwuGWlONxXamGzCMtFxLq4W1nZVGIQLYvMCJx3yAF9qyyuFpflABI9yVdtJAqbihOsCsJQ==", + "dev": true + } + } + }, + "postcss-modules-scope": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-2.1.1.tgz", + "integrity": "sha512-OXRUPecnHCg8b9xWvldG/jUpRIGPNRka0r4D4j0ESUU2/5IOnpsjfPPmDprM3Ih8CgZ8FXjWqaniK5v4rWt3oQ==", + "dev": true, + "requires": { + "postcss": "^7.0.6", + "postcss-selector-parser": "^6.0.0" + } + }, + "postcss-modules-values": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz", + "integrity": "sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==", + "dev": true, + "requires": { + "icss-utils": "^4.0.0", + "postcss": "^7.0.6" + } + }, + "postcss-selector-parser": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz", + "integrity": "sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + }, "postcss-value-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", @@ -7577,6 +7725,36 @@ "integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==", "dev": true }, + "quill": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz", + "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==", + "requires": { + "clone": "^2.1.1", + "deep-equal": "^1.0.1", + "eventemitter3": "^2.0.3", + "extend": "^3.0.2", + "parchment": "^1.1.4", + "quill-delta": "^3.6.2" + }, + "dependencies": { + "eventemitter3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "integrity": "sha1-teEHm1n7XhuidxwKmTvgYKWMmbo=" + } + } + }, + "quill-delta": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", + "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", + "requires": { + "deep-equal": "^1.0.1", + "extend": "^3.0.2", + "fast-diff": "1.1.2" + } + }, "raf-schd": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz", @@ -7651,6 +7829,11 @@ "use-memo-one": "^1.1.1" } }, + "react-content-loader": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/react-content-loader/-/react-content-loader-4.3.3.tgz", + "integrity": "sha512-9DdPa6rt013sO5Z2TBnRLExSBVPMwcFP9nxFKQXyQHo71/xR4nfiQqQvFFI92XQlahILYZPr+S/W5hWuRmbYjg==" + }, "react-dom": { "version": "16.12.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.12.0.tgz", @@ -7912,7 +8095,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.2.0.tgz", "integrity": "sha512-ztaw4M1VqgMwl9HlPpOuiYgItcHlunW0He2fE6eNfT6E/CF2FtYi9ofOYe4mKntstYk0Fyh/rDRBdS3AnxjlrA==", - "dev": true, "requires": { "define-properties": "^1.1.2" } @@ -8990,6 +9172,16 @@ "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", "dev": true }, + "style-loader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.0.1.tgz", + "integrity": "sha512-CnpEkSR1C+REjudiTWCv4+ssP7SCiuaQZJTZDWBRwTJoS90mdqkB8uOGMHKgVeUzpaU7IfLWoyQbvvs5Joj3Xw==", + "dev": true, + "requires": { + "loader-utils": "^1.2.3", + "schema-utils": "^2.0.1" + } + }, "styled-components": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-4.4.1.tgz", @@ -9365,6 +9557,12 @@ "set-value": "^2.0.1" } }, + "uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", + "dev": true + }, "unique-filename": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", diff --git a/client/package.json b/client/package.json index 2b9c7c1..7465ca1 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,7 @@ "@babel/preset-react": "^7.7.4", "babel-eslint": "^10.0.3", "babel-loader": "^8.0.6", + "css-loader": "^3.3.2", "eslint": "^6.1.0", "eslint-config-airbnb": "^18.0.1", "eslint-config-prettier": "^6.7.0", @@ -29,6 +30,7 @@ "html-webpack-plugin": "^3.2.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", @@ -44,12 +46,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" }, diff --git a/client/src/components/App/App.jsx b/client/src/components/App/App.jsx index 51cea4d..7734795 100644 --- a/client/src/components/App/App.jsx +++ b/client/src/components/App/App.jsx @@ -1,15 +1,18 @@ import React from 'react'; import NormalizeStyles from './NormalizeStyles'; -import FontStyles from './FontStyles'; 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 = () => ( <> - diff --git a/client/src/components/App/Authenticate.jsx b/client/src/components/App/Authenticate.jsx index 5299c5d..a8fef30 100644 --- a/client/src/components/App/Authenticate.jsx +++ b/client/src/components/App/Authenticate.jsx @@ -1,27 +1,28 @@ import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; -import useApi from 'shared/hooks/api'; +import toast from 'shared/utils/toast'; +import api from 'shared/utils/api'; import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken'; import { PageLoader } from 'shared/components'; const Authenticate = () => { - const [{ data }, createGuestAccount] = useApi.post('/authentication/guest'); - const history = useHistory(); useEffect(() => { - if (!getStoredAuthToken()) { - createGuestAccount(); - } - }, [createGuestAccount]); - - useEffect(() => { - if (data && data.authToken) { - storeAuthToken(data.authToken); - history.push('/'); - } - }, [data, history]); + const createGuestAccount = async () => { + if (!getStoredAuthToken()) { + try { + const { authToken } = await api.post('/authentication/guest'); + storeAuthToken(authToken); + history.push('/'); + } catch (error) { + toast.error(error); + } + } + }; + createGuestAccount(); + }, [history]); return ; }; diff --git a/client/src/components/App/BaseStyles.js b/client/src/components/App/BaseStyles.js index 07423b1..ca2969c 100644 --- a/client/src/components/App/BaseStyles.js +++ b/client/src/components/App/BaseStyles.js @@ -34,6 +34,7 @@ export default createGlobalStyle` } a, a:hover, a:visited, a:active { + color: inherit; text-decoration: none; } @@ -92,7 +93,7 @@ export default createGlobalStyle` } textarea { - line-height: 1.6; + line-height: 1.4285; } body, select { diff --git a/client/src/components/App/FontStyles.js b/client/src/components/App/FontStyles.js deleted file mode 100644 index b74d56e..0000000 --- a/client/src/components/App/FontStyles.js +++ /dev/null @@ -1,53 +0,0 @@ -import { createGlobalStyle } from 'styled-components'; - -import BlackWoff2 from 'shared/assets/fonts/CircularStd-Black.woff2'; -import BoldWoff2 from 'shared/assets/fonts/CircularStd-Bold.woff2'; -import MediumWoff2 from 'shared/assets/fonts/CircularStd-Medium.woff2'; -import BookWoff2 from 'shared/assets/fonts/CircularStd-Book.woff2'; -import BlackWoff from 'shared/assets/fonts/CircularStd-Black.woff'; -import BoldWoff from 'shared/assets/fonts/CircularStd-Bold.woff'; -import MediumWoff from 'shared/assets/fonts/CircularStd-Medium.woff'; -import BookWoff from 'shared/assets/fonts/CircularStd-Book.woff'; -import IconsSvg from 'shared/assets/icons/jira.svg'; -import IconsTtf from 'shared/assets/icons/jira.ttf'; -import IconsWoff from 'shared/assets/icons/jira.woff'; - -export default createGlobalStyle` - @font-face { - font-family: "CircularStdBlack"; - src: url("${BlackWoff2}") format("woff2"), - url("${BlackWoff}") format("woff"); - font-weight: normal; - font-style: normal; - } - @font-face { - font-family: "CircularStdBold"; - src: url("${BoldWoff2}") format("woff2"), - url("${BoldWoff}") format("woff"); - font-weight: normal; - font-style: normal; - } - @font-face { - font-family: "CircularStdMedium"; - src: url("${MediumWoff2}") format("woff2"), - url("${MediumWoff}") format("woff"); - font-weight: normal; - font-style: normal; - } - @font-face { - font-family: "CircularStdBook"; - src: url("${BookWoff2}") format("woff2"), - url("${BookWoff}") format("woff"); - font-weight: normal; - font-style: normal; - } - @font-face { - font-family: "jira"; - src: - url("${IconsTtf}") format("truetype"), - url("${IconsWoff}") format("woff"), - url("${IconsSvg}#jira") format("svg"); - font-weight: normal; - font-style: normal; - } -`; diff --git a/client/src/shared/assets/fonts/CircularStd-Black.eot b/client/src/components/App/assets/fonts/CircularStd-Black.eot similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Black.eot rename to client/src/components/App/assets/fonts/CircularStd-Black.eot diff --git a/client/src/shared/assets/fonts/CircularStd-Black.otf b/client/src/components/App/assets/fonts/CircularStd-Black.otf similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Black.otf rename to client/src/components/App/assets/fonts/CircularStd-Black.otf diff --git a/client/src/shared/assets/fonts/CircularStd-Black.svg b/client/src/components/App/assets/fonts/CircularStd-Black.svg similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Black.svg rename to client/src/components/App/assets/fonts/CircularStd-Black.svg diff --git a/client/src/shared/assets/fonts/CircularStd-Black.ttf b/client/src/components/App/assets/fonts/CircularStd-Black.ttf similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Black.ttf rename to client/src/components/App/assets/fonts/CircularStd-Black.ttf diff --git a/client/src/shared/assets/fonts/CircularStd-Black.woff b/client/src/components/App/assets/fonts/CircularStd-Black.woff similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Black.woff rename to client/src/components/App/assets/fonts/CircularStd-Black.woff diff --git a/client/src/shared/assets/fonts/CircularStd-Black.woff2 b/client/src/components/App/assets/fonts/CircularStd-Black.woff2 similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Black.woff2 rename to client/src/components/App/assets/fonts/CircularStd-Black.woff2 diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.eot b/client/src/components/App/assets/fonts/CircularStd-Bold.eot similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Bold.eot rename to client/src/components/App/assets/fonts/CircularStd-Bold.eot diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.otf b/client/src/components/App/assets/fonts/CircularStd-Bold.otf similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Bold.otf rename to client/src/components/App/assets/fonts/CircularStd-Bold.otf diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.svg b/client/src/components/App/assets/fonts/CircularStd-Bold.svg similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Bold.svg rename to client/src/components/App/assets/fonts/CircularStd-Bold.svg diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.ttf b/client/src/components/App/assets/fonts/CircularStd-Bold.ttf similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Bold.ttf rename to client/src/components/App/assets/fonts/CircularStd-Bold.ttf diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.woff b/client/src/components/App/assets/fonts/CircularStd-Bold.woff similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Bold.woff rename to client/src/components/App/assets/fonts/CircularStd-Bold.woff diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.woff2 b/client/src/components/App/assets/fonts/CircularStd-Bold.woff2 similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Bold.woff2 rename to client/src/components/App/assets/fonts/CircularStd-Bold.woff2 diff --git a/client/src/shared/assets/fonts/CircularStd-Book.eot b/client/src/components/App/assets/fonts/CircularStd-Book.eot similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Book.eot rename to client/src/components/App/assets/fonts/CircularStd-Book.eot diff --git a/client/src/shared/assets/fonts/CircularStd-Book.otf b/client/src/components/App/assets/fonts/CircularStd-Book.otf similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Book.otf rename to client/src/components/App/assets/fonts/CircularStd-Book.otf diff --git a/client/src/shared/assets/fonts/CircularStd-Book.svg b/client/src/components/App/assets/fonts/CircularStd-Book.svg similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Book.svg rename to client/src/components/App/assets/fonts/CircularStd-Book.svg diff --git a/client/src/shared/assets/fonts/CircularStd-Book.ttf b/client/src/components/App/assets/fonts/CircularStd-Book.ttf similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Book.ttf rename to client/src/components/App/assets/fonts/CircularStd-Book.ttf diff --git a/client/src/shared/assets/fonts/CircularStd-Book.woff b/client/src/components/App/assets/fonts/CircularStd-Book.woff similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Book.woff rename to client/src/components/App/assets/fonts/CircularStd-Book.woff diff --git a/client/src/shared/assets/fonts/CircularStd-Book.woff2 b/client/src/components/App/assets/fonts/CircularStd-Book.woff2 similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Book.woff2 rename to client/src/components/App/assets/fonts/CircularStd-Book.woff2 diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.eot b/client/src/components/App/assets/fonts/CircularStd-Medium.eot similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Medium.eot rename to client/src/components/App/assets/fonts/CircularStd-Medium.eot diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.otf b/client/src/components/App/assets/fonts/CircularStd-Medium.otf similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Medium.otf rename to client/src/components/App/assets/fonts/CircularStd-Medium.otf diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.svg b/client/src/components/App/assets/fonts/CircularStd-Medium.svg similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Medium.svg rename to client/src/components/App/assets/fonts/CircularStd-Medium.svg diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.ttf b/client/src/components/App/assets/fonts/CircularStd-Medium.ttf similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Medium.ttf rename to client/src/components/App/assets/fonts/CircularStd-Medium.ttf diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.woff b/client/src/components/App/assets/fonts/CircularStd-Medium.woff similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Medium.woff rename to client/src/components/App/assets/fonts/CircularStd-Medium.woff diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.woff2 b/client/src/components/App/assets/fonts/CircularStd-Medium.woff2 similarity index 100% rename from client/src/shared/assets/fonts/CircularStd-Medium.woff2 rename to client/src/components/App/assets/fonts/CircularStd-Medium.woff2 diff --git a/client/src/shared/assets/icons/jira.svg b/client/src/components/App/assets/fonts/jira.svg similarity index 90% rename from client/src/shared/assets/icons/jira.svg rename to client/src/components/App/assets/fonts/jira.svg index d22007a..e13a8e4 100755 --- a/client/src/shared/assets/icons/jira.svg +++ b/client/src/components/App/assets/fonts/jira.svg @@ -25,10 +25,10 @@ - + - + \ No newline at end of file diff --git a/client/src/shared/assets/icons/jira.ttf b/client/src/components/App/assets/fonts/jira.ttf similarity index 61% rename from client/src/shared/assets/icons/jira.ttf rename to client/src/components/App/assets/fonts/jira.ttf index ba30d7a..c4c92ab 100755 Binary files a/client/src/shared/assets/icons/jira.ttf and b/client/src/components/App/assets/fonts/jira.ttf differ diff --git a/client/src/shared/assets/icons/jira.woff b/client/src/components/App/assets/fonts/jira.woff similarity index 61% rename from client/src/shared/assets/icons/jira.woff rename to client/src/components/App/assets/fonts/jira.woff index 9cf0871..492fb82 100755 Binary files a/client/src/shared/assets/icons/jira.woff and b/client/src/components/App/assets/fonts/jira.woff differ diff --git a/client/src/components/App/fontStyles.css b/client/src/components/App/fontStyles.css new file mode 100644 index 0000000..20ef0b2 --- /dev/null +++ b/client/src/components/App/fontStyles.css @@ -0,0 +1,35 @@ +@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; +} diff --git a/client/src/components/Project/Board/Filters/Styles.js b/client/src/components/Project/Board/Filters/Styles.js index b2f7433..65de5f4 100644 --- a/client/src/components/Project/Board/Filters/Styles.js +++ b/client/src/components/Project/Board/Filters/Styles.js @@ -1,6 +1,6 @@ import styled from 'styled-components'; -import { Input, Avatar, Button } from 'shared/components'; +import { InputDebounced, Avatar, Button } from 'shared/components'; import { color, font, mixin } from 'shared/utils/styles'; export const Filters = styled.div` @@ -9,7 +9,7 @@ export const Filters = styled.div` margin-top: 24px; `; -export const SearchInput = styled(Input)` +export const SearchInput = styled(InputDebounced)` margin-right: 18px; width: 160px; `; diff --git a/client/src/components/Project/Board/Filters/index.jsx b/client/src/components/Project/Board/Filters/index.jsx index b272496..db76dc4 100644 --- a/client/src/components/Project/Board/Filters/index.jsx +++ b/client/src/components/Project/Board/Filters/index.jsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { xor, debounce } from 'lodash'; +import { xor } from 'lodash'; import { Filters, @@ -30,7 +30,8 @@ const ProjectBoardFilters = ({ projectUsers, defaultFilters, filters, setFilters setFiltersMerge({ searchQuery: value }), 500)} + value={searchQuery} + onChange={value => setFiltersMerge({ searchQuery: value })} /> {projectUsers.map(user => ( diff --git a/client/src/components/Project/Board/Header/index.jsx b/client/src/components/Project/Board/Header/index.jsx index f9774f8..9323ddc 100644 --- a/client/src/components/Project/Board/Header/index.jsx +++ b/client/src/components/Project/Board/Header/index.jsx @@ -11,6 +11,12 @@ const propTypes = { const ProjectBoardHeader = ({ projectName }) => { const [isLinkCopied, setLinkCopied] = useState(false); + + const handleLinkCopy = () => { + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 2000); + copyToClipboard(window.location.href); + }; return ( <> @@ -22,14 +28,7 @@ const ProjectBoardHeader = ({ projectName }) => {
Kanban board -
diff --git a/client/src/components/Project/Board/IssueDetails/Description/Styles.js b/client/src/components/Project/Board/IssueDetails/Description/Styles.js new file mode 100644 index 0000000..38c4d9c --- /dev/null +++ b/client/src/components/Project/Board/IssueDetails/Description/Styles.js @@ -0,0 +1,30 @@ +import styled from 'styled-components'; + +import { color, font, mixin } from 'shared/utils/styles'; + +export const DescriptionLabel = styled.div` + padding: 20px 0 6px; + ${font.size(15)} + ${font.medium} +`; + +export const EmptyLabel = styled.div` + margin-left: -7px; + padding: 7px; + border-radius: 3px; + color: ${color.textMedium} + transition: background 0.1s; + ${font.size(15)} + ${mixin.clickable} + &:hover { + background: ${color.backgroundLight}; + } +`; + +export const Actions = styled.div` + display: flex; + padding-top: 12px; + & > button { + margin-right: 6px; + } +`; diff --git a/client/src/components/Project/Board/IssueDetails/Description/index.jsx b/client/src/components/Project/Board/IssueDetails/Description/index.jsx new file mode 100644 index 0000000..5388681 --- /dev/null +++ b/client/src/components/Project/Board/IssueDetails/Description/index.jsx @@ -0,0 +1,60 @@ +import React, { useRef, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { getTextContentsFromHtmlString } from 'shared/utils/html'; +import { TextEditor, TextEditedContent, Button } from 'shared/components'; +import { DescriptionLabel, EmptyLabel, Actions } from './Styles'; + +const propTypes = { + issue: PropTypes.object.isRequired, + updateIssue: PropTypes.func.isRequired, +}; + +const ProjectBoardIssueDetailsDescription = ({ issue, updateIssue }) => { + const $editorRef = useRef(); + const [isPresenting, setPresenting] = useState(true); + + const isDescriptionEmpty = description => + getTextContentsFromHtmlString(description).trim().length === 0; + + const renderPresentingMode = () => + isDescriptionEmpty(issue.description) ? ( + setPresenting(false)}>Add a description... + ) : ( + setPresenting(false)} /> + ); + + const renderEditingMode = () => ( + <> + ($editorRef.current = editor)} + /> + + + + + + ); + return ( + <> + Description + {isPresenting ? renderPresentingMode() : renderEditingMode()} + + ); +}; + +ProjectBoardIssueDetailsDescription.propTypes = propTypes; + +export default ProjectBoardIssueDetailsDescription; diff --git a/client/src/components/Project/Board/IssueDetails/Loader.jsx b/client/src/components/Project/Board/IssueDetails/Loader.jsx new file mode 100644 index 0000000..f8fed75 --- /dev/null +++ b/client/src/components/Project/Board/IssueDetails/Loader.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import ContentLoader from 'react-content-loader'; + +const IssueDetailsLoader = () => ( +
+ + + + + + + + + + + + + + + + +
+); + +export default IssueDetailsLoader; diff --git a/client/src/components/Project/Board/IssueDetails/RightActions/Styles.js b/client/src/components/Project/Board/IssueDetails/RightActions/Styles.js new file mode 100644 index 0000000..e751f50 --- /dev/null +++ b/client/src/components/Project/Board/IssueDetails/RightActions/Styles.js @@ -0,0 +1,146 @@ +import styled, { css } from 'styled-components'; + +import { + color, + issueStatusColors, + issueStatusBackgroundColors, + font, + mixin, +} from 'shared/utils/styles'; +import { Icon } from 'shared/components'; + +export const SectionTitle = styled.div` + margin: 24px 0 5px; + text-transform: uppercase; + color: ${color.textMedium}; + ${font.size(12.5)} + ${font.bold} +`; + +export const User = styled.div` + display: inline-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}; + `} +`; + +export const UserName = styled.div` + padding: 0 3px 0 8px; + ${font.size(14.5)} +`; + +export const StatusOption = styled.div` + padding: 8px 16px; + &.jira-select-option-is-active { + background: ${color.backgroundLightPrimary}; + } +`; + +export const Status = styled.div` + text-transform: uppercase; + ${props => mixin.tag(issueStatusBackgroundColors[props.color], issueStatusColors[props.color])} + ${props => props.isLarge && `padding: 9px 14px 8px;`} +`; + +export const UserOptionCont = styled.div` + padding: 8px 12px 5px; + ${mixin.clickable} + &.jira-select-option-is-active { + background: ${color.backgroundLightPrimary}; + } +`; + +export const Priority = styled.div` + display: flex; + align-items: center; +`; + +export const PriorityOption = styled.div` + padding: 8px 12px; + ${mixin.clickable} + &.jira-select-option-is-active { + background: ${color.backgroundLightPrimary}; + } +`; + +export const PriorityLabel = styled.div` + text-transform: capitalize; + padding: 0 3px 0 8px; + ${font.size(14.5)} +`; + +export const Tracking = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 2px; + ${mixin.clickable}; +`; + +export const TrackingIcon = styled(Icon)` + color: ${color.textMedium}; +`; + +export const TrackingRight = styled.div` + width: 90%; +`; + +export const TrackingBarCont = styled.div` + height: 5px; + border-radius: 4px; + background: ${color.backgroundLight}; +`; + +export const TrackingBar = styled.div` + height: 5px; + border-radius: 4px; + background: ${color.primary}; + transition: all 0.1s; + width: ${props => props.width}%; +`; + +export const TrackingValues = styled.div` + display: flex; + justify-content: space-between; + padding-top: 3px; + ${font.size(14.5)}; +`; + +export const TrackingModalContents = styled.div` + padding: 20px; +`; + +export const TrackingModalTitle = styled.div` + padding-bottom: 14px; + ${font.medium} + ${font.size(20)} +`; + +export const Inputs = styled.div` + display: flex; + margin: 20px -5px 30px; +`; + +export const InputCont = styled.div` + margin: 0 5px; + width: 50%; +`; + +export const InputLabel = styled.div` + padding-bottom: 5px; + color: ${color.textMedium}; + ${font.medium}; + ${font.size(13)}; +`; + +export const Actions = styled.div` + display: flex; + justify-content: flex-end; +`; diff --git a/client/src/components/Project/Board/IssueDetails/RightActions/index.jsx b/client/src/components/Project/Board/IssueDetails/RightActions/index.jsx new file mode 100644 index 0000000..00e92dc --- /dev/null +++ b/client/src/components/Project/Board/IssueDetails/RightActions/index.jsx @@ -0,0 +1,267 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { invert, isNil } from 'lodash'; + +import { IssueStatus, IssueStatusCopy, IssuePriority } from 'shared/constants/issues'; +import { + Avatar, + Select, + Icon, + InputDebounced, + IssuePriorityIcon, + Modal, + Button, +} from 'shared/components'; +import { + SectionTitle, + User, + UserName, + Status, + StatusOption, + UserOptionCont, + Priority, + PriorityOption, + PriorityLabel, + Tracking, + TrackingIcon, + TrackingRight, + TrackingBarCont, + TrackingBar, + TrackingValues, + TrackingModalContents, + TrackingModalTitle, + Inputs, + InputCont, + InputLabel, + Actions, +} from './Styles'; + +const IssuePriorityCopy = invert(IssuePriority); + +const propTypes = { + issue: PropTypes.object.isRequired, + updateIssue: PropTypes.func.isRequired, + projectUsers: PropTypes.array.isRequired, +}; + +const ProjectBoardIssueDetailsRightActions = ({ issue, updateIssue, projectUsers }) => { + const getUserById = userId => projectUsers.find(user => user.id === parseInt(userId)); + + const userOptions = projectUsers.map(user => ({ value: user.id, label: user.name })); + + const renderHourInput = fieldName => ( + { + const value = stringValue.trim() ? parseInt(stringValue) : null; + updateIssue({ [fieldName]: value }); + }} + /> + ); + + const renderStatus = () => ( + <> + Status + { + updateIssue({ userIds, users: userIds.map(getUserById) }); + }} + renderValue={({ value, removeOptionValue }) => + renderUserValue(getUserById(value), true, removeOptionValue) + } + renderOption={({ value, ...optionProps }) => ( + {renderUserOption(getUserById(value))} + )} + /> + + ); + + const renderReporter = () => ( + <> + Reporter + ({ + value: priority, + label: IssuePriorityCopy[priority], + }))} + onChange={priority => updateIssue({ priority })} + renderValue={({ value }) => renderPriorityItem(value)} + renderOption={({ value, ...optionProps }) => ( + {renderPriorityItem(value)} + )} + /> + + ); + + const calculateTrackingBarWidth = () => { + const { timeSpent, timeRemaining, estimate } = issue; + + if (!timeSpent) { + return 0; + } + if (isNil(timeRemaining) && isNil(estimate)) { + return 100; + } + if (!isNil(timeRemaining)) { + return (timeSpent / (timeSpent + timeRemaining)) * 100; + } + if (!isNil(estimate)) { + return Math.min((timeSpent / estimate) * 100, 100); + } + }; + + const renderRemainingOrEstimate = () => { + const { timeRemaining, estimate } = issue; + + if (isNil(timeRemaining) && isNil(estimate)) { + return null; + } + if (!isNil(timeRemaining)) { + return
{`${timeRemaining}h remaining`}
; + } + if (!isNil(estimate)) { + return
{`${estimate}h estimated`}
; + } + }; + + const renderTrackingPreview = (onClick = () => {}) => ( + + + + + + + +
{issue.timeSpent ? `${issue.timeSpent}h logged` : 'No time logged'}
+ {renderRemainingOrEstimate()} +
+
+
+ ); + + const renderTracking = () => ( + <> + Time Tracking + renderTrackingPreview(modal.open)} + renderContent={modal => ( + + Time tracking + {renderTrackingPreview()} + + + Time spent (hours) + {renderHourInput('timeSpent')} + + + Time remaining (hours) + {renderHourInput('timeRemaining')} + + + + + + + )} + /> + + ); + + return ( + <> + {renderStatus()} + {renderAssignees()} + {renderReporter()} + {renderEstimate()} + {renderPriority()} + {renderTracking()} + + ); +}; + +ProjectBoardIssueDetailsRightActions.propTypes = propTypes; + +export default ProjectBoardIssueDetailsRightActions; diff --git a/client/src/components/Project/Board/IssueDetails/Styles.js b/client/src/components/Project/Board/IssueDetails/Styles.js new file mode 100644 index 0000000..7504306 --- /dev/null +++ b/client/src/components/Project/Board/IssueDetails/Styles.js @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +export const Content = styled.div` + display: flex; + padding: 0 30px 30px; +`; + +export const Left = styled.div` + width: 65%; + padding-right: 50px; +`; + +export const Right = styled.div` + width: 35%; + padding-top: 5px; +`; diff --git a/client/src/components/Project/Board/IssueDetails/Title/Styles.js b/client/src/components/Project/Board/IssueDetails/Title/Styles.js new file mode 100644 index 0000000..8dda6b6 --- /dev/null +++ b/client/src/components/Project/Board/IssueDetails/Title/Styles.js @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +import { color, font } from 'shared/utils/styles'; +import { Textarea } from 'shared/components'; + +export const TitleTextarea = styled(Textarea)` + margin: 18px 0 0 -8px; + height: 44px; + width: 100%; + textarea { + background: #fff; + border: none; + resize: none; + line-height: 1.28; + border: 1px solid transparent; + box-shadow: 0 0 0 1px transparent; + transition: background 0.1s; + ${font.size(24)} + ${font.medium} + &:hover:not(:focus) { + background: ${color.backgroundLight}; + } + } +`; + +export const ErrorText = styled.div` + padding-top: 4px; + color: ${color.danger}; + ${font.size(13)} + ${font.medium} +`; diff --git a/client/src/components/Project/Board/IssueDetails/Title/index.jsx b/client/src/components/Project/Board/IssueDetails/Title/index.jsx new file mode 100644 index 0000000..715209b --- /dev/null +++ b/client/src/components/Project/Board/IssueDetails/Title/index.jsx @@ -0,0 +1,53 @@ +import React, { useRef, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { KeyCodes } from 'shared/constants/keyCodes'; +import { is, generateErrors } from 'shared/utils/validation'; +import { TitleTextarea, ErrorText } from './Styles'; + +const propTypes = { + issue: PropTypes.object.isRequired, + updateIssue: PropTypes.func.isRequired, +}; + +const ProjectBoardIssueDetailsTitle = ({ issue, updateIssue }) => { + const $titleInputRef = useRef(); + const [error, setError] = useState(null); + + const handleTitleChange = () => { + setError(null); + + const title = $titleInputRef.current.value; + if (title === issue.title) return; + + const errors = generateErrors({ title }, { title: [is.required(), is.maxLength(200)] }); + + if (errors.title) { + setError(errors.title); + } else { + updateIssue({ title }); + } + }; + + return ( + <> + { + if (event.keyCode === KeyCodes.ENTER) { + event.target.blur(); + } + }} + /> + {error && {error}} + + ); +}; + +ProjectBoardIssueDetailsTitle.propTypes = propTypes; + +export default ProjectBoardIssueDetailsTitle; diff --git a/client/src/components/Project/Board/IssueDetails/TopActions/Styles.js b/client/src/components/Project/Board/IssueDetails/TopActions/Styles.js new file mode 100644 index 0000000..9ded3f5 --- /dev/null +++ b/client/src/components/Project/Board/IssueDetails/TopActions/Styles.js @@ -0,0 +1,72 @@ +import styled from 'styled-components'; + +import { color, font, mixin } from 'shared/utils/styles'; +import { Button } from 'shared/components'; + +export const TopActions = styled.div` + display: flex; + justify-content: space-between; + padding: 21px 18px 0; +`; + +export const TypeButton = styled(Button)` + text-transform: uppercase; + letter-spacing: 0.5px; + color: ${color.textMedium}; + ${font.size(13)} +`; + +export const Right = styled.div` + display: flex; + align-items: center; + & > * { + margin-left: 4px; + } +`; + +export const TypeDropdown = styled.div` + padding-bottom: 6px; +`; + +export const TypeTitle = styled.div` + padding: 10px 0 7px 12px; + text-transform: uppercase; + color: ${color.textMedium}; + ${font.size(12)} +`; + +export const Type = styled.div` + display: flex; + align-items: center; + padding: 7px 12px; + ${mixin.clickable} + &:hover { + background: ${color.backgroundLight}; + } +`; + +export const TypeLabel = styled.div` + padding: 0 5px 0 7px; + text-transform: capitalize; + ${font.size(15)} +`; + +export const FeedbackDropdown = styled.div` + padding: 16px 24px 24px; +`; + +export const FeedbackImageCont = styled.div` + padding: 24px 56px 20px; +`; + +export const FeedbackImage = styled.img` + width: 100%; +`; + +export const FeedbackParagraph = styled.p` + margin-bottom: 12px; + ${font.size(15)} + &:last-of-type { + margin-bottom: 22px; + } +`; diff --git a/client/src/components/Project/Board/IssueDetails/TopActions/assets/feedback.png b/client/src/components/Project/Board/IssueDetails/TopActions/assets/feedback.png new file mode 100644 index 0000000..dbaf66e Binary files /dev/null and b/client/src/components/Project/Board/IssueDetails/TopActions/assets/feedback.png differ diff --git a/client/src/components/Project/Board/IssueDetails/TopActions/index.jsx b/client/src/components/Project/Board/IssueDetails/TopActions/index.jsx new file mode 100644 index 0000000..f909cd0 --- /dev/null +++ b/client/src/components/Project/Board/IssueDetails/TopActions/index.jsx @@ -0,0 +1,118 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import api from 'shared/utils/api'; +import toast from 'shared/utils/toast'; +import { IssueType } from 'shared/constants/issues'; +import { IssueTypeIcon, Button, CopyLinkButton, Tooltip, ConfirmModal } from 'shared/components'; +import feedbackImage from './assets/feedback.png'; +import { + TopActions, + TypeButton, + Right, + TypeDropdown, + TypeTitle, + Type, + TypeLabel, + FeedbackDropdown, + FeedbackImageCont, + FeedbackImage, + FeedbackParagraph, +} from './Styles'; + +const propTypes = { + issue: PropTypes.object.isRequired, + updateIssue: PropTypes.func.isRequired, + fetchProject: PropTypes.func.isRequired, + modalClose: PropTypes.func.isRequired, +}; + +const ProjectBoardIssueDetailsTopActions = ({ issue, updateIssue, fetchProject, modalClose }) => { + const handleIssueDelete = async () => { + try { + await api.delete(`/issues/${issue.id}`); + await fetchProject(); + modalClose(); + } catch (error) { + toast.error(error); + } + }; + + const renderType = () => ( + ( + }> + {`${issue.type}-${issue.id}`} + + )} + renderContent={() => ( + + Change issue type + {Object.values(IssueType).map(type => ( + updateIssue({ type })}> + + {type} + + ))} + + )} + /> + ); + + const renderFeedback = () => ( + ( + + )} + renderContent={() => ( + + + + + + This simplified Jira clone is built with React on the front-end and Node/TypeScript on + the back-end. + + + Read more on our website or reach out via ivor@codetree.co + + + + + + )} + /> + ); + + const renderDeleteIcon = () => ( + + ); +}; + +export default CopyLinkButton; diff --git a/client/src/shared/components/DatePicker/Styles.js b/client/src/shared/components/DatePicker/Styles.js index 470462e..6894e4e 100644 --- a/client/src/shared/components/DatePicker/Styles.js +++ b/client/src/shared/components/DatePicker/Styles.js @@ -14,7 +14,7 @@ export const Dropdown = styled.div` width: 270px; border-radius: 3px; background: #fff; - ${mixin.boxShadowBorderMedium} + ${mixin.boxShadowDropdown} ${props => (props.withTime ? withTimeStyles : '')} `; diff --git a/client/src/shared/components/Icon/index.jsx b/client/src/shared/components/Icon/index.jsx index 9c5e033..6c9530c 100644 --- a/client/src/shared/components/Icon/index.jsx +++ b/client/src/shared/components/Icon/index.jsx @@ -26,8 +26,8 @@ const codes = { [`issues`]: '\\e908', [`settings`]: '\\e909', [`close`]: '\\e913', - [`help-filled`]: '\\e912', - [`feedback`]: '\\e915', + [`feedback`]: '\\e918', + [`trash`]: '\\e912', }; const propTypes = { diff --git a/client/src/shared/components/InputDebounced.jsx b/client/src/shared/components/InputDebounced.jsx new file mode 100644 index 0000000..a4d5ac0 --- /dev/null +++ b/client/src/shared/components/InputDebounced.jsx @@ -0,0 +1,43 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; + +import { Input } from 'shared/components'; + +const propTypes = { + onChange: PropTypes.func.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, +}; + +const InputDebounced = ({ onChange, value: propsValue, ...props }) => { + const [value, setValue] = useState(propsValue); + + const handleChange = useCallback( + debounce(newValue => onChange(newValue), 500), + [], + ); + + const valueRef = useRef(value); + valueRef.current = value; + + useEffect(() => { + if (propsValue !== valueRef.current) { + setValue(propsValue); + } + }, [propsValue]); + + return ( + { + setValue(newValue); + handleChange(newValue); + }} + /> + ); +}; + +InputDebounced.propTypes = propTypes; + +export default InputDebounced; diff --git a/client/src/shared/components/IssuePriorityIcon/Styles.js b/client/src/shared/components/IssuePriorityIcon/Styles.js new file mode 100644 index 0000000..4f31139 --- /dev/null +++ b/client/src/shared/components/IssuePriorityIcon/Styles.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +import { Icon } from 'shared/components'; +import { issuePriorityColors } from 'shared/utils/styles'; + +export const PriorityIcon = styled(Icon)` + font-size: 18px; + color: ${props => issuePriorityColors[props.color]}; +`; diff --git a/client/src/shared/components/IssuePriorityIcon/index.jsx b/client/src/shared/components/IssuePriorityIcon/index.jsx new file mode 100644 index 0000000..ac488ff --- /dev/null +++ b/client/src/shared/components/IssuePriorityIcon/index.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { IssuePriority } from 'shared/constants/issues'; +import { PriorityIcon } from './Styles'; + +const propTypes = { + priority: PropTypes.string.isRequired, +}; + +const IssuePriorityIcon = ({ priority, ...otherProps }) => { + const iconType = [IssuePriority.LOW || IssuePriority.LOWEST].includes(priority) + ? 'arrow-down' + : 'arrow-up'; + + return ; +}; + +IssuePriorityIcon.propTypes = propTypes; + +export default IssuePriorityIcon; diff --git a/client/src/shared/components/IssueTypeIcon/Styles.js b/client/src/shared/components/IssueTypeIcon/Styles.js new file mode 100644 index 0000000..25115c8 --- /dev/null +++ b/client/src/shared/components/IssueTypeIcon/Styles.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +import { Icon } from 'shared/components'; +import { issueTypeColors } from 'shared/utils/styles'; + +export const TypeIcon = styled(Icon)` + font-size: 18px; + color: ${props => issueTypeColors[props.color]}; +`; diff --git a/client/src/shared/components/IssueTypeIcon/index.jsx b/client/src/shared/components/IssueTypeIcon/index.jsx new file mode 100644 index 0000000..6fb4995 --- /dev/null +++ b/client/src/shared/components/IssueTypeIcon/index.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { TypeIcon } from './Styles'; + +const propTypes = { + type: PropTypes.string.isRequired, +}; + +const IssueTypeIcon = ({ type, ...otherProps }) => ( + +); + +IssueTypeIcon.propTypes = propTypes; + +export default IssueTypeIcon; diff --git a/client/src/shared/components/Modal/Styles.js b/client/src/shared/components/Modal/Styles.js index 38337fc..d212dac 100644 --- a/client/src/shared/components/Modal/Styles.js +++ b/client/src/shared/components/Modal/Styles.js @@ -41,14 +41,14 @@ export const StyledModal = styled.div` const modalStyles = { center: css` - max-width: 600px; + max-width: ${props => props.width}px; vertical-align: middle; text-align: left; ${mixin.boxShadowMedium} `, aside: css` min-height: 100vh; - max-width: 500px; + max-width: ${props => props.width}px; text-align: left; box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.15); `, @@ -57,7 +57,7 @@ const modalStyles = { export const CloseIcon = styled(Icon)` position: absolute; font-size: 25px; - color: ${color.textDark}; + color: ${color.textMedium}; ${mixin.clickable} ${props => closeIconStyles[props.variant]} `; diff --git a/client/src/shared/components/Modal/index.jsx b/client/src/shared/components/Modal/index.jsx index a3dfc6b..5e652d5 100644 --- a/client/src/shared/components/Modal/index.jsx +++ b/client/src/shared/components/Modal/index.jsx @@ -1,7 +1,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; -import { uniqueId as uniqueIncreasingIntegerId } from 'lodash'; import useOnOutsideClick from 'shared/hooks/onOutsideClick'; import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown'; @@ -10,6 +9,8 @@ import { ScrollOverlay, ClickableOverlay, StyledModal, CloseIcon } from './Style const propTypes = { className: PropTypes.string, variant: PropTypes.oneOf(['center', 'aside']), + width: PropTypes.number, + withCloseIcon: PropTypes.bool, isOpen: PropTypes.bool, onClose: PropTypes.func, renderLink: PropTypes.func, @@ -19,6 +20,8 @@ const propTypes = { const defaultProps = { className: undefined, variant: 'center', + width: 600, + withCloseIcon: true, isOpen: undefined, onClose: () => {}, renderLink: () => {}, @@ -27,6 +30,8 @@ const defaultProps = { const Modal = ({ className, variant, + width, + withCloseIcon, isOpen: propsIsOpen, onClose: tellParentToClose, renderLink, @@ -37,12 +42,9 @@ const Modal = ({ const isOpen = isControlled ? propsIsOpen : stateIsOpen; const $modalRef = useRef(); - const modalIdRef = useRef(uniqueIncreasingIntegerId()); + const $clickableOverlayRef = useRef(); const closeModal = useCallback(() => { - if (hasChildModal(modalIdRef.current)) { - return; - } if (!isControlled) { setStateOpen(false); } else { @@ -50,15 +52,15 @@ const Modal = ({ } }, [isControlled, tellParentToClose]); - useOnOutsideClick($modalRef, isOpen, closeModal); + useOnOutsideClick($modalRef, isOpen, closeModal, $clickableOverlayRef); useOnEscapeKeyDown(isOpen, closeModal); useEffect(setBodyScrollLock, [isOpen]); const renderModal = () => ( - - - - + + + + {withCloseIcon && } {renderContent({ close: closeModal })} @@ -75,15 +77,8 @@ const Modal = ({ const $root = document.getElementById('root'); -const getIdsOfAllOpenModals = () => { - const $modalNodes = Array.from(document.querySelectorAll('[data-jira-modal-id]')); - return $modalNodes.map($node => parseInt($node.getAttribute('data-jira-modal-id'))); -}; - -const hasChildModal = modalId => getIdsOfAllOpenModals().some(id => id > modalId); - const setBodyScrollLock = () => { - const areAnyModalsOpen = getIdsOfAllOpenModals().length > 0; + const areAnyModalsOpen = !!document.querySelector('[data-jira-modal]'); document.body.style.overflow = areAnyModalsOpen ? 'hidden' : 'visible'; }; diff --git a/client/src/shared/components/PageLoader/Styles.js b/client/src/shared/components/PageLoader/Styles.js index 277972d..39db28f 100644 --- a/client/src/shared/components/PageLoader/Styles.js +++ b/client/src/shared/components/PageLoader/Styles.js @@ -2,6 +2,6 @@ import styled from 'styled-components'; export default styled.div` width: 100%; - padding-top: 200px; + padding: 200px 0; text-align: center; `; diff --git a/client/src/shared/components/Select/Dropdown.jsx b/client/src/shared/components/Select/Dropdown.jsx index 3941af6..f6617e4 100644 --- a/client/src/shared/components/Select/Dropdown.jsx +++ b/client/src/shared/components/Select/Dropdown.jsx @@ -16,12 +16,14 @@ const propTypes = { onChange: PropTypes.func.isRequired, onCreate: PropTypes.func, isMulti: PropTypes.bool, + propsRenderOption: PropTypes.func, }; const defaultProps = { value: undefined, onCreate: undefined, isMulti: false, + propsRenderOption: undefined, }; const SelectDropdown = ({ @@ -35,6 +37,7 @@ const SelectDropdown = ({ onChange, onCreate, isMulti, + propsRenderOption, }) => { const [isCreatingOption, setCreatingOption] = useState(false); @@ -143,27 +146,33 @@ const SelectDropdown = ({ .includes(searchValue.toLowerCase()), ); - const removeSelectedOptions = opts => opts.filter(option => !value.includes(option.value)); + const removeSelectedOptionsMulti = opts => opts.filter(option => !value.includes(option.value)); + const removeSelectedOptionsSingle = opts => opts.filter(option => value !== option.value); const filteredOptions = isMulti - ? removeSelectedOptions(optionsFilteredBySearchValue) - : optionsFilteredBySearchValue; + ? removeSelectedOptionsMulti(optionsFilteredBySearchValue) + : removeSelectedOptionsSingle(optionsFilteredBySearchValue); const searchValueNotInOptions = !options.map(option => option.label).includes(searchValue); const isOptionCreatable = onCreate && searchValue && searchValueNotInOptions; - const renderSelectableOption = (option, i) => ( - - ); + const renderSelectableOption = (option, i) => { + const optionProps = { + key: option.value, + value: option.value, + label: option.label, + className: i === 0 ? activeOptionClass : undefined, + isSelected: option.value === value, + 'data-select-option-value': option.value, + onMouseEnter: handleOptionMouseEnter, + onClick: () => selectOptionValue(option.value), + }; + return propsRenderOption ? ( + propsRenderOption(optionProps) + ) : ( + + ); + }; const renderCreatableOption = () => (