Implemented first draft of issue modal

This commit is contained in:
ireic
2019-12-18 03:48:42 +01:00
parent f48b2a9d40
commit 386694d28f
97 changed files with 1972 additions and 428 deletions

228
client/package-lock.json generated
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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 = () => (
<>
<NormalizeStyles />
<FontStyles />
<BaseStyles />
<Toast />
<Routes />

View File

@@ -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 <PageLoader />;
};

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 384 KiB

View File

Before

Width:  |  Height:  |  Size: 433 KiB

After

Width:  |  Height:  |  Size: 433 KiB

View File

Before

Width:  |  Height:  |  Size: 341 KiB

After

Width:  |  Height:  |  Size: 341 KiB

View File

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 432 KiB

View File

@@ -25,10 +25,10 @@
<glyph unicode="&#xe90f;" glyph-name="bug" d="M554 384.667v256h-84v-256h84zM554 212.667v86h-84v-86h84zM512 852.667q176 0 301-125t125-301-125-301-301-125-301 125-125 301 125 301 301 125z" />
<glyph unicode="&#xe910;" glyph-name="task" d="M426 212.667l384 384-60 62-324-324-152 152-60-60zM810 810.667q36 0 61-25t25-61v-596q0-36-25-61t-61-25h-596q-36 0-61 25t-25 61v596q0 36 25 61t61 25h596z" />
<glyph unicode="&#xe911;" glyph-name="story" d="M726 810.667q34 0 59-26t25-60v-682l-298 128-298-128v682q0 34 25 60t59 26h428z" />
<glyph unicode="&#xe912;" glyph-name="help-filled" d="M512 952.32c-271.462 0-491.52-220.058-491.52-491.52s220.058-491.52 491.52-491.52c271.411 0 491.52 220.058 491.52 491.571 0 271.411-220.109 491.469-491.52 491.469zM504.269 174.131h-2.611c-40.038 1.178-68.301 30.72-67.123 70.195 1.126 38.81 30.054 67.021 68.762 67.021l2.355-0.102c41.165-1.178 69.12-30.413 67.942-71.014-1.178-38.912-29.594-66.099-69.325-66.099zM672.768 508.518c-9.421-13.312-30.106-30.003-56.218-50.33l-28.774-19.814c-15.77-12.288-25.293-23.91-28.826-35.226-2.867-8.909-4.198-11.315-4.454-29.491v-4.608h-109.824l0.307 9.318c1.382 38.093 2.304 60.621 18.125 79.206 24.832 29.082 79.616 64.41 81.92 65.894 7.885 5.888 14.49 12.595 19.405 19.814 11.52 15.923 16.589 28.416 16.589 40.602 0 17.101-5.018 32.922-15.002 46.899-9.626 13.619-27.904 20.378-54.323 20.378-26.214 0-44.186-8.294-54.886-25.395-11.059-17.459-16.64-35.84-16.64-54.63v-4.71h-113.203l0.205 4.915c2.918 69.274 27.699 119.194 73.472 148.326 28.826 18.483 64.717 27.853 106.547 27.853 54.682 0 100.966-13.312 137.318-39.526 36.915-26.573 55.603-66.406 55.603-118.323-0.051-29.030-9.216-56.32-27.341-81.152z" />
<glyph unicode="&#xe912;" glyph-name="trash" d="M768 640v-554.667c0-11.776-4.736-22.4-12.501-30.165s-18.389-12.501-30.165-12.501h-426.667c-11.776 0-22.4 4.736-30.165 12.501s-12.501 18.389-12.501 30.165v554.667zM725.333 725.334v42.667c0 35.328-14.379 67.413-37.504 90.496s-55.168 37.504-90.496 37.504h-170.667c-35.328 0-67.413-14.379-90.496-37.504s-37.504-55.168-37.504-90.496v-42.667h-170.667c-23.552 0-42.667-19.115-42.667-42.667s19.115-42.667 42.667-42.667h42.667v-554.667c0-35.328 14.379-67.413 37.504-90.496s55.168-37.504 90.496-37.504h426.667c35.328 0 67.413 14.379 90.496 37.504s37.504 55.168 37.504 90.496v554.667h42.667c23.552 0 42.667 19.115 42.667 42.667s-19.115 42.667-42.667 42.667zM384 725.334v42.667c0 11.776 4.736 22.4 12.501 30.165s18.389 12.501 30.165 12.501h170.667c11.776 0 22.4-4.736 30.165-12.501s12.501-18.389 12.501-30.165v-42.667zM384 469.334v-256c0-23.552 19.115-42.667 42.667-42.667s42.667 19.115 42.667 42.667v256c0 23.552-19.115 42.667-42.667 42.667s-42.667-19.115-42.667-42.667zM554.667 469.334v-256c0-23.552 19.115-42.667 42.667-42.667s42.667 19.115 42.667 42.667v256c0 23.552-19.115 42.667-42.667 42.667s-42.667-19.115-42.667-42.667z" />
<glyph unicode="&#xe913;" glyph-name="close" d="M225.835 652.502l225.835-225.835-225.835-225.835c-16.683-16.683-16.683-43.691 0-60.331s43.691-16.683 60.331 0l225.835 225.835 225.835-225.835c16.683-16.683 43.691-16.683 60.331 0s16.683 43.691 0 60.331l-225.835 225.835 225.835 225.835c16.683 16.683 16.683 43.691 0 60.331s-43.691 16.683-60.331 0l-225.835-225.835-225.835 225.835c-16.683 16.683-43.691 16.683-60.331 0s-16.683-43.691 0-60.331z" />
<glyph unicode="&#xe914;" glyph-name="stopwatch" d="M512 84.667q124 0 211 88t87 212-87 211-211 87-211-87-87-211 87-212 211-88zM812 622.667q34-44 59-113t25-125q0-158-112-271t-272-113-272 113-112 271 112 271 272 113q54 0 125-26t115-60l60 62q32-26 60-60zM470 340.667v256h84v-256h-84zM640 896.667v-86h-256v86h256z" />
<glyph unicode="&#xe915;" glyph-name="feedback" d="M881.818 612.864c-81.101 188.723-211.558 332.288-277.555 305.51-112.077-45.619 66.765-264.397-483.686-488.090-47.565-19.405-59.597-96.666-39.68-142.95 19.866-46.182 84.89-92.211 132.454-72.909 8.243 3.379 38.451 13.107 38.451 13.107 33.946-45.619 69.478-18.586 82.125-47.514 15.155-34.816 48.077-110.49 59.29-136.192s36.608-49.51 55.040-42.496c18.381 7.014 80.998 30.822 104.96 39.885 23.962 9.114 29.645 30.515 22.323 47.309-7.885 18.176-40.243 23.501-49.51 44.698-9.216 21.094-39.373 88.986-48.026 110.387-11.776 29.082 13.261 52.787 49.664 56.525 250.573 26.214 297.421-128.614 382.72-93.901 65.894 26.88 52.48 218.061-28.57 406.63zM853.606 306.893c-14.694-5.888-113.306 71.782-176.282 218.47-63.027 146.586-55.091 280.576-40.448 286.566 14.643 5.888 110.848-87.91 173.824-234.496 63.027-146.586 57.549-264.55 42.906-270.541z" />
<glyph unicode="&#xe916;" glyph-name="menu" d="M128 384h768c23.552 0 42.667 19.115 42.667 42.667s-19.115 42.667-42.667 42.667h-768c-23.552 0-42.667-19.115-42.667-42.667s19.115-42.667 42.667-42.667zM128 640h768c23.552 0 42.667 19.115 42.667 42.667s-19.115 42.667-42.667 42.667h-768c-23.552 0-42.667-19.115-42.667-42.667s19.115-42.667 42.667-42.667zM128 128h768c23.552 0 42.667 19.115 42.667 42.667s-19.115 42.667-42.667 42.667h-768c-23.552 0-42.667-19.115-42.667-42.667s19.115-42.667 42.667-42.667z" />
<glyph unicode="&#xe917;" glyph-name="arrow-left-circle" d="M981.333 426.667c0 129.579-52.565 246.997-137.472 331.861s-202.283 137.472-331.861 137.472-246.997-52.565-331.861-137.472-137.472-202.283-137.472-331.861 52.565-246.997 137.472-331.861 202.283-137.472 331.861-137.472 246.997 52.565 331.861 137.472 137.472 202.283 137.472 331.861zM896 426.667c0-106.069-42.923-201.984-112.469-271.531s-165.461-112.469-271.531-112.469-201.984 42.923-271.531 112.469-112.469 165.461-112.469 271.531 42.923 201.984 112.469 271.531 165.461 112.469 271.531 112.469 201.984-42.923 271.531-112.469 112.469-165.461 112.469-271.531zM682.667 469.334h-238.336l97.835 97.835c16.683 16.683 16.683 43.691 0 60.331s-43.691 16.683-60.331 0l-170.667-170.667c-4.096-4.096-7.168-8.789-9.259-13.824s-3.243-10.539-3.243-16.341c0-5.547 1.067-11.136 3.243-16.341 2.091-5.035 5.163-9.728 9.259-13.824l170.667-170.667c16.683-16.683 43.691-16.683 60.331 0s16.683 43.691 0 60.331l-97.835 97.835h238.336c23.552 0 42.667 19.115 42.667 42.667s-19.115 42.667-42.667 42.667z" />
<glyph unicode="&#xe918;" glyph-name="feedback" d="M979.755 841.856c1.835 6.443 2.133 13.397 0.64 20.309-1.024 4.821-2.901 9.557-5.589 13.867-2.731 4.352-6.187 8.107-10.155 11.179-4.992 3.84-10.624 6.4-16.469 7.723s-12.032 1.451-18.176 0.171c-1.792-0.384-3.627-0.896-5.376-1.493l-0.896-0.299-852.48-298.368c-10.752-3.755-19.925-11.776-24.917-22.955-9.557-21.547 0.128-46.763 21.675-56.32l369.024-164.011 164.011-369.024c4.608-10.368 13.355-18.901 24.875-22.955 22.229-7.765 46.592 3.925 54.357 26.197l298.368 852.437c0.427 1.152 0.811 2.304 1.152 3.499zM459.904 434.902l-258.901 115.029 575.275 201.387zM836.651 690.944l-201.387-575.275-115.029 258.901z" />
</font></defs></svg>

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

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

