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 - { - setLinkCopied(true); - setTimeout(() => setLinkCopied(false), 2000); - copyToClipboard(window.location.href); - }} - > + {isLinkCopied ? 'Link Copied' : 'Copy link'} 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)} + /> + + { + setPresenting(true); + updateIssue({ description: $editorRef.current.getHTML() }); + }} + > + Save + + setPresenting(true)}> + Cancel + + + > + ); + 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 + ({ + value: status, + label: IssueStatusCopy[status], + }))} + onChange={status => updateIssue({ status })} + renderValue={({ value: status }) => ( + + {IssueStatusCopy[status]} + + )} + renderOption={({ value: status, ...optionProps }) => ( + + {IssueStatusCopy[status]} + + )} + /> + > + ); + + const renderUserValue = (user, withBottomMargin, removeOptionValue) => ( + removeOptionValue && removeOptionValue(user.id)} + > + + {user.name} + {removeOptionValue && } + + ); + + const renderUserOption = user => ( + + + {user.name} + + ); + + const renderAssignees = () => ( + <> + Assignees + { + updateIssue({ userIds, users: userIds.map(getUserById) }); + }} + renderValue={({ value, removeOptionValue }) => + renderUserValue(getUserById(value), true, removeOptionValue) + } + renderOption={({ value, ...optionProps }) => ( + {renderUserOption(getUserById(value))} + )} + /> + > + ); + + const renderReporter = () => ( + <> + Reporter + updateIssue({ reporterId: userId })} + renderValue={({ value }) => renderUserValue(getUserById(value), false)} + renderOption={({ value, ...optionProps }) => ( + {renderUserOption(getUserById(value))} + )} + /> + > + ); + + const renderEstimate = () => ( + <> + Original Estimate (hours) + {renderHourInput('estimate')} + > + ); + + const renderPriorityItem = priority => ( + + + {IssuePriorityCopy[priority].toLowerCase()} + + ); + + const renderPriority = () => ( + <> + Priority + ({ + 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')} + + + + + Close + + + + )} + /> + > + ); + + 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 = () => ( + ( + + Give feedback + + )} + 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 + + + Visit Website + + + )} + /> + ); + + const renderDeleteIcon = () => ( + } + /> + ); + + return ( + + {renderType()} + + {renderFeedback()} + + {renderDeleteIcon()} + + + + ); +}; + +ProjectBoardIssueDetailsTopActions.propTypes = propTypes; + +export default ProjectBoardIssueDetailsTopActions; diff --git a/client/src/components/Project/Board/IssueDetails/index.jsx b/client/src/components/Project/Board/IssueDetails/index.jsx new file mode 100644 index 0000000..fe68ce7 --- /dev/null +++ b/client/src/components/Project/Board/IssueDetails/index.jsx @@ -0,0 +1,74 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import api from 'shared/utils/api'; +import useApi from 'shared/hooks/api'; +import { PageError } from 'shared/components'; +import Loader from './Loader'; +import TopActions from './TopActions'; +import Title from './Title'; +import Description from './Description'; +import RightActions from './RightActions'; +import { Content, Left, Right } from './Styles'; + +const propTypes = { + issueId: PropTypes.string.isRequired, + projectUsers: PropTypes.array.isRequired, + fetchProject: PropTypes.func.isRequired, + updateLocalIssuesArray: PropTypes.func.isRequired, + modalClose: PropTypes.func.isRequired, +}; + +const ProjectBoardIssueDetails = ({ + issueId, + projectUsers, + fetchProject, + updateLocalIssuesArray, + modalClose, +}) => { + const [{ data, error, isLoading, setLocalData }] = useApi.get(`/issues/${issueId}`); + + if (isLoading) return ; + if (error) return ; + + const { issue } = data; + + const updateLocalIssue = fields => + setLocalData(currentData => ({ issue: { ...currentData.issue, ...fields } })); + + const updateIssue = updatedFields => { + api.optimisticUpdate({ + url: `/issues/${issueId}`, + updatedFields, + currentFields: issue, + setLocalData: fields => { + updateLocalIssue(fields); + updateLocalIssuesArray(issue.id, fields); + }, + }); + }; + + return ( + <> + + + + + + + + + + + > + ); +}; + +ProjectBoardIssueDetails.propTypes = propTypes; + +export default ProjectBoardIssueDetails; diff --git a/client/src/components/Project/Board/IssueModal/Styles.js b/client/src/components/Project/Board/IssueModal/Styles.js deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/components/Project/Board/IssueModal/index.jsx b/client/src/components/Project/Board/IssueModal/index.jsx deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/components/Project/Board/Lists/Issue/Styles.js b/client/src/components/Project/Board/Lists/Issue/Styles.js index 1143fbf..fef28d1 100644 --- a/client/src/components/Project/Board/Lists/Issue/Styles.js +++ b/client/src/components/Project/Board/Lists/Issue/Styles.js @@ -1,9 +1,11 @@ import styled, { css } from 'styled-components'; +import { Link } from 'react-router-dom'; -import { Avatar, Icon } from 'shared/components'; -import { color, issueTypeColors, issuePriorityColors, font, mixin } from 'shared/utils/styles'; +import { Avatar } from 'shared/components'; +import { color, font, mixin } from 'shared/utils/styles'; -export const IssueWrapper = styled.div` +export const IssueWrapper = styled(Link)` + display: block; margin-bottom: 5px; `; @@ -36,19 +38,6 @@ export const Bottom = styled.div` align-items: center; `; -export const TypeIcon = styled(Icon)` - font-size: 19px; - color: ${props => issueTypeColors[props.color]}; -`; - -export const PriorityIcon = styled(Icon)` - position: relative; - top: -1px; - margin-left: 4px; - font-size: 18px; - color: ${props => issuePriorityColors[props.color]}; -`; - export const Assignees = styled.div` display: flex; flex-direction: row-reverse; diff --git a/client/src/components/Project/Board/Lists/Issue/index.jsx b/client/src/components/Project/Board/Lists/Issue/index.jsx index 905fb9c..7b22b7d 100644 --- a/client/src/components/Project/Board/Lists/Issue/index.jsx +++ b/client/src/components/Project/Board/Lists/Issue/index.jsx @@ -1,18 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { useRouteMatch } from 'react-router-dom'; import { Draggable } from 'react-beautiful-dnd'; -import { IssuePriority } from 'shared/constants/issues'; -import { - IssueWrapper, - Issue, - Title, - Bottom, - TypeIcon, - PriorityIcon, - Assignees, - AssigneeAvatar, -} from './Styles'; +import { IssueTypeIcon, IssuePriorityIcon } from 'shared/components'; +import { IssueWrapper, Issue, Title, Bottom, Assignees, AssigneeAvatar } from './Styles'; const propTypes = { projectUsers: PropTypes.array.isRequired, @@ -21,28 +13,26 @@ const propTypes = { }; const ProjectBoardListsIssue = ({ projectUsers, issue, index }) => { + const match = useRouteMatch(); + const getUserById = userId => projectUsers.find(user => user.id === userId); - const assignees = issue.userIds.map(getUserById); - const priorityIconType = [IssuePriority.LOW || IssuePriority.LOWEST].includes(issue.priority) - ? 'arrow-down' - : 'arrow-up'; - return ( {(provided, snapshot) => ( {issue.title} - - + + {assignees.map(user => ( diff --git a/client/src/components/Project/Board/Lists/index.jsx b/client/src/components/Project/Board/Lists/index.jsx index 4aa3882..4064203 100644 --- a/client/src/components/Project/Board/Lists/index.jsx +++ b/client/src/components/Project/Board/Lists/index.jsx @@ -2,64 +2,44 @@ import React from 'react'; import PropTypes from 'prop-types'; import moment from 'moment'; import { DragDropContext, Droppable } from 'react-beautiful-dnd'; -import { intersection } from 'lodash'; +import { get, intersection } from 'lodash'; import api from 'shared/utils/api'; -import { - moveItemWithinArray, - insertItemIntoArray, - updateArrayItemById, -} from 'shared/utils/javascript'; -import { IssueStatus } from 'shared/constants/issues'; +import useApi from 'shared/hooks/api'; +import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/javascript'; +import { IssueStatus, IssueStatusCopy } from 'shared/constants/issues'; import Issue from './Issue'; import { Lists, List, Title, IssuesCount, Issues } from './Styles'; const propTypes = { project: PropTypes.object.isRequired, filters: PropTypes.object.isRequired, - currentUserId: PropTypes.number, - setLocalProjectData: PropTypes.func.isRequired, + updateLocalIssuesArray: PropTypes.func.isRequired, }; -const defaultProps = { - currentUserId: null, -}; +const ProjectBoardLists = ({ project, filters, updateLocalIssuesArray }) => { + const [{ data: currentUserData }] = useApi.get('/currentUser'); + const currentUserId = get(currentUserData, 'currentUser.id'); -const ProjectBoardLists = ({ project, filters, currentUserId, setLocalProjectData }) => { const filteredIssues = filterIssues(project.issues, filters, currentUserId); - const handleIssueDrop = ({ draggableId, destination, source }) => { + const handleIssueDrop = async ({ draggableId, destination, source }) => { if (!destination) return; - const isSameList = destination.droppableId === source.droppableId; const isSamePosition = destination.index === source.index; - if (isSameList && isSamePosition) return; const issueId = parseInt(draggableId); - const { prevIssue, nextIssue } = getAfterDropPrevNextIssue( - project.issues, - destination, - isSameList, - issueId, - ); - - const afterDropListPosition = calculateListPosition(prevIssue, nextIssue); - - const issueFieldsToUpdate = { - status: destination.droppableId, - listPosition: afterDropListPosition, - }; - - setLocalProjectData(data => ({ - project: { - ...data.project, - issues: updateArrayItemById(data.project.issues, issueId, issueFieldsToUpdate), + api.optimisticUpdate({ + url: `/issues/${issueId}`, + updatedFields: { + status: destination.droppableId, + listPosition: calculateListPosition(project.issues, destination, isSameList, issueId), }, - })); - - api.put(`/issues/${issueId}`, issueFieldsToUpdate); + currentFields: project.issues.find(({ id }) => id === issueId), + setLocalData: fields => updateLocalIssuesArray(issueId, fields), + }); }; const renderList = status => { @@ -76,12 +56,12 @@ const ProjectBoardLists = ({ project, filters, currentUserId, setLocalProjectDat {provided => ( - {`${issueStatusCopy[status]} `} + {`${IssueStatusCopy[status]} `} {issuesCount} - {filteredListIssues.map((issue, i) => ( - + {filteredListIssues.map((issue, index) => ( + ))} {provided.placeholder} @@ -92,9 +72,11 @@ const ProjectBoardLists = ({ project, filters, currentUserId, setLocalProjectDat }; return ( - - {Object.values(IssueStatus).map(renderList)} - + <> + + {Object.values(IssueStatus).map(renderList)} + + > ); }; @@ -120,7 +102,8 @@ const filterIssues = (projectIssues, filters, currentUserId) => { const getSortedListIssues = (issues, status) => issues.filter(issue => issue.status === status).sort((a, b) => a.listPosition - b.listPosition); -const calculateListPosition = (prevIssue, nextIssue) => { +const calculateListPosition = (...args) => { + const { prevIssue, nextIssue } = getAfterDropPrevNextIssue(...args); let position; if (!prevIssue && !nextIssue) { @@ -149,14 +132,6 @@ const getAfterDropPrevNextIssue = (allIssues, destination, isSameList, droppedIs }; }; -const issueStatusCopy = { - [IssueStatus.BACKLOG]: 'Backlog', - [IssueStatus.SELECTED]: 'Selected for development', - [IssueStatus.INPROGRESS]: 'In progress', - [IssueStatus.DONE]: 'Done', -}; - ProjectBoardLists.propTypes = propTypes; -ProjectBoardLists.defaultProps = defaultProps; export default ProjectBoardLists; diff --git a/client/src/components/Project/Board/index.jsx b/client/src/components/Project/Board/index.jsx index 7d3f001..e538a02 100644 --- a/client/src/components/Project/Board/index.jsx +++ b/client/src/components/Project/Board/index.jsx @@ -1,24 +1,31 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { get } from 'lodash'; +import { Route, useRouteMatch, useHistory } from 'react-router-dom'; -import useApi from 'shared/hooks/api'; +import { Modal } from 'shared/components'; import Header from './Header'; import Filters from './Filters'; import Lists from './Lists'; +import IssueDetails from './IssueDetails'; const propTypes = { project: PropTypes.object.isRequired, - setLocalProjectData: PropTypes.func.isRequired, + fetchProject: PropTypes.func.isRequired, + updateLocalIssuesArray: PropTypes.func.isRequired, }; -const defaultFilters = { searchQuery: '', userIds: [], myOnly: false, recent: false }; +const defaultFilters = { + searchQuery: '', + userIds: [], + myOnly: false, + recent: false, +}; -const ProjectBoard = ({ project, setLocalProjectData }) => { +const ProjectBoard = ({ project, fetchProject, updateLocalIssuesArray }) => { + const match = useRouteMatch(); + const history = useHistory(); const [filters, setFilters] = useState(defaultFilters); - const [{ data }] = useApi.get('/currentUser'); - return ( <> @@ -28,11 +35,26 @@ const ProjectBoard = ({ project, setLocalProjectData }) => { filters={filters} setFilters={setFilters} /> - + ( + history.push(match.url)} + renderContent={modal => ( + + )} + /> + )} /> > ); diff --git a/client/src/components/Project/Sidebar/index.jsx b/client/src/components/Project/Sidebar/index.jsx index a890abd..646efaf 100644 --- a/client/src/components/Project/Sidebar/index.jsx +++ b/client/src/components/Project/Sidebar/index.jsx @@ -14,9 +14,10 @@ import { const propTypes = { projectName: PropTypes.string.isRequired, + matchPath: PropTypes.string.isRequired, }; -const ProjectSidebar = ({ projectName }) => ( +const ProjectSidebar = ({ projectName, matchPath }) => ( @@ -25,15 +26,15 @@ const ProjectSidebar = ({ projectName }) => ( Software project - + Kanban Board - + Issues and filters - + Project settings diff --git a/client/src/components/Project/index.jsx b/client/src/components/Project/index.jsx index c657f6a..774eb14 100644 --- a/client/src/components/Project/index.jsx +++ b/client/src/components/Project/index.jsx @@ -1,23 +1,48 @@ import React from 'react'; +import { Route, Redirect, useRouteMatch } from 'react-router-dom'; import useApi from 'shared/hooks/api'; +import { updateArrayItemById } from 'shared/utils/javascript'; import { PageLoader, PageError } from 'shared/components'; import Sidebar from './Sidebar'; import Board from './Board'; import { ProjectPage } from './Styles'; const Project = () => { - const [{ data, error, setLocalData: setLocalProjectData }] = useApi.get('/project'); + const match = useRouteMatch(); + const [{ data, error, setLocalData }, fetchProject] = useApi.get('/project'); + + const updateLocalIssuesArray = (issueId, updatedFields) => { + setLocalData(currentData => ({ + project: { + ...currentData.project, + issues: updateArrayItemById(data.project.issues, issueId, updatedFields), + }, + })); + }; if (!data) return ; if (error) return ; const { project } = data; + const renderBoard = () => ( + + ); + const renderSettings = () => SETTINGS; + const renderIssues = () => ISSUES; + return ( - - + + + + + {match.isExact && } ); }; diff --git a/client/src/index.jsx b/client/src/index.jsx index 7056f83..acebc4f 100755 --- a/client/src/index.jsx +++ b/client/src/index.jsx @@ -1,4 +1,5 @@ import 'core-js/stable'; +import 'regenerator-runtime/runtime'; import React from 'react'; import ReactDOM from 'react-dom'; diff --git a/client/src/shared/components/Button/Styles.js b/client/src/shared/components/Button/Styles.js index eeff93a..f72acc7 100644 --- a/client/src/shared/components/Button/Styles.js +++ b/client/src/shared/components/Button/Styles.js @@ -26,6 +26,21 @@ export const StyledButton = styled.button` } `; +const colored = css` + color: #fff; + background: ${props => color[props.color]}; + ${font.medium} + &:not(:disabled) { + &:hover { + background: ${props => mixin.lighten(color[props.color], 0.15)}; + } + &:active { + background: ${props => mixin.darken(color[props.color], 0.1)}; + } + ${props => props.isActive && `background: ${mixin.darken(color[props.color], 0.1)} !important;`} + } +`; + const secondaryAndEmptyShared = css` color: ${color.textDark}; ${font.regular} @@ -35,36 +50,21 @@ const secondaryAndEmptyShared = css` } &:active { color: ${color.primary}; - background: ${mixin.rgba(color.primary, 0.15)}; + background: ${color.backgroundLightPrimary}; } ${props => props.isActive && ` color: ${color.primary}; - background: ${mixin.rgba(color.primary, 0.15)} !important; + background: ${color.backgroundLightPrimary} !important; `} } `; const buttonColors = { - primary: css` - color: #fff; - background: ${color.primary}; - ${font.medium} - &:not(:disabled) { - &:hover { - background: ${mixin.lighten(color.primary, 0.15)}; - } - &:active { - background: ${mixin.darken(color.primary, 0.1)}; - } - ${props => - props.isActive && - ` - background: ${mixin.darken(color.primary, 0.1)} !important; - `} - } - `, + primary: colored, + success: colored, + danger: colored, secondary: css` background: ${color.secondary}; ${secondaryAndEmptyShared}; diff --git a/client/src/shared/components/Button/index.jsx b/client/src/shared/components/Button/index.jsx index 7a41e4a..36467db 100644 --- a/client/src/shared/components/Button/index.jsx +++ b/client/src/shared/components/Button/index.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import PropTypes from 'prop-types'; import { color } from 'shared/utils/styles'; @@ -8,8 +8,8 @@ import { StyledButton, StyledSpinner } from './Styles'; const propTypes = { className: PropTypes.string, children: PropTypes.node, - color: PropTypes.oneOf(['primary', 'secondary', 'empty']), - icon: PropTypes.string, + color: PropTypes.oneOf(['primary', 'secondary', 'empty', 'success', 'danger']), + icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), iconSize: PropTypes.number, disabled: PropTypes.bool, working: PropTypes.bool, @@ -27,44 +27,55 @@ const defaultProps = { onClick: () => {}, }; -const Button = ({ - children, - color: propsColor, - icon, - iconSize, - disabled, - working, - onClick = () => {}, - ...buttonProps -}) => ( - { +const Button = forwardRef( + ( + { + children, + color: propsColor, + icon, + iconSize, + disabled, + working, + onClick = () => {}, + ...buttonProps + }, + ref, + ) => { + const handleClick = () => { if (!disabled && !working) { onClick(); } - }} - color={propsColor} - disabled={disabled || working} - working={working} - iconOnly={!children} - > - {working && ( + }; + const renderSpinner = () => ( - )} - {!working && icon && ( + ); + const renderIcon = () => ( - )} - {children} - + ); + return ( + + {working && renderSpinner()} + {!working && icon && (typeof icon !== 'string' ? icon : renderIcon())} + {children} + + ); + }, ); Button.propTypes = propTypes; diff --git a/client/src/shared/components/ConfirmModal/Styles.js b/client/src/shared/components/ConfirmModal/Styles.js index e9c7843..7bdfa80 100644 --- a/client/src/shared/components/ConfirmModal/Styles.js +++ b/client/src/shared/components/ConfirmModal/Styles.js @@ -11,21 +11,21 @@ export const StyledConfirmModal = styled(Modal)` export const Title = styled.div` padding-bottom: 25px; - ${font.bold} - ${font.size(24)} + ${font.medium} + ${font.size(22)} line-height: 1.5; `; export const Message = styled.p` padding-bottom: 25px; white-space: pre-wrap; - ${font.size(16)} + ${font.size(15)} `; export const InputLabel = styled.div` padding-bottom: 12px; ${font.bold} - ${font.size(16)} + ${font.size(15)} `; export const StyledInput = styled(Input)` @@ -33,6 +33,10 @@ export const StyledInput = styled(Input)` max-width: 220px; `; +export const Actions = styled.div` + display: flex; +`; + export const StyledButton = styled(Button)` margin: 5px 20px 0 0; `; diff --git a/client/src/shared/components/ConfirmModal/index.jsx b/client/src/shared/components/ConfirmModal/index.jsx index c5fab2f..56bece2 100644 --- a/client/src/shared/components/ConfirmModal/index.jsx +++ b/client/src/shared/components/ConfirmModal/index.jsx @@ -7,6 +7,7 @@ import { Message, InputLabel, StyledInput, + Actions, StyledButton, } from './Styles'; @@ -76,17 +77,19 @@ const ConfirmModal = ({ > )} - - {cancelText} - - handleConfirm(modal)} - > - {confirmText} - + + + {cancelText} + + handleConfirm(modal)} + > + {confirmText} + + > )} /> diff --git a/client/src/shared/components/CopyLinkButton.jsx b/client/src/shared/components/CopyLinkButton.jsx new file mode 100644 index 0000000..5a9ef91 --- /dev/null +++ b/client/src/shared/components/CopyLinkButton.jsx @@ -0,0 +1,21 @@ +import React, { useState } from 'react'; + +import { copyToClipboard } from 'shared/utils/clipboard'; +import { Button } from 'shared/components'; + +const CopyLinkButton = ({ ...otherProps }) => { + const [isLinkCopied, setLinkCopied] = useState(false); + + const handleLinkCopy = () => { + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 2000); + copyToClipboard(window.location.href); + }; + return ( + + {isLinkCopied ? 'Link Copied' : 'Copy link'} + + ); +}; + +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) => ( - selectOptionValue(option.value)} - > - {option.label} - - ); + 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) + ) : ( + {option.label} + ); + }; const renderCreatableOption = () => ( (props.hasIcon ? 'padding-left: 25px;' : '')} - ${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')} -`; - -export const StyledIcon = styled(Icon)` - position: absolute; - top: 12px; - left: 14px; - font-size: 16px; - color: ${color.textMedium}; + ${props => props.invalid && `&, &:focus { border: 1px solid ${color.danger}; }`} `; export const ValueContainer = styled.div` - min-height: 38px; + display: flex; + align-items: center; + min-height: 32px; width: 100%; + padding: 8px 5px 8px 10px; `; export const ChevronIcon = styled(Icon)` - position: absolute; - top: 10px; - right: 11px; + margin-left: auto; font-size: 18px; color: ${color.textMedium}; `; export const Placeholder = styled.div` - padding: 11px 0 0 15px; color: ${color.textLight}; `; -export const ValueSingle = styled.div` - padding: 11px 0 0 15px; -`; - export const ValueMulti = styled.div` - padding: 15px 5px 10px 10px; + display: flex; + align-items: center; + flex-wrap: wrap; + padding-top: 5px; `; export const ValueMultiItem = styled.div` margin: 0 5px 5px 0; - ${mixin.tag} + ${mixin.tag()} `; export const AddMore = styled.div` display: inline-block; - height: 24px; - line-height: 22px; - padding-right: 5px; - ${font.size(12)} + margin-bottom: 3px; + padding: 3px 0; + ${font.size(12.5)} ${mixin.link()} i { margin-right: 3px; @@ -77,12 +68,13 @@ export const Dropdown = styled.div` top: 100%; left: 0; width: 100%; + border-radius: 4px; background: #fff; - ${mixin.boxShadowBorderMedium} + ${mixin.boxShadowDropdown} `; export const DropdownInput = styled.input` - padding: 10px 15px 8px; + padding: 10px 12px 8px; width: 100%; border: none; color: ${color.textDarkest}; @@ -118,7 +110,7 @@ export const Option = styled.div` margin-bottom: 8px; } &.jira-select-option-is-active { - background: ${mixin.lighten(color.backgroundMedium, 0.05)}; + background: ${color.backgroundLightPrimary}; } ${props => (props.isSelected ? selectedOptionStyles : '')} `; diff --git a/client/src/shared/components/Select/index.jsx b/client/src/shared/components/Select/index.jsx index b5ae249..f8e8bec 100644 --- a/client/src/shared/components/Select/index.jsx +++ b/client/src/shared/components/Select/index.jsx @@ -7,11 +7,9 @@ import Icon from 'shared/components/Icon'; import Dropdown from './Dropdown'; import { StyledSelect, - StyledIcon, ValueContainer, ChevronIcon, Placeholder, - ValueSingle, ValueMulti, ValueMultiItem, AddMore, @@ -19,8 +17,7 @@ import { const propTypes = { className: PropTypes.string, - icon: PropTypes.string, - value: PropTypes.any, + value: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]), defaultValue: PropTypes.any, placeholder: PropTypes.string, invalid: PropTypes.bool, @@ -28,22 +25,24 @@ const propTypes = { onChange: PropTypes.func.isRequired, onCreate: PropTypes.func, isMulti: PropTypes.bool, + renderValue: PropTypes.func, + renderOption: PropTypes.func, }; const defaultProps = { className: undefined, - icon: undefined, value: undefined, defaultValue: undefined, placeholder: '', invalid: false, onCreate: undefined, isMulti: false, + renderValue: undefined, + renderOption: undefined, }; const Select = ({ className, - icon, value: propsValue, defaultValue, placeholder, @@ -52,6 +51,8 @@ const Select = ({ onChange, onCreate, isMulti, + renderValue: propsRenderValue, + renderOption: propsRenderOption, }) => { const [stateValue, setStateValue] = useState(defaultValue || (isMulti ? [] : null)); const [isDropdownOpen, setDropdownOpen] = useState(false); @@ -79,11 +80,18 @@ const Select = ({ useOnOutsideClick($selectRef, isDropdownOpen, deactivateDropdown); + const ensureValueType = newValue => { + if (typeof value === 'number') { + return isMulti ? newValue.map(parseInt) : parseInt(newValue); + } + return newValue; + }; + const handleChange = newValue => { if (!isControlled) { - setStateValue(newValue); + setStateValue(ensureValueType(newValue)); } - onChange(newValue); + onChange(ensureValueType(newValue)); }; const removeOptionValue = optionValue => { @@ -106,16 +114,21 @@ const Select = ({ const isValueEmpty = isMulti ? !value.length : !getOption(value); - const renderSingleValue = () => {getOptionLabel(value)}; + const renderSingleValue = () => + propsRenderValue ? propsRenderValue({ value }) : getOptionLabel(value); const renderMultiValue = () => ( - {value.map(optionValue => ( - removeOptionValue(optionValue)}> - {getOptionLabel(optionValue)} - - - ))} + {value.map(optionValue => + propsRenderValue ? ( + propsRenderValue({ value: optionValue, removeOptionValue }) + ) : ( + removeOptionValue(optionValue)}> + {getOptionLabel(optionValue)} + + + ), + )} Add more @@ -128,16 +141,14 @@ const Select = ({ className={className} ref={$selectRef} tabIndex="0" - hasIcon={!!icon} onKeyDown={handleFocusedSelectKeydown} invalid={invalid} > - {icon && } - {(!isMulti || isValueEmpty) && } {isValueEmpty && {placeholder}} {!isValueEmpty && !isMulti && renderSingleValue()} {!isValueEmpty && isMulti && renderMultiValue()} + {(!isMulti || isValueEmpty) && } {isDropdownOpen && ( )} diff --git a/client/src/shared/components/TextEditedContent/Styles.js b/client/src/shared/components/TextEditedContent/Styles.js new file mode 100644 index 0000000..1305ac9 --- /dev/null +++ b/client/src/shared/components/TextEditedContent/Styles.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +import { font } from 'shared/utils/styles'; + +export const Content = styled.div` + padding: 0 !important; + ${font.size(15)} + ${font.regular} +`; diff --git a/client/src/shared/components/TextEditedContent/index.jsx b/client/src/shared/components/TextEditedContent/index.jsx new file mode 100644 index 0000000..8594042 --- /dev/null +++ b/client/src/shared/components/TextEditedContent/index.jsx @@ -0,0 +1,21 @@ +/* eslint-disable react/no-danger */ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Content } from './Styles'; + +import('quill/dist/quill.snow.css'); + +const propTypes = { + content: PropTypes.string.isRequired, +}; + +const TextEditedContent = ({ content, ...otherProps }) => ( + + + +); + +TextEditedContent.propTypes = propTypes; + +export default TextEditedContent; diff --git a/client/src/shared/components/TextEditor/Styles.js b/client/src/shared/components/TextEditor/Styles.js new file mode 100644 index 0000000..1dbaa75 --- /dev/null +++ b/client/src/shared/components/TextEditor/Styles.js @@ -0,0 +1,21 @@ +import styled from 'styled-components'; + +import { color, font } from 'shared/utils/styles'; + +export const EditorCont = styled.div` + .ql-toolbar.ql-snow { + border-radius: 4px 4px 0 0; + border: 1px solid ${color.borderLightest}; + border-bottom: none; + } + .ql-container.ql-snow { + border-radius: 0 0 4px 4px; + border: 1px solid ${color.borderLightest}; + border-top: none; + ${font.size(15)} + ${font.regular} + } + .ql-editor { + min-height: 110px; + } +`; diff --git a/client/src/shared/components/TextEditor/index.jsx b/client/src/shared/components/TextEditor/index.jsx new file mode 100644 index 0000000..f151433 --- /dev/null +++ b/client/src/shared/components/TextEditor/index.jsx @@ -0,0 +1,70 @@ +import React, { useLayoutEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import Quill from 'quill'; + +import { EditorCont } from './Styles'; + +import('quill/dist/quill.snow.css'); + +const propTypes = { + className: PropTypes.string, + placeholder: PropTypes.string, + defaultValue: PropTypes.string, + getEditor: PropTypes.func.isRequired, +}; + +const defaultProps = { + className: undefined, + placeholder: undefined, + defaultValue: undefined, +}; + +const TextEditor = ({ className, placeholder, defaultValue, getEditor, ...otherProps }) => { + const $editorContRef = useRef(); + const $editorRef = useRef(); + + useLayoutEffect(() => { + let editor = null; + + const setup = async () => { + editor = new Quill($editorRef.current, { placeholder, ...editorConfig }); + + editor.clipboard.dangerouslyPasteHTML(0, defaultValue); + + getEditor({ + getHTML: () => $editorContRef.current.querySelector('.ql-editor').innerHTML, + }); + }; + setup(); + + return () => { + editor = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + ); +}; + +const editorConfig = { + theme: 'snow', + modules: { + toolbar: [ + ['bold', 'italic', 'underline', 'strike'], + ['blockquote', 'code-block'], + [{ list: 'ordered' }, { list: 'bullet' }], + [{ header: [1, 2, 3, 4, 5, 6, false] }], + [{ color: [] }, { background: [] }], + ['clean'], + ], + }, +}; + +TextEditor.propTypes = propTypes; +TextEditor.defaultProps = defaultProps; + +export default TextEditor; diff --git a/client/src/shared/components/Textarea/index.jsx b/client/src/shared/components/Textarea/index.jsx index 9beacb5..555fadf 100644 --- a/client/src/shared/components/Textarea/index.jsx +++ b/client/src/shared/components/Textarea/index.jsx @@ -25,7 +25,7 @@ const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps }, onChange(event.target.value, event)} - ref={ref} + inputRef={ref} /> )); diff --git a/client/src/shared/components/Tooltip/Styles.js b/client/src/shared/components/Tooltip/Styles.js new file mode 100644 index 0000000..358d7da --- /dev/null +++ b/client/src/shared/components/Tooltip/Styles.js @@ -0,0 +1,13 @@ +import styled from 'styled-components'; + +import { zIndexValues, mixin } from 'shared/utils/styles'; + +export const Tooltip = styled.div` + z-index: ${zIndexValues.modal + 1}; + position: fixed; + width: ${props => props.width}px; + border-radius: 3px; + background: #fff; + ${mixin.hardwareAccelerate} + ${mixin.boxShadowDropdown} +`; diff --git a/client/src/shared/components/Tooltip/index.jsx b/client/src/shared/components/Tooltip/index.jsx new file mode 100644 index 0000000..1c22276 --- /dev/null +++ b/client/src/shared/components/Tooltip/index.jsx @@ -0,0 +1,110 @@ +import React, { useState, useRef, useLayoutEffect } from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; + +import useOnOutsideClick from 'shared/hooks/onOutsideClick'; +import { Tooltip } from './Styles'; + +const propTypes = { + className: PropTypes.string, + placement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), + offset: PropTypes.shape({ + top: PropTypes.number, + left: PropTypes.number, + }), + width: PropTypes.number.isRequired, + renderLink: PropTypes.func.isRequired, + renderContent: PropTypes.func.isRequired, +}; + +const defaultProps = { + className: undefined, + placement: 'bottom', + offset: { + top: 0, + left: 0, + }, +}; + +const Modal = ({ className, placement, offset, width, renderLink, renderContent }) => { + const [isOpen, setIsOpen] = useState(false); + + const $linkRef = useRef(); + const $tooltipRef = useRef(); + + const openTooltip = () => setIsOpen(true); + const closeTooltip = () => setIsOpen(false); + + useOnOutsideClick([$tooltipRef, $linkRef], isOpen, closeTooltip); + + useLayoutEffect(() => { + const setTooltipPosition = () => { + const { top, left } = calcPosition(offset, placement, $tooltipRef, $linkRef); + $tooltipRef.current.style.top = `${top}px`; + $tooltipRef.current.style.left = `${left}px`; + }; + if (isOpen) { + setTooltipPosition(); + window.addEventListener('resize', setTooltipPosition); + window.addEventListener('scroll', setTooltipPosition); + } + return () => { + window.removeEventListener('resize', setTooltipPosition); + window.removeEventListener('scroll', setTooltipPosition); + }; + }, [isOpen, offset, placement]); + + const renderTooltip = () => ( + + {renderContent({ close: closeTooltip })} + + ); + + return ( + <> + {renderLink({ ref: $linkRef, onClick: isOpen ? closeTooltip : openTooltip })} + {isOpen && ReactDOM.createPortal(renderTooltip(), $root)} + > + ); +}; + +const calcPosition = (offset, placement, $tooltipRef, $linkRef) => { + const margin = 20; + const finalOffset = { ...defaultProps.offset, ...offset }; + + const tooltipRect = $tooltipRef.current.getBoundingClientRect(); + const linkRect = $linkRef.current.getBoundingClientRect(); + + const linkCenterY = linkRect.top + linkRect.height / 2; + const linkCenterX = linkRect.left + linkRect.width / 2; + + const placements = { + top: { + top: linkRect.top - margin - tooltipRect.height, + left: linkCenterX - tooltipRect.width / 2, + }, + right: { + top: linkCenterY - tooltipRect.height / 2, + left: linkRect.right + margin, + }, + bottom: { + top: linkRect.bottom + margin, + left: linkCenterX - tooltipRect.width / 2, + }, + left: { + top: linkCenterY - tooltipRect.height / 2, + left: linkRect.left - margin - tooltipRect.width, + }, + }; + return { + top: placements[placement].top + finalOffset.top, + left: placements[placement].left + finalOffset.left, + }; +}; + +const $root = document.getElementById('root'); + +Modal.propTypes = propTypes; +Modal.defaultProps = defaultProps; + +export default Modal; diff --git a/client/src/shared/components/index.js b/client/src/shared/components/index.js index b884081..fdae0b9 100644 --- a/client/src/shared/components/index.js +++ b/client/src/shared/components/index.js @@ -1,9 +1,14 @@ export { default as Avatar } from './Avatar'; export { default as Button } from './Button'; export { default as ConfirmModal } from './ConfirmModal'; +export { default as CopyLinkButton } from './CopyLinkButton'; export { default as DatePicker } from './DatePicker'; +export { default as Tooltip } from './Tooltip'; export { default as Icon } from './Icon'; export { default as Input } from './Input'; +export { default as InputDebounced } from './InputDebounced'; +export { default as IssueTypeIcon } from './IssueTypeIcon'; +export { default as IssuePriorityIcon } from './IssuePriorityIcon'; export { default as Logo } from './Logo'; export { default as Modal } from './Modal'; export { default as PageError } from './PageError'; @@ -12,3 +17,5 @@ export { default as ProjectAvatar } from './ProjectAvatar'; export { default as Select } from './Select'; export { default as Spinner } from './Spinner'; export { default as Textarea } from './Textarea'; +export { default as TextEditedContent } from './TextEditedContent'; +export { default as TextEditor } from './TextEditor'; diff --git a/client/src/shared/constants/issues.js b/client/src/shared/constants/issues.js index fa33495..66247e5 100644 --- a/client/src/shared/constants/issues.js +++ b/client/src/shared/constants/issues.js @@ -18,3 +18,10 @@ export const IssuePriority = { LOW: '2', LOWEST: '1', }; + +export const IssueStatusCopy = { + [IssueStatus.BACKLOG]: 'Backlog', + [IssueStatus.SELECTED]: 'Selected for development', + [IssueStatus.INPROGRESS]: 'In progress', + [IssueStatus.DONE]: 'Done', +}; diff --git a/client/src/shared/hooks/api.js b/client/src/shared/hooks/api.js index 26a0040..6aae27c 100644 --- a/client/src/shared/hooks/api.js +++ b/client/src/shared/hooks/api.js @@ -1,54 +1,49 @@ -/* eslint-disable react-hooks/rules-of-hooks */ import { useState, useRef, useCallback, useEffect } from 'react'; import api from 'shared/utils/api'; import useDeepCompareMemoize from './deepCompareMemoize'; -const useApi = (method, url, paramsOrData = {}, { lazy = false } = {}) => { +const useApi = (method, url, variables = {}, { lazy = false } = {}) => { const isCalledAutomatically = method === 'get' && !lazy; const [state, setState] = useState({ data: null, error: null, isLoading: isCalledAutomatically, - variables: {}, + additionalVariables: {}, }); - const setLocalData = useCallback( - set => setState(currentState => ({ ...currentState, data: set(currentState.data) })), - [], - ); - const updateState = newState => setState(currentState => ({ ...currentState, ...newState })); + const setStateMerge = newState => setState(currentState => ({ ...currentState, ...newState })); const wasCalledRef = useRef(false); + const variablesMemoized = useDeepCompareMemoize(variables); - const paramsOrDataMemoized = useDeepCompareMemoize(paramsOrData); const stateRef = useRef(); stateRef.current = state; const makeRequest = useCallback( (newVariables = {}) => new Promise((resolve, reject) => { - const variables = { ...stateRef.current.variables, ...newVariables }; + const additionalVariables = { ...stateRef.current.additionalVariables, ...newVariables }; if (!isCalledAutomatically || wasCalledRef.current) { - updateState({ variables, isLoading: true }); + setStateMerge({ additionalVariables, isLoading: true }); } - api[method](url, { ...paramsOrDataMemoized, ...variables }).then( + api[method](url, { ...variablesMemoized, ...additionalVariables }).then( data => { resolve(data); - updateState({ data, error: null, isLoading: false }); + setStateMerge({ data, error: null, isLoading: false }); }, error => { reject(error); - updateState({ error, data: null, isLoading: false }); + setStateMerge({ error, data: null, isLoading: false }); }, ); wasCalledRef.current = true; }), - [method, paramsOrDataMemoized, isCalledAutomatically, url], + [method, variablesMemoized, isCalledAutomatically, url], ); useEffect(() => { @@ -57,17 +52,26 @@ const useApi = (method, url, paramsOrData = {}, { lazy = false } = {}) => { } }, [makeRequest, isCalledAutomatically]); - return [ + const setLocalData = useCallback( + getUpdatedData => + setState(currentState => ({ ...currentState, data: getUpdatedData(currentState.data) })), + [], + ); + + const result = [ { ...state, wasCalled: wasCalledRef.current, - variables: { ...paramsOrDataMemoized, ...state.variables }, + variables: { ...variablesMemoized, ...state.additionalVariables }, setLocalData, }, makeRequest, ]; + + return result; }; +/* eslint-disable react-hooks/rules-of-hooks */ export default { get: (...args) => useApi('get', ...args), post: (...args) => useApi('post', ...args), diff --git a/client/src/shared/hooks/debounceValue.js b/client/src/shared/hooks/debounceValue.js deleted file mode 100644 index 269000f..0000000 --- a/client/src/shared/hooks/debounceValue.js +++ /dev/null @@ -1,19 +0,0 @@ -import { useState, useEffect } from 'react'; - -const useDebounceValue = (value, delay) => { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - - return () => { - clearTimeout(handler); - }; - }, [value, delay]); - - return debouncedValue; -}; - -export default useDebounceValue; diff --git a/client/src/shared/hooks/onOutsideClick.js b/client/src/shared/hooks/onOutsideClick.js index 8f7540d..1b61f82 100644 --- a/client/src/shared/hooks/onOutsideClick.js +++ b/client/src/shared/hooks/onOutsideClick.js @@ -1,30 +1,40 @@ import { useEffect, useRef } from 'react'; -const useOnOutsideClick = ($elementRef, isListening, onOutsideClick) => { +import useDeepCompareMemoize from 'shared/hooks/deepCompareMemoize'; + +const useOnOutsideClick = ( + $ignoredElementRefs, + isListening, + onOutsideClick, + $listeningElementRef = {}, +) => { const $mouseDownTargetRef = useRef(); + const $ignoredElementRefsMemoized = useDeepCompareMemoize([$ignoredElementRefs].flat()); useEffect(() => { const handleMouseDown = event => { $mouseDownTargetRef.current = event.target; }; const handleMouseUp = event => { - if ( - event.button === 0 && - !$elementRef.current.contains($mouseDownTargetRef.current) && - !$elementRef.current.contains(event.target) - ) { + const noElementsContainTarget = $ignoredElementRefsMemoized.every( + $elementRef => + !$elementRef.current.contains($mouseDownTargetRef.current) && + !$elementRef.current.contains(event.target), + ); + if (event.button === 0 && noElementsContainTarget) { onOutsideClick(); } }; + const $listeningElement = $listeningElementRef.current || document; if (isListening) { - document.addEventListener('mousedown', handleMouseDown); - document.addEventListener('mouseup', handleMouseUp); + $listeningElement.addEventListener('mousedown', handleMouseDown); + $listeningElement.addEventListener('mouseup', handleMouseUp); } return () => { - document.removeEventListener('mousedown', handleMouseDown); - document.removeEventListener('mouseup', handleMouseUp); + $listeningElement.removeEventListener('mousedown', handleMouseDown); + $listeningElement.removeEventListener('mouseup', handleMouseUp); }; - }, [$elementRef, isListening, onOutsideClick]); + }, [$ignoredElementRefsMemoized, $listeningElementRef, isListening, onOutsideClick]); }; export default useOnOutsideClick; diff --git a/client/src/shared/utils/api.js b/client/src/shared/utils/api.js index efbc58f..41414f5 100644 --- a/client/src/shared/utils/api.js +++ b/client/src/shared/utils/api.js @@ -1,6 +1,7 @@ import axios from 'axios'; import history from 'browserHistory'; +import toast from 'shared/utils/toast'; import { objectToQueryString } from 'shared/utils/url'; import { getStoredAuthToken, removeStoredAuthToken } from 'shared/utils/authToken'; @@ -17,14 +18,14 @@ const defaults = { }, }; -const api = (method, url, paramsOrData) => +const api = (method, url, variables) => new Promise((resolve, reject) => { axios({ url: `${defaults.baseURL}${url}`, method, headers: defaults.headers(), - params: method === 'get' ? paramsOrData : undefined, - data: method !== 'get' ? paramsOrData : undefined, + params: method === 'get' ? variables : undefined, + data: method !== 'get' ? variables : undefined, paramsSerializer: objectToQueryString, }).then( response => { @@ -45,10 +46,21 @@ const api = (method, url, paramsOrData) => ); }); +const optimisticUpdate = async ({ url, updatedFields, currentFields, setLocalData }) => { + try { + setLocalData(updatedFields); + await api('put', url, updatedFields); + } catch (error) { + setLocalData(currentFields); + toast.error(error); + } +}; + export default { get: (...args) => api('get', ...args), post: (...args) => api('post', ...args), put: (...args) => api('put', ...args), patch: (...args) => api('patch', ...args), delete: (...args) => api('delete', ...args), + optimisticUpdate, }; diff --git a/client/src/shared/utils/html.js b/client/src/shared/utils/html.js new file mode 100644 index 0000000..c859ef0 --- /dev/null +++ b/client/src/shared/utils/html.js @@ -0,0 +1,5 @@ +export const getTextContentsFromHtmlString = html => { + const el = document.createElement('div'); + el.innerHTML = html; + return el.textContent; +}; diff --git a/client/src/shared/utils/javascript.js b/client/src/shared/utils/javascript.js index fa358b9..05b33d9 100644 --- a/client/src/shared/utils/javascript.js +++ b/client/src/shared/utils/javascript.js @@ -11,12 +11,12 @@ export const insertItemIntoArray = (arr, item, index) => { return arrClone; }; -export const updateArrayItemById = (arr, itemId, newFields) => { +export const updateArrayItemById = (arr, itemId, fields) => { const arrClone = [...arr]; const item = arrClone.find(({ id }) => id === itemId); - const itemIndex = arrClone.indexOf(item); - if (itemIndex > -1) { - arrClone.splice(itemIndex, 1, { ...item, ...newFields }); + if (item) { + const itemIndex = arrClone.indexOf(item); + arrClone.splice(itemIndex, 1, { ...item, ...fields }); } return arrClone; }; diff --git a/client/src/shared/utils/styles.js b/client/src/shared/utils/styles.js index dd06937..00c3ba9 100644 --- a/client/src/shared/utils/styles.js +++ b/client/src/shared/utils/styles.js @@ -1,8 +1,9 @@ import Color from 'color'; +import { IssueType, IssueStatus, IssuePriority } from 'shared/constants/issues'; export const color = { primary: '#0052cc', // Blue - success: '#29A638', // green + success: '#0B875B', // green danger: '#E13C3C', // red warning: '#F89C1C', // orange secondary: '#F4F5F7', // light grey @@ -17,6 +18,8 @@ export const color = { backgroundMedium: '#dfe1e6', backgroundLight: '#ebecf0', backgroundLightest: '#F4F5F7', + backgroundLightPrimary: '#D2E5FE', + backgroundLightSuccess: '#E4FCEF', borderLightest: '#dfe1e6', borderLight: '#C1C7D0', @@ -24,17 +27,31 @@ export const color = { }; export const issueTypeColors = { - story: '#65BA43', // green - bug: '#E44D42', // red - task: '#4FADE6', // blue + [IssueType.TASK]: '#4FADE6', // blue + [IssueType.BUG]: '#E44D42', // red + [IssueType.STORY]: '#65BA43', // green }; export const issuePriorityColors = { - '5': '#CD1317', // red - '4': '#E9494A', // orange - '3': '#E97F33', // orange - '2': '#2D8738', // green - '1': '#57A55A', // green + [IssuePriority.HIGHEST]: '#CD1317', // red + [IssuePriority.HIGH]: '#E9494A', // orange + [IssuePriority.MEDIUM]: '#E97F33', // orange + [IssuePriority.LOW]: '#2D8738', // green + [IssuePriority.LOWEST]: '#57A55A', // green +}; + +export const issueStatusColors = { + [IssueStatus.BACKLOG]: color.textDark, + [IssueStatus.INPROGRESS]: '#fff', + [IssueStatus.SELECTED]: color.textDark, + [IssueStatus.DONE]: '#fff', +}; + +export const issueStatusBackgroundColors = { + [IssueStatus.BACKLOG]: color.backgroundMedium, + [IssueStatus.INPROGRESS]: color.primary, + [IssueStatus.SELECTED]: color.backgroundMedium, + [IssueStatus.DONE]: color.success, }; export const sizes = { @@ -73,10 +90,8 @@ export const mixin = { boxShadowMedium: ` box-shadow: 0 5px 10px 0 rgba(0,0,0,0.1); `, - boxShadowBorderMedium: ` - box-shadow: 0 5px 10px 0 rgba(0,0,0,0.1); - border: 1px solid ${color.borderLight}; - border-top: 1px solid ${color.borderLightest}; + boxShadowDropdown: ` + box-shadow: rgba(9, 30, 66, 0.25) 0px 4px 8px -2px, rgba(9, 30, 66, 0.31) 0px 0px 1px; `, truncateText: ` overflow: hidden; @@ -161,4 +176,20 @@ export const mixin = { text-decoration: underline; } `, + tag: (background = color.backgroundLight, colorValue = color.textDarkest) => ` + display: inline-block; + padding: 6px 8px 5px; + border-radius: 4px; + cursor: pointer; + user-select: none; + color: ${colorValue}; + background: ${background}; + ${font.bold} + ${font.size(11.5)} + i { + margin-left: 4px; + vertical-align: middle; + font-size: 14px; + } + `, }; diff --git a/client/src/shared/utils/validation.js b/client/src/shared/utils/validation.js index 2b307da..0accc74 100644 --- a/client/src/shared/utils/validation.js +++ b/client/src/shared/utils/validation.js @@ -19,7 +19,7 @@ export const is = { 'Must be a valid URL', }; -const isNilOrEmptyString = value => value === undefined || value === null || value === ''; +export const isNilOrEmptyString = value => value === undefined || value === null || value === ''; export const generateErrors = (fieldValues, fieldValidators) => { const errors = {}; diff --git a/client/webpack.config.js b/client/webpack.config.js index afa3fda..7b47a93 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -17,6 +17,10 @@ module.exports = { exclude: /node_modules/, use: ['babel-loader'], }, + { + test: /\.css$/, + use: ['style-loader', { loader: 'css-loader' }], + }, { test: /\.(jpe?g|png|gif|woff2?|eot|ttf|otf|svg)$/, use: [