View File

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

View File

@@ -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
<Filters>
<SearchInput
icon="search"
onChange={debounce(value => setFiltersMerge({ searchQuery: value }), 500)}
value={searchQuery}
onChange={value => setFiltersMerge({ searchQuery: value })}
/>
<Avatars>
{projectUsers.map(user => (

View File

@@ -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 (
<>
<Breadcrumbs>
@@ -22,14 +28,7 @@ const ProjectBoardHeader = ({ projectName }) => {
</Breadcrumbs>
<Header>
<BoardName>Kanban board</BoardName>
<Button
icon="link"
onClick={() => {
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
copyToClipboard(window.location.href);
}}
>
<Button icon="link" onClick={handleLinkCopy}>
{isLinkCopied ? 'Link Copied' : 'Copy link'}
</Button>
</Header>

View File

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

View File

@@ -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) ? (
<EmptyLabel onClick={() => setPresenting(false)}>Add a description...</EmptyLabel>
) : (
<TextEditedContent content={issue.description} onClick={() => setPresenting(false)} />
);
const renderEditingMode = () => (
<>
<TextEditor
placeholder="Describe the issue"
defaultValue={issue.description}
getEditor={editor => ($editorRef.current = editor)}
/>
<Actions>
<Button
color="primary"
onClick={() => {
setPresenting(true);
updateIssue({ description: $editorRef.current.getHTML() });
}}
>
Save
</Button>
<Button color="empty" onClick={() => setPresenting(true)}>
Cancel
</Button>
</Actions>
</>
);
return (
<>
<DescriptionLabel>Description</DescriptionLabel>
{isPresenting ? renderPresentingMode() : renderEditingMode()}
</>
);
};
ProjectBoardIssueDetailsDescription.propTypes = propTypes;
export default ProjectBoardIssueDetailsDescription;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import ContentLoader from 'react-content-loader';
const IssueDetailsLoader = () => (
<div style={{ padding: 40 }}>
<ContentLoader
height={260}
width={940}
speed={2}
primaryColor="#f3f3f3"
secondaryColor="#ecebeb"
>
<rect x="0" y="0" rx="3" ry="3" width="627" height="24" />
<rect x="0" y="29" rx="3" ry="3" width="506" height="24" />
<rect x="0" y="77" rx="3" ry="3" width="590" height="16" />
<rect x="0" y="100" rx="3" ry="3" width="627" height="16" />
<rect x="0" y="123" rx="3" ry="3" width="480" height="16" />
<rect x="0" y="187" rx="3" ry="3" width="370" height="16" />
<circle cx="18" cy="239" r="18" />
<rect x="46" y="217" rx="3" ry="3" width="548" height="42" />
<rect x="683" y="3" rx="3" ry="3" width="135" height="14" />
<rect x="683" y="33" rx="3" ry="3" width="251" height="24" />
<rect x="683" y="90" rx="3" ry="3" width="135" height="14" />
<rect x="683" y="120" rx="3" ry="3" width="251" height="24" />
<rect x="683" y="177" rx="3" ry="3" width="135" height="14" />
<rect x="683" y="207" rx="3" ry="3" width="251" height="24" />
</ContentLoader>
</div>
);
export default IssueDetailsLoader;

View File

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

View File

@@ -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 => (
<InputDebounced
placeholder="Number"
filter={/^\d{0,6}$/}
value={isNil(issue[fieldName]) ? '' : issue[fieldName]}
onChange={stringValue => {
const value = stringValue.trim() ? parseInt(stringValue) : null;
updateIssue({ [fieldName]: value });
}}
/>
);
const renderStatus = () => (
<>
<SectionTitle>Status</SectionTitle>
<Select
value={issue.status}
options={Object.values(IssueStatus).map(status => ({
value: status,
label: IssueStatusCopy[status],
}))}
onChange={status => updateIssue({ status })}
renderValue={({ value: status }) => (
<Status isLarge color={status}>
{IssueStatusCopy[status]}
</Status>
)}
renderOption={({ value: status, ...optionProps }) => (
<StatusOption {...optionProps}>
<Status color={status}>{IssueStatusCopy[status]}</Status>
</StatusOption>
)}
/>
</>
);
const renderUserValue = (user, withBottomMargin, removeOptionValue) => (
<User
key={user.id}
isSelectValue
withBottomMargin={withBottomMargin}
onClick={() => removeOptionValue && removeOptionValue(user.id)}
>
<Avatar avatarUrl={user.avatarUrl} name={user.name} size={24} />
<UserName>{user.name}</UserName>
{removeOptionValue && <Icon type="close" top={1} />}
</User>
);
const renderUserOption = user => (
<User key={user.id}>
<Avatar avatarUrl={user.avatarUrl} name={user.name} size={32} />
<UserName>{user.name}</UserName>
</User>
);
const renderAssignees = () => (
<>
<SectionTitle>Assignees</SectionTitle>
<Select
isMulti
placeholder="Unassigned"
value={issue.userIds}
options={userOptions}
onChange={userIds => {
updateIssue({ userIds, users: userIds.map(getUserById) });
}}
renderValue={({ value, removeOptionValue }) =>
renderUserValue(getUserById(value), true, removeOptionValue)
}
renderOption={({ value, ...optionProps }) => (
<UserOptionCont {...optionProps}>{renderUserOption(getUserById(value))}</UserOptionCont>
)}
/>
</>
);
const renderReporter = () => (
<>
<SectionTitle>Reporter</SectionTitle>
<Select
value={issue.reporterId}
options={userOptions}
onChange={userId => updateIssue({ reporterId: userId })}
renderValue={({ value }) => renderUserValue(getUserById(value), false)}
renderOption={({ value, ...optionProps }) => (
<UserOptionCont {...optionProps}>{renderUserOption(getUserById(value))}</UserOptionCont>
)}
/>
</>
);
const renderEstimate = () => (
<>
<SectionTitle>Original Estimate (hours)</SectionTitle>
{renderHourInput('estimate')}
</>
);
const renderPriorityItem = priority => (
<Priority color={priority}>
<IssuePriorityIcon priority={priority} />
<PriorityLabel>{IssuePriorityCopy[priority].toLowerCase()}</PriorityLabel>
</Priority>
);
const renderPriority = () => (
<>
<SectionTitle>Priority</SectionTitle>
<Select
value={issue.priority}
options={Object.values(IssuePriority).map(priority => ({
value: priority,
label: IssuePriorityCopy[priority],
}))}
onChange={priority => updateIssue({ priority })}
renderValue={({ value }) => renderPriorityItem(value)}
renderOption={({ value, ...optionProps }) => (
<PriorityOption {...optionProps}>{renderPriorityItem(value)}</PriorityOption>
)}
/>
</>
);
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 <div>{`${timeRemaining}h remaining`}</div>;
}
if (!isNil(estimate)) {
return <div>{`${estimate}h estimated`}</div>;
}
};
const renderTrackingPreview = (onClick = () => {}) => (
<Tracking onClick={onClick}>
<TrackingIcon type="stopwatch" size={26} top={-1} />
<TrackingRight>
<TrackingBarCont>
<TrackingBar width={calculateTrackingBarWidth()} />
</TrackingBarCont>
<TrackingValues>
<div>{issue.timeSpent ? `${issue.timeSpent}h logged` : 'No time logged'}</div>
{renderRemainingOrEstimate()}
</TrackingValues>
</TrackingRight>
</Tracking>
);
const renderTracking = () => (
<>
<SectionTitle>Time Tracking</SectionTitle>
<Modal
width={400}
renderLink={modal => renderTrackingPreview(modal.open)}
renderContent={modal => (
<TrackingModalContents>
<TrackingModalTitle>Time tracking</TrackingModalTitle>
{renderTrackingPreview()}
<Inputs>
<InputCont>
<InputLabel>Time spent (hours)</InputLabel>
{renderHourInput('timeSpent')}
</InputCont>
<InputCont>
<InputLabel>Time remaining (hours)</InputLabel>
{renderHourInput('timeRemaining')}
</InputCont>
</Inputs>
<Actions>
<Button color="primary" onClick={modal.close}>
Close
</Button>
</Actions>
</TrackingModalContents>
)}
/>
</>
);
return (
<>
{renderStatus()}
{renderAssignees()}
{renderReporter()}
{renderEstimate()}
{renderPriority()}
{renderTracking()}
</>
);
};
ProjectBoardIssueDetailsRightActions.propTypes = propTypes;
export default ProjectBoardIssueDetailsRightActions;

View File

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

View File

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

View File

@@ -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 (
<>
<TitleTextarea
minRows={1}
placeholder="Short summary"
defaultValue={issue.title}
ref={$titleInputRef}
onBlur={handleTitleChange}
onKeyDown={event => {
if (event.keyCode === KeyCodes.ENTER) {
event.target.blur();
}
}}
/>
{error && <ErrorText>{error}</ErrorText>}
</>
);
};
ProjectBoardIssueDetailsTitle.propTypes = propTypes;
export default ProjectBoardIssueDetailsTitle;

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -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 = () => (
<Tooltip
width={150}
offset={{ top: -15 }}
renderLink={linkProps => (
<TypeButton {...linkProps} color="empty" icon={<IssueTypeIcon type={issue.type} />}>
{`${issue.type}-${issue.id}`}
</TypeButton>
)}
renderContent={() => (
<TypeDropdown>
<TypeTitle>Change issue type</TypeTitle>
{Object.values(IssueType).map(type => (
<Type key={type} onClick={() => updateIssue({ type })}>
<IssueTypeIcon type={type} top={1} />
<TypeLabel>{type}</TypeLabel>
</Type>
))}
</TypeDropdown>
)}
/>
);
const renderFeedback = () => (
<Tooltip
width={300}
offset={{ top: -15 }}
renderLink={linkProps => (
<Button icon="feedback" color="empty" {...linkProps}>
Give feedback
</Button>
)}
renderContent={() => (
<FeedbackDropdown>
<FeedbackImageCont>
<FeedbackImage src={feedbackImage} alt="Give feedback" />
</FeedbackImageCont>
<FeedbackParagraph>
This simplified Jira clone is built with React on the front-end and Node/TypeScript on
the back-end.
</FeedbackParagraph>
<FeedbackParagraph>
Read more on our website or reach out via <strong>ivor@codetree.co</strong>
</FeedbackParagraph>
<a href="https://codetree.co/" target="_blank" rel="noreferrer noopener">
<Button color="primary">Visit Website</Button>
</a>
</FeedbackDropdown>
)}
/>
);
const renderDeleteIcon = () => (
<ConfirmModal
title="Are you sure you want to delete this issue?"
message="This action is permanent and can not be reversed."
confirmText="Delete issue"
onConfirm={handleIssueDelete}
renderLink={modal => <Button icon="trash" iconSize={19} color="empty" onClick={modal.open} />}
/>
);
return (
<TopActions>
{renderType()}
<Right>
{renderFeedback()}
<CopyLinkButton color="empty" />
{renderDeleteIcon()}
<Button icon="close" iconSize={24} color="empty" onClick={modalClose} />
</Right>
</TopActions>
);
};
ProjectBoardIssueDetailsTopActions.propTypes = propTypes;
export default ProjectBoardIssueDetailsTopActions;

View File

@@ -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 <Loader />;
if (error) return <PageError />;
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 (
<>
<TopActions
issue={issue}
updateIssue={updateIssue}
fetchProject={fetchProject}
modalClose={modalClose}
/>
<Content>
<Left>
<Title issue={issue} updateIssue={updateIssue} />
<Description issue={issue} updateIssue={updateIssue} />
</Left>
<Right>
<RightActions issue={issue} updateIssue={updateIssue} projectUsers={projectUsers} />
</Right>
</Content>
</>
);
};
ProjectBoardIssueDetails.propTypes = propTypes;
export default ProjectBoardIssueDetails;

View File

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

View File

@@ -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 (
<Draggable draggableId={issue.id.toString()} index={index}>
{(provided, snapshot) => (
<IssueWrapper
to={`${match.url}/${issue.id}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
>
<Issue isBeingDragged={snapshot.isDragging && !snapshot.isDropAnimating}>
<Title>{issue.title}</Title>
<Bottom>
<div>
<TypeIcon type={issue.type} color={issue.type} />
<PriorityIcon type={priorityIconType} color={issue.priority} />
<IssueTypeIcon type={issue.type} />
<IssuePriorityIcon priority={issue.priority} top={-1} left={4} />
</div>
<Assignees>
{assignees.map(user => (

View File

@@ -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 => (
<List>
<Title>
{`${issueStatusCopy[status]} `}
{`${IssueStatusCopy[status]} `}
<IssuesCount>{issuesCount}</IssuesCount>
</Title>
<Issues {...provided.droppableProps} ref={provided.innerRef}>
{filteredListIssues.map((issue, i) => (
<Issue key={issue.id} projectUsers={project.users} issue={issue} index={i} />
{filteredListIssues.map((issue, index) => (
<Issue key={issue.id} projectUsers={project.users} issue={issue} index={index} />
))}
{provided.placeholder}
</Issues>
@@ -92,9 +72,11 @@ const ProjectBoardLists = ({ project, filters, currentUserId, setLocalProjectDat
};
return (
<DragDropContext onDragEnd={handleIssueDrop}>
<Lists>{Object.values(IssueStatus).map(renderList)}</Lists>
</DragDropContext>
<>
<DragDropContext onDragEnd={handleIssueDrop}>
<Lists>{Object.values(IssueStatus).map(renderList)}</Lists>
</DragDropContext>
</>
);
};
@@ -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;

View File

@@ -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 (
<>
<Header projectName={project.name} />
@@ -28,11 +35,26 @@ const ProjectBoard = ({ project, setLocalProjectData }) => {
filters={filters}
setFilters={setFilters}
/>
<Lists
project={project}
filters={filters}
currentUserId={get(data, 'currentUser.id')}
setLocalProjectData={setLocalProjectData}
<Lists project={project} filters={filters} updateLocalIssuesArray={updateLocalIssuesArray} />
<Route
path={`${match.path}/:issueId`}
render={({ match: { params } }) => (
<Modal
isOpen
width={1040}
withCloseIcon={false}
onClose={() => history.push(match.url)}
renderContent={modal => (
<IssueDetails
issueId={params.issueId}
projectUsers={project.users}
fetchProject={fetchProject}
updateLocalIssuesArray={updateLocalIssuesArray}
modalClose={modal.close}
/>
)}
/>
)}
/>
</>
);

View File

@@ -14,9 +14,10 @@ import {
const propTypes = {
projectName: PropTypes.string.isRequired,
matchPath: PropTypes.string.isRequired,
};
const ProjectSidebar = ({ projectName }) => (
const ProjectSidebar = ({ projectName, matchPath }) => (
<Sidebar>
<ProjectInfo>
<ProjectAvatar />
@@ -25,15 +26,15 @@ const ProjectSidebar = ({ projectName }) => (
<ProjectCategory>Software project</ProjectCategory>
</ProjectTexts>
</ProjectInfo>
<LinkItem to="/project/board">
<LinkItem to={`${matchPath}/board`}>
<Icon type="board" />
<LinkText>Kanban Board</LinkText>
</LinkItem>
<LinkItem to="/project/issues">
<LinkItem to={`${matchPath}/issues`}>
<Icon type="issues" />
<LinkText>Issues and filters</LinkText>
</LinkItem>
<LinkItem to="/project/settings">
<LinkItem to={`${matchPath}/settings`}>
<Icon type="settings" />
<LinkText>Project settings</LinkText>
</LinkItem>

View File

@@ -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 <PageLoader />;
if (error) return <PageError />;
const { project } = data;
const renderBoard = () => (
<Board
project={project}
fetchProject={fetchProject}
updateLocalIssuesArray={updateLocalIssuesArray}
/>
);
const renderSettings = () => <h1>SETTINGS</h1>;
const renderIssues = () => <h1>ISSUES</h1>;
return (
<ProjectPage>
<Sidebar projectName={project.name} />
<Board project={project} setLocalProjectData={setLocalProjectData} />
<Sidebar projectName={project.name} matchPath={match.path} />
<Route path={`${match.path}/board`} render={renderBoard} />
<Route path={`${match.path}/settings`} render={renderSettings} />
<Route path={`${match.path}/issues`} render={renderIssues} />
{match.isExact && <Redirect to={`${match.url}/board`} />}
</ProjectPage>
);
};

View File

@@ -1,4 +1,5 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import React from 'react';
import ReactDOM from 'react-dom';

View File

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

View File

@@ -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
}) => (
<StyledButton
{...buttonProps}
onClick={() => {
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 = () => (
<StyledSpinner
iconOnly={!children}
size={26}
color={propsColor === 'primary' ? '#fff' : color.textDark}
/>
)}
{!working && icon && (
);
const renderIcon = () => (
<Icon
type={icon}
size={iconSize}
color={propsColor === 'primary' ? '#fff' : color.textDark}
/>
)}
<div>{children}</div>
</StyledButton>
);
return (
<StyledButton
{...buttonProps}
onClick={handleClick}
color={propsColor}
disabled={disabled || working}
working={working}
iconOnly={!children}
ref={ref}
>
{working && renderSpinner()}
{!working && icon && (typeof icon !== 'string' ? icon : renderIcon())}
<div>{children}</div>
</StyledButton>
);
},
);
Button.propTypes = propTypes;

View File

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

View File

@@ -7,6 +7,7 @@ import {
Message,
InputLabel,
StyledInput,
Actions,
StyledButton,
} from './Styles';
@@ -76,17 +77,19 @@ const ConfirmModal = ({
<br />
</>
)}
<StyledButton hollow onClick={modal.close}>
{cancelText}
</StyledButton>
<StyledButton
color={type}
disabled={confirmInput && !isConfirmEnabled}
working={isWorking}
onClick={() => handleConfirm(modal)}
>
{confirmText}
</StyledButton>
<Actions>
<StyledButton hollow onClick={modal.close}>
{cancelText}
</StyledButton>
<StyledButton
color={type}
disabled={confirmInput && !isConfirmEnabled}
working={isWorking}
onClick={() => handleConfirm(modal)}
>
{confirmText}
</StyledButton>
</Actions>
</>
)}
/>

View File

@@ -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 (
<Button icon="link" onClick={handleLinkCopy} {...otherProps}>
{isLinkCopied ? 'Link Copied' : 'Copy link'}
</Button>
);
};
export default CopyLinkButton;

View File

@@ -14,7 +14,7 @@ export const Dropdown = styled.div`
width: 270px;
border-radius: 3px;
background: #fff;
${mixin.boxShadowBorderMedium}
${mixin.boxShadowDropdown}
${props => (props.withTime ? withTimeStyles : '')}
`;

View File

@@ -26,8 +26,8 @@ const codes = {
[`issues`]: '\\e908',
[`settings`]: '\\e909',
[`close`]: '\\e913',
[`help-filled`]: '\\e912',
[`feedback`]: '\\e915',
[`feedback`]: '\\e918',
[`trash`]: '\\e912',
};
const propTypes = {

View File

@@ -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 (
<Input
{...props}
value={value}
onChange={newValue => {
setValue(newValue);
handleChange(newValue);
}}
/>
);
};
InputDebounced.propTypes = propTypes;
export default InputDebounced;

View File

@@ -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]};
`;

View File

@@ -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 <PriorityIcon type={iconType} color={priority} {...otherProps} />;
};
IssuePriorityIcon.propTypes = propTypes;
export default IssuePriorityIcon;

View File

@@ -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]};
`;

View File

@@ -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 }) => (
<TypeIcon type={type} color={type} {...otherProps} />
);
IssueTypeIcon.propTypes = propTypes;
export default IssueTypeIcon;

View File

@@ -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]}
`;

View File

@@ -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 = () => (
<ScrollOverlay data-jira-modal-id={modalIdRef.current}>
<ClickableOverlay variant={variant}>
<StyledModal className={className} variant={variant} ref={$modalRef}>
<CloseIcon type="close" variant={variant} onClick={closeModal} />
<ScrollOverlay data-jira-modal="true">
<ClickableOverlay variant={variant} ref={$clickableOverlayRef}>
<StyledModal className={className} variant={variant} width={width} ref={$modalRef}>
{withCloseIcon && <CloseIcon type="close" variant={variant} onClick={closeModal} />}
{renderContent({ close: closeModal })}
</StyledModal>
</ClickableOverlay>
@@ -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';
};

View File

@@ -2,6 +2,6 @@ import styled from 'styled-components';
export default styled.div`
width: 100%;
padding-top: 200px;
padding: 200px 0;
text-align: center;
`;

View File

@@ -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) => (
<Option
key={option.value}
className={i === 0 ? activeOptionClass : undefined}
isSelected={option.value === value}
data-select-option-value={option.value}
onMouseEnter={handleOptionMouseEnter}
onClick={() => selectOptionValue(option.value)}
>
{option.label}
</Option>
);
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 {...optionProps}>{option.label}</Option>
);
};
const renderCreatableOption = () => (
<Option

View File

@@ -6,63 +6,54 @@ import Icon from 'shared/components/Icon';
export const StyledSelect = styled.div`
position: relative;
width: 100%;
border-radius: 3px;
border: 1px solid ${color.borderLight};
border-radius: 4px;
border: 1px solid ${color.borderLightest};
background: #fff;
${font.size(14)}
&:focus {
outline: none;
border: 1px solid ${color.borderMedium};
background: #fff;
border: 1px solid ${color.borderInputFocus};
box-shadow: 0 0 0 1px ${color.borderInputFocus};
}
${props => (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 : '')}
`;

View File

@@ -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 = () => <ValueSingle>{getOptionLabel(value)}</ValueSingle>;
const renderSingleValue = () =>
propsRenderValue ? propsRenderValue({ value }) : getOptionLabel(value);
const renderMultiValue = () => (
<ValueMulti>
{value.map(optionValue => (
<ValueMultiItem key={optionValue} onClick={() => removeOptionValue(optionValue)}>
{getOptionLabel(optionValue)}
<Icon type="close" />
</ValueMultiItem>
))}
{value.map(optionValue =>
propsRenderValue ? (
propsRenderValue({ value: optionValue, removeOptionValue })
) : (
<ValueMultiItem key={optionValue} onClick={() => removeOptionValue(optionValue)}>
{getOptionLabel(optionValue)}
<Icon type="close" />
</ValueMultiItem>
),
)}
<AddMore>
<Icon type="plus" />
Add more
@@ -128,16 +141,14 @@ const Select = ({
className={className}
ref={$selectRef}
tabIndex="0"
hasIcon={!!icon}
onKeyDown={handleFocusedSelectKeydown}
invalid={invalid}
>
<ValueContainer onClick={activateDropdown}>
{icon && <StyledIcon type={icon} />}
{(!isMulti || isValueEmpty) && <ChevronIcon type="chevron-down" />}
{isValueEmpty && <Placeholder>{placeholder}</Placeholder>}
{!isValueEmpty && !isMulti && renderSingleValue()}
{!isValueEmpty && isMulti && renderMultiValue()}
{(!isMulti || isValueEmpty) && <ChevronIcon type="chevron-down" top={1} />}
</ValueContainer>
{isDropdownOpen && (
<Dropdown
@@ -152,6 +163,7 @@ const Select = ({
onChange={handleChange}
onCreate={onCreate}
isMulti={isMulti}
propsRenderOption={propsRenderOption}
/>
)}
</StyledSelect>

View File

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

View File

@@ -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 }) => (
<div className="ql-snow">
<Content className="ql-editor" dangerouslySetInnerHTML={{ __html: content }} {...otherProps} />
</div>
);
TextEditedContent.propTypes = propTypes;
export default TextEditedContent;

View File

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

View File

@@ -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 (
<EditorCont className={className} ref={$editorContRef}>
<div ref={$editorRef} {...otherProps} />
</EditorCont>
);
};
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;

View File

@@ -25,7 +25,7 @@ const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps },
<TextareaAutoSize
{...textareaProps}
onChange={event => onChange(event.target.value, event)}
ref={ref}
inputRef={ref}
/>
</StyledTextarea>
));

View File

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

View File

@@ -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 = () => (
<Tooltip className={className} ref={$tooltipRef} width={width}>
{renderContent({ close: closeTooltip })}
</Tooltip>
);
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;

View File

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

View File

@@ -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',
};

View File

@@ -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),

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
export const getTextContentsFromHtmlString = html => {
const el = document.createElement('div');
el.innerHTML = html;
return el.textContent;
};

View File

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

View File

@@ -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;
}
`,
};

View File

@@ -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 = {};

View File

@@ -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: [