diff --git a/client/.babelrc b/client/.babelrc
index f45bd23..aa70268 100644
--- a/client/.babelrc
+++ b/client/.babelrc
@@ -1,9 +1,18 @@
{
- "presets": ["@babel/preset-env", "@babel/react"],
+ "presets": [
+ [
+ "@babel/preset-env",
+ {
+ "useBuiltIns": "entry",
+ "corejs": 3
+ }
+ ],
+ "@babel/react"
+ ],
"plugins": [
["@babel/plugin-proposal-decorators", { "legacy": true }],
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-syntax-dynamic-import",
- ["@babel/plugin-proposal-class-properties", { "loose": true }],
+ ["@babel/plugin-proposal-class-properties", { "loose": true }]
]
}
diff --git a/client/.eslintrc.json b/client/.eslintrc.json
index 8f26a99..f2469b4 100644
--- a/client/.eslintrc.json
+++ b/client/.eslintrc.json
@@ -12,7 +12,10 @@
"jest": true
},
"extends": ["airbnb", "prettier", "prettier/react"],
+ "plugins": ["react-hooks"],
"rules": {
+ "react-hooks/rules-of-hooks": "error",
+ "react-hooks/exhaustive-deps": "warn",
"radix": 0,
"no-restricted-syntax": 0,
"no-await-in-loop": 0,
@@ -25,10 +28,12 @@
"import/no-cycle": 0,
"react/no-array-index-key": 0,
"react/forbid-prop-types": 0,
+ "react/state-in-constructor": 0,
+ "react/jsx-props-no-spreading": 0,
"jsx-a11y/click-events-have-key-events": 0
},
"settings": {
- // Allows us to use absolute imports within codebase
+ // Allows us to lint absolute "src" imports within codebase
"import/resolver": {
"node": {
"moduleDirectory": ["node_modules", "src/"]
diff --git a/client/jsconfig.json b/client/jsconfig.json
new file mode 100644
index 0000000..6835f58
--- /dev/null
+++ b/client/jsconfig.json
@@ -0,0 +1,8 @@
+// This config allows VSCode intellisense to work with absolute "src" imports and jsx files
+{
+ "compilerOptions": {
+ "baseUrl": "./src",
+ "jsx": "react"
+ },
+ "exclude": ["node_modules", "dist", "dev"]
+}
diff --git a/client/package-lock.json b/client/package-lock.json
index 130a964..f044fd1 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -2432,6 +2432,11 @@
"integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=",
"dev": true
},
+ "core-js": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.4.7.tgz",
+ "integrity": "sha512-qaPVGw30J1wQ0GR3GvoPqlGf9GZfKKF4kFC7kiHlcsPTqH3txrs9crCp3ZiMAXuSenhz89Jnl4GZs/67S5VOSg=="
+ },
"core-js-compat": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.4.7.tgz",
@@ -2598,6 +2603,11 @@
"integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==",
"dev": true
},
+ "csstype": {
+ "version": "2.6.7",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.7.tgz",
+ "integrity": "sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ=="
+ },
"cyclist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
@@ -2841,6 +2851,15 @@
"utila": "~0.4"
}
},
+ "dom-helpers": {
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.3.tgz",
+ "integrity": "sha512-nZD1OtwfWGRBWlpANxacBEZrEuLa16o1nh7YopFWeoF68Zt8GGEmzHu6Xv4F3XaFIC+YXtTLrzgqKxFgLEe4jw==",
+ "requires": {
+ "@babel/runtime": "^7.6.3",
+ "csstype": "^2.6.7"
+ }
+ },
"dom-serializer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
@@ -7661,6 +7680,17 @@
"prop-types": "^15.6.0"
}
},
+ "react-transition-group": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.3.0.tgz",
+ "integrity": "sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw==",
+ "requires": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ }
+ },
"read-pkg": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",
diff --git a/client/package.json b/client/package.json
index f7c07af..ad9cae1 100644
--- a/client/package.json
+++ b/client/package.json
@@ -37,6 +37,7 @@
"dependencies": {
"axios": "^0.19.0",
"color": "^3.1.2",
+ "core-js": "^3.4.7",
"history": "^4.10.1",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.15",
@@ -47,6 +48,7 @@
"react-dom": "^16.12.0",
"react-router-dom": "^5.1.2",
"react-textarea-autosize": "^7.1.2",
+ "react-transition-group": "^4.3.0",
"styled-components": "^4.4.1",
"sweet-pubsub": "^1.1.2"
},
diff --git a/client/src/browserHistory.js b/client/src/browserHistory.js
new file mode 100644
index 0000000..9937105
--- /dev/null
+++ b/client/src/browserHistory.js
@@ -0,0 +1,3 @@
+import { createBrowserHistory } from 'history';
+
+export default createBrowserHistory();
diff --git a/client/src/components/App/App.jsx b/client/src/components/App/App.jsx
new file mode 100644
index 0000000..5d67ec9
--- /dev/null
+++ b/client/src/components/App/App.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+
+import Toast from './Toast';
+import Routes from './Routes';
+import NormalizeStyles from './NormalizeStyles';
+import FontStyles from './FontStyles';
+import BaseStyles from './BaseStyles';
+
+const App = () => (
+ <>
+
+
+
+
+
+ >
+);
+
+export default App;
diff --git a/client/src/components/App/AppStyles.js b/client/src/components/App/AppStyles.js
new file mode 100644
index 0000000..67814ef
--- /dev/null
+++ b/client/src/components/App/AppStyles.js
@@ -0,0 +1,6 @@
+import styled from 'styled-components';
+
+export const Main = styled.main`
+ width: 100%;
+ padding-left: 75px;
+`;
diff --git a/client/src/components/App/BaseStyles.js b/client/src/components/App/BaseStyles.js
new file mode 100644
index 0000000..6074be1
--- /dev/null
+++ b/client/src/components/App/BaseStyles.js
@@ -0,0 +1,108 @@
+import { createGlobalStyle } from 'styled-components';
+
+import { color, font, mixin } from 'shared/utils/styles';
+
+export default createGlobalStyle`
+ html, body, #root {
+ height: 100%;
+ min-height: 100%;
+ }
+
+ body {
+ color: ${color.textDarkest};
+ -webkit-tap-highlight-color: transparent;
+ line-height: 1.2;
+ ${font.size(16)}
+ ${font.regular}
+ }
+
+ #root {
+ display: flex;
+ flex-direction: column;
+ }
+
+ button,
+ input,
+ optgroup,
+ select,
+ textarea {
+ ${font.regular}
+ }
+
+ *, *:after, *:before, input[type="search"] {
+ box-sizing: border-box;
+ }
+
+ a, a:hover, a:visited, a:active {
+ text-decoration: none;
+ }
+
+ ul {
+ list-style: none;
+ }
+
+ ul, li, ol, dd, h1, h2, h3, h4, h5, h6, p {
+ padding: 0;
+ margin: 0;
+ }
+
+ h1, h2, h3, h4, h5, h6, strong {
+ ${font.bold}
+ }
+
+ button {
+ background: none;
+ border: none;
+ }
+
+ /* Workaround for IE11 focus highlighting for select elements */
+ select::-ms-value {
+ background: none;
+ color: #42413d;
+ }
+
+ [role="button"], button, input, select, textarea {
+ outline: none;
+ &:focus {
+ outline: none;
+ }
+ &:disabled {
+ opacity: 1;
+ }
+ }
+ [role="button"], button, input, textarea {
+ appearance: none;
+ }
+ select:-moz-focusring {
+ color: transparent;
+ text-shadow: 0 0 0 #000;
+ }
+ select::-ms-expand {
+ display: none;
+ }
+ select option {
+ color: ${color.textDarkest};
+ }
+
+ p {
+ line-height: 1.6;
+ a {
+ ${mixin.link()}
+ }
+ }
+
+ textarea {
+ line-height: 1.6;
+ }
+
+ body, select {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ html {
+ touch-action: manipulation;
+ }
+
+ ${mixin.placeholderColor(color.textLightBlue)}
+`;
diff --git a/client/src/components/App/FontStyles.js b/client/src/components/App/FontStyles.js
new file mode 100644
index 0000000..b74d56e
--- /dev/null
+++ b/client/src/components/App/FontStyles.js
@@ -0,0 +1,53 @@
+import { createGlobalStyle } from 'styled-components';
+
+import BlackWoff2 from 'shared/assets/fonts/CircularStd-Black.woff2';
+import BoldWoff2 from 'shared/assets/fonts/CircularStd-Bold.woff2';
+import MediumWoff2 from 'shared/assets/fonts/CircularStd-Medium.woff2';
+import BookWoff2 from 'shared/assets/fonts/CircularStd-Book.woff2';
+import BlackWoff from 'shared/assets/fonts/CircularStd-Black.woff';
+import BoldWoff from 'shared/assets/fonts/CircularStd-Bold.woff';
+import MediumWoff from 'shared/assets/fonts/CircularStd-Medium.woff';
+import BookWoff from 'shared/assets/fonts/CircularStd-Book.woff';
+import IconsSvg from 'shared/assets/icons/jira.svg';
+import IconsTtf from 'shared/assets/icons/jira.ttf';
+import IconsWoff from 'shared/assets/icons/jira.woff';
+
+export default createGlobalStyle`
+ @font-face {
+ font-family: "CircularStdBlack";
+ src: url("${BlackWoff2}") format("woff2"),
+ url("${BlackWoff}") format("woff");
+ font-weight: normal;
+ font-style: normal;
+ }
+ @font-face {
+ font-family: "CircularStdBold";
+ src: url("${BoldWoff2}") format("woff2"),
+ url("${BoldWoff}") format("woff");
+ font-weight: normal;
+ font-style: normal;
+ }
+ @font-face {
+ font-family: "CircularStdMedium";
+ src: url("${MediumWoff2}") format("woff2"),
+ url("${MediumWoff}") format("woff");
+ font-weight: normal;
+ font-style: normal;
+ }
+ @font-face {
+ font-family: "CircularStdBook";
+ src: url("${BookWoff2}") format("woff2"),
+ url("${BookWoff}") format("woff");
+ font-weight: normal;
+ font-style: normal;
+ }
+ @font-face {
+ font-family: "jira";
+ src:
+ url("${IconsTtf}") format("truetype"),
+ url("${IconsWoff}") format("woff"),
+ url("${IconsSvg}#jira") format("svg");
+ font-weight: normal;
+ font-style: normal;
+ }
+`;
diff --git a/client/src/components/App/NavbarLeft/Styles.js b/client/src/components/App/NavbarLeft/Styles.js
new file mode 100644
index 0000000..1530c47
--- /dev/null
+++ b/client/src/components/App/NavbarLeft/Styles.js
@@ -0,0 +1,107 @@
+import styled from 'styled-components';
+import { NavLink } from 'react-router-dom';
+
+import { font, sizes, color, mixin, zIndexValues } from 'shared/utils/styles';
+import Logo from 'shared/components/Logo';
+
+export const NavLeft = styled.aside`
+ z-index: ${zIndexValues.navLeft};
+ position: absolute;
+ top: 0;
+ left: 0;
+ overflow-x: hidden;
+ height: 100%;
+ width: ${sizes.appNavBarLeftWidth}px;
+ background: ${color.primary};
+ transition: all 0.1s;
+ ${mixin.hardwareAccelerate}
+ &:hover {
+ width: 260px;
+ box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.6);
+ }
+`;
+
+export const LogoLink = styled(NavLink)`
+ display: block;
+ position: relative;
+ left: 0;
+ margin: 40px 0 40px;
+ transition: left 0.1s;
+ &:before {
+ display: inline-block;
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 0;
+ height: 50px;
+ width: 20px;
+ background: ${color.primary};
+ }
+ ${NavLeft}:hover & {
+ left: 3px;
+ &:before {
+ display: none;
+ }
+ }
+`;
+
+export const StyledLogo = styled(Logo)`
+ display: inline-block;
+ margin-left: 13px;
+ padding: 10px;
+ ${mixin.clickable}
+`;
+
+export const IconLink = styled(NavLink)`
+ display: block;
+ position: relative;
+ width: 100%;
+ height: 60px;
+ line-height: 60px;
+ padding-left: 67px;
+ color: rgba(255, 255, 255, 0.75);
+ transition: color 0.1s;
+ ${mixin.clickable}
+ &:before {
+ content: '';
+ display: none;
+ position: absolute;
+ top: 5px;
+ right: 0;
+ height: 50px;
+ width: 5px;
+ background: #fff;
+ border-radius: 6px 0 0 6px;
+ }
+ &.active,
+ &:hover {
+ color: #fff;
+ }
+ &.active:before {
+ display: inline-block;
+ }
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ }
+ i {
+ position: absolute;
+ left: 27px;
+ }
+`;
+
+export const LinkText = styled.div`
+ position: relative;
+ right: 12px;
+ visibility: hidden;
+ opacity: 0;
+ text-transform: uppercase;
+ transition: all 0.1s;
+ transition-property: right, visibility, opacity;
+ ${font.bold}
+ ${font.size(12)}
+ ${NavLeft}:hover & {
+ right: 0;
+ visibility: visible;
+ opacity: 1;
+ }
+`;
diff --git a/client/src/components/App/NavbarLeft/index.jsx b/client/src/components/App/NavbarLeft/index.jsx
new file mode 100644
index 0000000..da3ef5c
--- /dev/null
+++ b/client/src/components/App/NavbarLeft/index.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+import { Icon } from 'shared/components';
+import { NavLeft, LogoLink, StyledLogo, IconLink, LinkText } from './Styles';
+
+const NavbarLeft = () => (
+
+
+
+
+
+
+ Projects
+
+
+
+ Subcontractors
+
+
+
+ Bids
+
+
+);
+
+export default NavbarLeft;
diff --git a/client/src/components/App/NormalizeStyles.js b/client/src/components/App/NormalizeStyles.js
new file mode 100644
index 0000000..f054d37
--- /dev/null
+++ b/client/src/components/App/NormalizeStyles.js
@@ -0,0 +1,152 @@
+import { createGlobalStyle } from 'styled-components';
+
+/** DO NOT ALTER THIS FILE. It is a copy of https://necolas.github.io/normalize.css/ */
+
+export default createGlobalStyle`
+ html {
+ line-height: 1.15;
+ -webkit-text-size-adjust: 100%;
+ }
+ body {
+ margin: 0;
+ }
+ main {
+ display: block;
+ }
+ h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+ }
+ hr {
+ box-sizing: content-box;
+ height: 0;
+ overflow: visible;
+ }
+ pre {
+ font-family: monospace, monospace;
+ font-size: 1em;
+ }
+ a {
+ background-color: transparent;
+ }
+ abbr[title] {
+ border-bottom: none;
+ text-decoration: underline;
+ text-decoration: underline dotted;
+ }
+ b,
+ strong {
+ font-weight: bolder;
+ }
+ code,
+ kbd,
+ samp {
+ font-family: monospace, monospace;
+ font-size: 1em;
+ }
+ small {
+ font-size: 80%;
+ }
+ sub,
+ sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ }
+ sub {
+ bottom: -0.25em;
+ }
+ sup {
+ top: -0.5em;
+ }
+ img {
+ border-style: none;
+ }
+ button,
+ input,
+ optgroup,
+ select,
+ textarea {
+ font-family: inherit;
+ font-size: 100%;
+ line-height: 1.15;
+ margin: 0;
+ }
+ button,
+ input {
+ overflow: visible;
+ }
+ button,
+ select {
+ text-transform: none;
+ }
+ button,
+ [type="button"],
+ [type="reset"],
+ [type="submit"] {
+ -webkit-appearance: button;
+ }
+ button::-moz-focus-inner,
+ [type="button"]::-moz-focus-inner,
+ [type="reset"]::-moz-focus-inner,
+ [type="submit"]::-moz-focus-inner {
+ border-style: none;
+ padding: 0;
+ }
+ button:-moz-focusring,
+ [type="button"]:-moz-focusring,
+ [type="reset"]:-moz-focusring,
+ [type="submit"]:-moz-focusring {
+ outline: 1px dotted ButtonText;
+ }
+ fieldset {
+ padding: 0.35em 0.75em 0.625em;
+ }
+ legend {
+ box-sizing: border-box;
+ color: inherit;
+ display: table;
+ max-width: 100%;
+ padding: 0;
+ white-space: normal;
+ }
+ progress {
+ vertical-align: baseline;
+ }
+ textarea {
+ overflow: auto;
+ }
+ [type="checkbox"],
+ [type="radio"] {
+ box-sizing: border-box;
+ padding: 0;
+ }
+ [type="number"]::-webkit-inner-spin-button,
+ [type="number"]::-webkit-outer-spin-button {
+ height: auto;
+ }
+ [type="search"] {
+ -webkit-appearance: textfield;
+ outline-offset: -2px;
+ }
+ [type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+ }
+ ::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ font: inherit;
+ }
+ details {
+ display: block;
+ }
+ summary {
+ display: list-item;
+ }
+ template {
+ display: none;
+ }
+ [hidden] {
+ display: none;
+ }
+`;
diff --git a/client/src/components/App/Routes.jsx b/client/src/components/App/Routes.jsx
new file mode 100644
index 0000000..011a60a
--- /dev/null
+++ b/client/src/components/App/Routes.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import history from 'browserHistory';
+import { Router, Switch, Route } from 'react-router-dom';
+
+import PageNotFound from 'components/PageNotFound';
+import NavbarLeft from './NavbarLeft';
+
+import { Main } from './AppStyles';
+
+const Routes = () => (
+
+
+
+
+
+
+
+
+);
+
+export default Routes;
diff --git a/client/src/components/App/Toast/Styles.js b/client/src/components/App/Toast/Styles.js
new file mode 100644
index 0000000..21a15b8
--- /dev/null
+++ b/client/src/components/App/Toast/Styles.js
@@ -0,0 +1,58 @@
+import styled from 'styled-components';
+
+import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
+
+export const Container = styled.div`
+ z-index: ${zIndexValues.modal + 1};
+ position: fixed;
+ right: 30px;
+ top: 50px;
+`;
+
+export const StyledToast = styled.div`
+ position: relative;
+ margin-bottom: 5px;
+ width: 300px;
+ padding: 15px 20px;
+ border-radius: 4px;
+ color: #fff;
+ background: ${props => color[props.type]};
+ cursor: pointer;
+ transition: all 0.15s;
+ ${mixin.clearfix}
+ ${mixin.hardwareAccelerate}
+
+ &.jira-toast-enter,
+ &.jira-toast-exit.jira-toast-exit-active {
+ opacity: 0;
+ right: -10px;
+ }
+
+ &.jira-toast-exit,
+ &.jira-toast-enter.jira-toast-enter-active {
+ opacity: 1;
+ right: 0;
+ }
+
+ i {
+ position: absolute;
+ top: 13px;
+ right: 14px;
+ font-size: 22px;
+ cursor: pointer;
+ color: #fff;
+ }
+`;
+
+export const Title = styled.div`
+ padding-right: 22px;
+ ${font.size(16)}
+ ${font.medium}
+`;
+
+export const Message = styled.div`
+ padding: 8px 10px 0 0;
+ white-space: pre-wrap;
+ ${font.size(14)}
+ ${font.medium}
+`;
diff --git a/client/src/components/App/Toast/index.jsx b/client/src/components/App/Toast/index.jsx
new file mode 100644
index 0000000..867fe82
--- /dev/null
+++ b/client/src/components/App/Toast/index.jsx
@@ -0,0 +1,62 @@
+import React, { Component } from 'react';
+import { CSSTransition, TransitionGroup } from 'react-transition-group';
+import pubsub from 'sweet-pubsub';
+import { uniqueId } from 'lodash';
+
+import { Icon } from 'shared/components';
+import { Container, StyledToast, Title, Message } from './Styles';
+
+class Toast extends Component {
+ state = { toasts: [] };
+
+ componentDidMount() {
+ pubsub.on('toast', this.addToast);
+ }
+
+ componentWillUnmount() {
+ pubsub.off('toast', this.addToast);
+ }
+
+ addToast = ({ type = 'success', title, message, duration = 5 }) => {
+ const id = uniqueId('toast-');
+
+ this.setState(state => ({
+ toasts: [...state.toasts, { id, type, title, message }],
+ }));
+
+ if (duration) {
+ setTimeout(() => this.removeToast(id), duration * 1000);
+ }
+ };
+
+ removeToast = id => {
+ this.setState(state => ({
+ toasts: state.toasts.filter(toast => toast.id !== id),
+ }));
+ };
+
+ render() {
+ const { toasts } = this.state;
+ return (
+
+
+ {toasts.map(toast => (
+
+ this.removeToast(toast.id)}
+ >
+
+ {toast.title && {toast.title}}
+ {toast.message && {toast.message}}
+
+
+ ))}
+
+
+ );
+ }
+}
+
+export default Toast;
diff --git a/client/src/components/PageNotFound/Styles.js b/client/src/components/PageNotFound/Styles.js
new file mode 100644
index 0000000..1d72f9b
--- /dev/null
+++ b/client/src/components/PageNotFound/Styles.js
@@ -0,0 +1,23 @@
+import styled from 'styled-components';
+
+import { color, font } from 'shared/utils/styles';
+
+export const Wrapper = styled.div`
+ margin: 50px auto 0;
+ max-width: 500px;
+ padding: 50px 50px 60px;
+ text-align: center;
+ border-radius: 4px;
+ background: ${color.backgroundLight};
+`;
+
+export const Heading = styled.h1`
+ ${font.size(60)}
+`;
+
+export const Message = styled.p`
+ color: ${color.textDark};
+ padding: 10px 0 30px;
+ line-height: 1.35;
+ ${font.size(20)}
+`;
diff --git a/client/src/components/PageNotFound/index.jsx b/client/src/components/PageNotFound/index.jsx
new file mode 100644
index 0000000..266c7ba
--- /dev/null
+++ b/client/src/components/PageNotFound/index.jsx
@@ -0,0 +1,104 @@
+import React, { useState } from 'react';
+import { Link } from 'react-router-dom';
+
+import {
+ Button,
+ ConfirmModal,
+ Avatar,
+ DatePicker,
+ Input,
+ Modal,
+ Select,
+ Textarea,
+ Spinner,
+} from 'shared/components';
+import { Wrapper, Heading, Message } from './Styles';
+
+const PageNotFound = () => {
+ const [dateValue, setDateValue] = useState(null);
+ const [inputValue, setInputValue] = useState('');
+ const [isModalOpen, setModalOpen] = useState(false);
+ const [selectValue, setSelectValue] = useState('');
+ const [selectOptions, setSelectOptions] = useState([
+ { label: 'one', value: '1' },
+ { label: 'two', value: '2' },
+ { label: 'three', value: '3' },
+ { label: 'four', value: '4' },
+ { label: 'five', value: '5' },
+ { label: 'six', value: '6' },
+ { label: 'seven', value: '7' },
+ { label: 'eight', value: '8' },
+ { label: 'nine', value: '9' },
+ { label: 'ten', value: '10' },
+ ]);
+ console.log('ha');
+ return (
+
+ 404
+ We cannot find the page you are looking for.
+
+
+
+
+
+ );
+};
+
+export default PageNotFound;
diff --git a/client/src/index.jsx b/client/src/index.jsx
old mode 100644
new mode 100755
index 90e1cef..7056f83
--- a/client/src/index.jsx
+++ b/client/src/index.jsx
@@ -1,5 +1,8 @@
-import React from 'react';
+import 'core-js/stable';
+import React from 'react';
import ReactDOM from 'react-dom';
-ReactDOM.render(
YOOOOOOOOOO
, document.getElementById('root'));
+import App from 'components/App/App';
+
+ReactDOM.render(, document.getElementById('root'));
diff --git a/client/src/shared/assets/fonts/CircularStd-Black.eot b/client/src/shared/assets/fonts/CircularStd-Black.eot
new file mode 100755
index 0000000..2dfb730
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Black.eot differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Black.otf b/client/src/shared/assets/fonts/CircularStd-Black.otf
new file mode 100755
index 0000000..cbb206e
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Black.otf differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Black.svg b/client/src/shared/assets/fonts/CircularStd-Black.svg
new file mode 100755
index 0000000..a2f6bc3
--- /dev/null
+++ b/client/src/shared/assets/fonts/CircularStd-Black.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/shared/assets/fonts/CircularStd-Black.ttf b/client/src/shared/assets/fonts/CircularStd-Black.ttf
new file mode 100755
index 0000000..ba37192
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Black.ttf differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Black.woff b/client/src/shared/assets/fonts/CircularStd-Black.woff
new file mode 100755
index 0000000..1cbce1c
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Black.woff differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Black.woff2 b/client/src/shared/assets/fonts/CircularStd-Black.woff2
new file mode 100755
index 0000000..21c9327
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Black.woff2 differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.eot b/client/src/shared/assets/fonts/CircularStd-Bold.eot
new file mode 100755
index 0000000..275fce3
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Bold.eot differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.otf b/client/src/shared/assets/fonts/CircularStd-Bold.otf
new file mode 100755
index 0000000..3374c7b
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Bold.otf differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.svg b/client/src/shared/assets/fonts/CircularStd-Bold.svg
new file mode 100755
index 0000000..e4fa120
--- /dev/null
+++ b/client/src/shared/assets/fonts/CircularStd-Bold.svg
@@ -0,0 +1,13533 @@
+
+
+
diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.ttf b/client/src/shared/assets/fonts/CircularStd-Bold.ttf
new file mode 100755
index 0000000..291e4d2
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Bold.ttf differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.woff b/client/src/shared/assets/fonts/CircularStd-Bold.woff
new file mode 100755
index 0000000..66bc04d
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Bold.woff differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Bold.woff2 b/client/src/shared/assets/fonts/CircularStd-Bold.woff2
new file mode 100755
index 0000000..e9eff3d
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Bold.woff2 differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Book.eot b/client/src/shared/assets/fonts/CircularStd-Book.eot
new file mode 100755
index 0000000..d4effe7
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Book.eot differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Book.otf b/client/src/shared/assets/fonts/CircularStd-Book.otf
new file mode 100755
index 0000000..e4c2e62
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Book.otf differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Book.svg b/client/src/shared/assets/fonts/CircularStd-Book.svg
new file mode 100755
index 0000000..146a87c
--- /dev/null
+++ b/client/src/shared/assets/fonts/CircularStd-Book.svg
@@ -0,0 +1,9962 @@
+
+
+
diff --git a/client/src/shared/assets/fonts/CircularStd-Book.ttf b/client/src/shared/assets/fonts/CircularStd-Book.ttf
new file mode 100755
index 0000000..c55d6a6
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Book.ttf differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Book.woff b/client/src/shared/assets/fonts/CircularStd-Book.woff
new file mode 100755
index 0000000..a6c48ce
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Book.woff differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Book.woff2 b/client/src/shared/assets/fonts/CircularStd-Book.woff2
new file mode 100755
index 0000000..6830dc2
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Book.woff2 differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.eot b/client/src/shared/assets/fonts/CircularStd-Medium.eot
new file mode 100755
index 0000000..793e837
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Medium.eot differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.otf b/client/src/shared/assets/fonts/CircularStd-Medium.otf
new file mode 100755
index 0000000..931f415
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Medium.otf differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.svg b/client/src/shared/assets/fonts/CircularStd-Medium.svg
new file mode 100755
index 0000000..363f853
--- /dev/null
+++ b/client/src/shared/assets/fonts/CircularStd-Medium.svg
@@ -0,0 +1,13507 @@
+
+
+
diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.ttf b/client/src/shared/assets/fonts/CircularStd-Medium.ttf
new file mode 100755
index 0000000..4b50d1e
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Medium.ttf differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.woff b/client/src/shared/assets/fonts/CircularStd-Medium.woff
new file mode 100755
index 0000000..6de2499
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Medium.woff differ
diff --git a/client/src/shared/assets/fonts/CircularStd-Medium.woff2 b/client/src/shared/assets/fonts/CircularStd-Medium.woff2
new file mode 100755
index 0000000..edc03eb
Binary files /dev/null and b/client/src/shared/assets/fonts/CircularStd-Medium.woff2 differ
diff --git a/client/src/shared/assets/icons/jira.svg b/client/src/shared/assets/icons/jira.svg
new file mode 100755
index 0000000..8fefa58
--- /dev/null
+++ b/client/src/shared/assets/icons/jira.svg
@@ -0,0 +1,54 @@
+
+
+
diff --git a/client/src/shared/assets/icons/jira.ttf b/client/src/shared/assets/icons/jira.ttf
new file mode 100755
index 0000000..6be71d3
Binary files /dev/null and b/client/src/shared/assets/icons/jira.ttf differ
diff --git a/client/src/shared/assets/icons/jira.woff b/client/src/shared/assets/icons/jira.woff
new file mode 100755
index 0000000..dbd5437
Binary files /dev/null and b/client/src/shared/assets/icons/jira.woff differ
diff --git a/client/src/shared/assets/icons/jira.woff2 b/client/src/shared/assets/icons/jira.woff2
new file mode 100755
index 0000000..b2a6b91
Binary files /dev/null and b/client/src/shared/assets/icons/jira.woff2 differ
diff --git a/client/src/shared/components/Avatar/Styles.js b/client/src/shared/components/Avatar/Styles.js
new file mode 100644
index 0000000..ea7b464
--- /dev/null
+++ b/client/src/shared/components/Avatar/Styles.js
@@ -0,0 +1,30 @@
+import styled from 'styled-components';
+
+import { font, mixin } from 'shared/utils/styles';
+
+export const Image = styled.div`
+ display: inline-block;
+ width: ${props => props.size}px;
+ height: ${props => props.size}px;
+ border-radius: 100%;
+ background-image: url('${props => props.avatarUrl}');
+ ${mixin.backgroundImage}
+`;
+
+export const Letter = styled.div`
+ display: inline-block;
+ width: ${props => props.size}px;
+ height: ${props => props.size}px;
+ border-radius: 100%;
+ text-transform: uppercase;
+ color: #fff;
+ background: ${props => props.color};
+ ${font.medium}
+ ${props => font.size(Math.round(props.size / 1.7))}
+ & > span {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ }
+`;
diff --git a/client/src/shared/components/Avatar/index.jsx b/client/src/shared/components/Avatar/index.jsx
new file mode 100644
index 0000000..bf289f7
--- /dev/null
+++ b/client/src/shared/components/Avatar/index.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Image, Letter } from './Styles';
+
+const propTypes = {
+ className: PropTypes.string,
+ avatarUrl: PropTypes.string,
+ name: PropTypes.string,
+ size: PropTypes.number,
+};
+
+const defaultProps = {
+ className: undefined,
+ avatarUrl: null,
+ name: '',
+ size: 24,
+};
+
+const colors = [
+ '#DA7657',
+ '#6ADA57',
+ '#5784DA',
+ '#AA57DA',
+ '#DA5757',
+ '#DA5792',
+ '#57DACA',
+ '#57A5DA',
+];
+
+const getColorFromName = name => colors[name.toLocaleLowerCase().charCodeAt(0) % colors.length];
+
+const Avatar = ({ className, avatarUrl, name, size }) => {
+ if (avatarUrl) {
+ return ;
+ }
+ return (
+
+ {name.charAt(0)}
+
+ );
+};
+
+Avatar.propTypes = propTypes;
+Avatar.defaultProps = defaultProps;
+
+export default Avatar;
diff --git a/client/src/shared/components/Button/Styles.js b/client/src/shared/components/Button/Styles.js
new file mode 100644
index 0000000..d78391c
--- /dev/null
+++ b/client/src/shared/components/Button/Styles.js
@@ -0,0 +1,86 @@
+import styled, { css } from 'styled-components';
+
+import Spinner from 'shared/components/Spinner';
+import { color, font, mixin } from 'shared/utils/styles';
+
+export const StyledButton = styled.button`
+ display: inline-block;
+ height: 36px;
+ line-height: 34px;
+ padding: 0 18px;
+ vertical-align: middle;
+ white-space: nowrap;
+ text-align: center;
+ border-radius: 4px;
+ transition: all 0.1s;
+ appearance: none !important;
+ ${mixin.clickable}
+ ${font.bold}
+ ${font.size(14)}
+ ${props => (props.hollow ? hollowStyles : filledStyles)}
+ &:disabled {
+ opacity: 0.6;
+ cursor: default;
+ }
+ i {
+ position: relative;
+ top: -1px;
+ right: 4px;
+ margin-right: 7px;
+ display: inline-block;
+ vertical-align: middle;
+ line-height: 1;
+ font-size: 16px;
+ }
+ ${props => (props.iconOnly ? iconOnlyStyles : '')}
+`;
+
+const filledStyles = props => css`
+ color: #fff;
+ background: ${color[props.color]};
+ border: 1px solid ${color[props.color]};
+ ${!props.disabled &&
+ css`
+ &:hover,
+ &:focus {
+ background: ${mixin.darken(color[props.color], 0.15)};
+ border: 1px solid ${mixin.darken(color[props.color], 0.15)};
+ }
+ &:active {
+ background: ${mixin.lighten(color[props.color], 0.1)};
+ border: 1px solid ${mixin.lighten(color[props.color], 0.1)};
+ }
+ `}
+`;
+
+const hollowStyles = props => css`
+ color: ${color.textMediumBlue};
+ background: #fff;
+ border: 1px solid ${color.borderBlue};
+ ${!props.disabled &&
+ css`
+ &:hover,
+ &:focus {
+ border: 1px solid ${mixin.darken(color.borderBlue, 0.15)};
+ }
+ &:active {
+ border: 1px solid ${color.borderBlue};
+ }
+ `}
+`;
+
+const iconOnlyStyles = css`
+ padding: 0 12px;
+ i {
+ right: 0;
+ margin-right: 0;
+ }
+`;
+
+export const StyledSpinner = styled(Spinner)`
+ position: relative;
+ right: 8px;
+ display: inline-block;
+ vertical-align: middle;
+ line-height: 1;
+`;
diff --git a/client/src/shared/components/Button/index.jsx b/client/src/shared/components/Button/index.jsx
new file mode 100644
index 0000000..c257289
--- /dev/null
+++ b/client/src/shared/components/Button/index.jsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { color } from 'shared/utils/styles';
+import Icon from 'shared/components/Icon';
+import { StyledButton, StyledSpinner } from './Styles';
+
+const propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node,
+ type: PropTypes.string,
+ hollow: PropTypes.bool,
+ color: PropTypes.oneOf(['primary', 'success', 'danger']),
+ icon: PropTypes.string,
+ iconSize: PropTypes.number,
+ disabled: PropTypes.bool,
+ working: PropTypes.bool,
+ onClick: PropTypes.func,
+};
+
+const defaultProps = {
+ className: undefined,
+ children: undefined,
+ type: 'button',
+ hollow: false,
+ color: 'primary',
+ icon: undefined,
+ iconSize: undefined,
+ disabled: false,
+ working: false,
+ onClick: () => {},
+};
+
+const Button = ({
+ children,
+ hollow,
+ icon,
+ iconSize,
+ disabled,
+ working,
+ onClick = () => {},
+ ...buttonProps
+}) => (
+ {
+ if (!disabled && !working) {
+ onClick();
+ }
+ }}
+ disabled={disabled || working}
+ working={working}
+ iconOnly={!children}
+ >
+ {working && }
+ {!working && icon && (
+
+ )}
+ {children}
+
+);
+
+Button.propTypes = propTypes;
+Button.defaultProps = defaultProps;
+
+export default Button;
diff --git a/client/src/shared/components/ConfirmModal/Styles.js b/client/src/shared/components/ConfirmModal/Styles.js
new file mode 100644
index 0000000..e9c7843
--- /dev/null
+++ b/client/src/shared/components/ConfirmModal/Styles.js
@@ -0,0 +1,38 @@
+import styled from 'styled-components';
+
+import Modal from 'shared/components/Modal';
+import Input from 'shared/components/Input';
+import Button from 'shared/components/Button';
+import { font } from 'shared/utils/styles';
+
+export const StyledConfirmModal = styled(Modal)`
+ padding: 45px 50px 50px;
+`;
+
+export const Title = styled.div`
+ padding-bottom: 25px;
+ ${font.bold}
+ ${font.size(24)}
+ line-height: 1.5;
+`;
+
+export const Message = styled.p`
+ padding-bottom: 25px;
+ white-space: pre-wrap;
+ ${font.size(16)}
+`;
+
+export const InputLabel = styled.div`
+ padding-bottom: 12px;
+ ${font.bold}
+ ${font.size(16)}
+`;
+
+export const StyledInput = styled(Input)`
+ margin-bottom: 25px;
+ max-width: 220px;
+`;
+
+export const StyledButton = styled(Button)`
+ margin: 5px 20px 0 0;
+`;
diff --git a/client/src/shared/components/ConfirmModal/index.jsx b/client/src/shared/components/ConfirmModal/index.jsx
new file mode 100644
index 0000000..5d08fe3
--- /dev/null
+++ b/client/src/shared/components/ConfirmModal/index.jsx
@@ -0,0 +1,99 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ StyledConfirmModal,
+ Title,
+ Message,
+ InputLabel,
+ StyledInput,
+ StyledButton,
+} from './Styles';
+
+const propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string,
+ message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ confirmText: PropTypes.string,
+ cancelText: PropTypes.string,
+ confirmInput: PropTypes.string,
+ type: PropTypes.oneOf(['primary', 'danger']),
+ onConfirm: PropTypes.func.isRequired,
+ renderLink: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ className: undefined,
+ title: 'Warning',
+ message: 'Are you sure you want to continue with this action?',
+ confirmText: 'Confirm',
+ cancelText: 'Cancel',
+ confirmInput: null,
+ type: 'primary',
+};
+
+const ConfirmModal = ({
+ className,
+ title,
+ message,
+ confirmText,
+ cancelText,
+ confirmInput,
+ type,
+ onConfirm,
+ renderLink,
+}) => {
+ const [isConfirmEnabled, setConfirmEnabled] = useState(false);
+ const [isWorking, setWorking] = useState(false);
+
+ const handleConfirm = modal => {
+ setWorking(true);
+ onConfirm({
+ close: () => {
+ modal.close();
+ setWorking(false);
+ },
+ });
+ };
+
+ const handleConfirmInputChange = value =>
+ setConfirmEnabled(value.trim().toLowerCase() === confirmInput.toLowerCase());
+
+ return (
+ setConfirmEnabled(false)}
+ renderLink={renderLink}
+ renderContent={modal => (
+ <>
+ {title}
+ {message && {message}}
+ {confirmInput && (
+ <>
+ {`Type ${confirmInput} below to confirm.`}
+ handleConfirmInputChange(value)} />
+
+ >
+ )}
+
+ {cancelText}
+
+ handleConfirm(modal)}
+ >
+ {confirmText}
+
+ >
+ )}
+ />
+ );
+};
+
+ConfirmModal.propTypes = propTypes;
+ConfirmModal.defaultProps = defaultProps;
+
+export default ConfirmModal;
diff --git a/client/src/shared/components/DatePicker/DateSection.jsx b/client/src/shared/components/DatePicker/DateSection.jsx
new file mode 100644
index 0000000..110f7c1
--- /dev/null
+++ b/client/src/shared/components/DatePicker/DateSection.jsx
@@ -0,0 +1,117 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import moment from 'moment';
+import { times, range } from 'lodash';
+
+import { formatDate, formatDateTimeForAPI } from 'shared/utils/dateTime';
+import Icon from 'shared/components/Icon';
+import {
+ DateSection,
+ YearSelect,
+ SelectedMonthYear,
+ Grid,
+ PrevNextIcons,
+ DayName,
+ Day,
+} from './Styles';
+
+const propTypes = {
+ withTime: PropTypes.bool,
+ value: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ setDropdownOpen: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ withTime: true,
+ value: null,
+};
+
+const DatePickerDateSection = ({ withTime, value, onChange, setDropdownOpen }) => {
+ const [selectedMonth, setSelectedMonth] = useState(moment(value || undefined).startOf('month'));
+
+ const handleYearChange = year => {
+ setSelectedMonth(moment(selectedMonth).set({ year: parseInt(year) }));
+ };
+
+ const handleMonthChange = addOrSubtract => {
+ setSelectedMonth(moment(selectedMonth)[addOrSubtract](1, 'month'));
+ };
+
+ const handleDayChange = newDate => {
+ const existingHour = value ? moment(value).hour() : '00';
+ const existingMinute = value ? moment(value).minute() : '00';
+
+ const newDateWithExistingTime = newDate.set({
+ hour: existingHour,
+ minute: existingMinute,
+ });
+ onChange(formatDateTimeForAPI(newDateWithExistingTime));
+
+ if (!withTime) {
+ setDropdownOpen(false);
+ }
+ };
+
+ const generateYears = () => times(50, i => ({ label: `${i + 2010}`, value: `${i + 2010}` }));
+
+ const generateWeekDayNames = () => moment.weekdaysMin(true);
+
+ const generateFillerDaysBeforeMonthStart = () => {
+ const count = selectedMonth.diff(moment(selectedMonth).startOf('week'), 'days');
+ return range(count);
+ };
+
+ const generateMonthDays = () =>
+ times(selectedMonth.daysInMonth()).map(i => moment(selectedMonth).add(i, 'days'));
+
+ const generateFillerDaysAfterMonthEnd = () => {
+ const selectedMonthEnd = moment(selectedMonth).endOf('month');
+ const weekEnd = moment(selectedMonthEnd).endOf('week');
+ const count = weekEnd.diff(selectedMonthEnd, 'days');
+ return range(count);
+ };
+
+ return (
+
+ {formatDate(selectedMonth, 'MMM YYYY')}
+ handleYearChange(event.target.value)}>
+ {[{ label: 'Year', value: '' }, ...generateYears()].map(option => (
+
+ ))}
+
+
+ handleMonthChange('subtract')} />
+ handleMonthChange('add')} />
+
+
+ {generateWeekDayNames().map(name => (
+ {name}
+ ))}
+ {generateFillerDaysBeforeMonthStart().map(i => (
+
+ ))}
+ {generateMonthDays().map(date => (
+ handleDayChange(date)}
+ >
+ {formatDate(date, 'D')}
+
+ ))}
+ {generateFillerDaysAfterMonthEnd().map(i => (
+
+ ))}
+
+
+ );
+};
+
+DatePickerDateSection.propTypes = propTypes;
+DatePickerDateSection.defaultProps = defaultProps;
+
+export default DatePickerDateSection;
diff --git a/client/src/shared/components/DatePicker/Styles.js b/client/src/shared/components/DatePicker/Styles.js
new file mode 100644
index 0000000..8b38da1
--- /dev/null
+++ b/client/src/shared/components/DatePicker/Styles.js
@@ -0,0 +1,116 @@
+import styled from 'styled-components';
+
+import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
+
+export const StyledDatePicker = styled.div`
+ position: relative;
+`;
+
+export const Dropdown = styled.div`
+ z-index: ${zIndexValues.dropdown};
+ position: absolute;
+ top: 130%;
+ right: 0;
+ width: 270px;
+ border-radius: 4px;
+ background: #fff;
+ ${mixin.boxShadowBorderMedium}
+ ${props => (props.withTime ? withTimeStyles : '')}
+`;
+
+const withTimeStyles = `
+ width: 360px;
+ padding-right: 90px;
+`;
+
+export const DateSection = styled.div`
+ position: relative;
+ padding: 20px;
+`;
+
+export const SelectedMonthYear = styled.div`
+ display: inline-block;
+ padding-left: 7px;
+ ${font.bold}
+ ${font.size(16)}
+`;
+
+export const YearSelect = styled.select`
+ margin-left: 5px;
+ width: 60px;
+ height: 22px;
+ ${font.size(13)}
+`;
+
+export const PrevNextIcons = styled.div`
+ position: absolute;
+ top: 12px;
+ right: 19px;
+ i {
+ padding: 7px 5px 4px;
+ font-size: 22px;
+ color: ${color.textLight};
+ ${mixin.clickable}
+ &:hover {
+ color: ${color.textDarkest};
+ }
+ }
+`;
+
+export const Grid = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ padding-top: 15px;
+ text-align: center;
+`;
+
+export const DayName = styled.div`
+ width: 14.28%;
+ height: 30px;
+ line-height: 30px;
+ color: ${color.textLight};
+ ${font.size(13)}
+`;
+
+export const Day = styled.div`
+ width: 14.28%;
+ height: 30px;
+ line-height: 30px;
+ border-radius: 4px;
+ ${font.size(15)}
+ ${props => (!props.isFiller ? hoverStyles : '')}
+ ${props => (props.isToday ? font.bold : '')}
+ ${props => (props.isSelected ? selectedStyles : '')}
+`;
+
+export const TimeSection = styled.div`
+ position: absolute;
+ top: 0;
+ right: 0;
+ height: 100%;
+ width: 90px;
+ padding: 5px 0;
+ border-left: 1px solid ${color.borderLight};
+ ${mixin.scrollableY}
+`;
+
+export const Time = styled.div`
+ padding: 5px 0 5px 20px;
+ ${font.size(14)}
+ ${props => (!props.isFiller ? hoverStyles : '')}
+ ${props => (props.isSelected ? selectedStyles : '')}
+`;
+
+const hoverStyles = `
+ ${mixin.clickable}
+ &:hover {
+ background: ${color.backgroundMedium};
+ }
+`;
+
+const selectedStyles = `
+ color: #fff;
+ &:hover, & {
+ background: ${color.primary};
+ }
+`;
diff --git a/client/src/shared/components/DatePicker/TimeSection.jsx b/client/src/shared/components/DatePicker/TimeSection.jsx
new file mode 100644
index 0000000..04f3b6c
--- /dev/null
+++ b/client/src/shared/components/DatePicker/TimeSection.jsx
@@ -0,0 +1,76 @@
+import React, { useLayoutEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import moment from 'moment';
+import { range } from 'lodash';
+
+import { formatDate, formatDateTimeForAPI } from 'shared/utils/dateTime';
+import { TimeSection, Time } from './Styles';
+
+const propTypes = {
+ value: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ setDropdownOpen: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ value: null,
+};
+
+const DatePickerTimeSection = ({ value, onChange, setDropdownOpen }) => {
+ const $sectionRef = useRef();
+ const formattedTimeValue = formatDate(value, 'HH:mm');
+
+ useLayoutEffect(() => {
+ const scrollToSelectedTime = () => {
+ if (!$sectionRef.current) return;
+
+ const $selectedTime = $sectionRef.current.querySelector(
+ `[data-time="${formattedTimeValue}"]`,
+ );
+ if (!$selectedTime) return;
+
+ $sectionRef.current.scrollTop = $selectedTime.offsetTop - 80;
+ };
+ scrollToSelectedTime();
+ }, [formattedTimeValue]);
+
+ const handleTimeChange = newTime => {
+ const [newHour, newMinute] = newTime.split(':');
+ const existingDate = moment(value || undefined);
+
+ const existingDateWithNewTime = existingDate.set({
+ hour: parseInt(newHour),
+ minute: parseInt(newMinute),
+ });
+ onChange(formatDateTimeForAPI(existingDateWithNewTime));
+ setDropdownOpen(false);
+ };
+
+ const generateTimes = () =>
+ range(48).map(i => {
+ const hour = `${Math.floor(i / 2)}`;
+ const paddedHour = hour.length < 2 ? `0${hour}` : hour;
+ const minute = i % 2 === 0 ? '00' : '30';
+ return `${paddedHour}:${minute}`;
+ });
+
+ return (
+
+ {generateTimes().map(time => (
+
+ ))}
+
+ );
+};
+
+DatePickerTimeSection.propTypes = propTypes;
+DatePickerTimeSection.defaultProps = defaultProps;
+
+export default DatePickerTimeSection;
diff --git a/client/src/shared/components/DatePicker/index.jsx b/client/src/shared/components/DatePicker/index.jsx
new file mode 100644
index 0000000..d3b95b8
--- /dev/null
+++ b/client/src/shared/components/DatePicker/index.jsx
@@ -0,0 +1,65 @@
+import React, { useState, useRef } from 'react';
+import PropTypes from 'prop-types';
+
+import { formatDate, formatDateTime } from 'shared/utils/dateTime';
+import useOnOutsideClick from 'shared/hooks/onOutsideClick';
+import Input from 'shared/components/Input';
+import DateSection from './DateSection';
+import TimeSection from './TimeSection';
+import { StyledDatePicker, Dropdown } from './Styles';
+
+const propTypes = {
+ className: PropTypes.string,
+ withTime: PropTypes.bool,
+ value: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ className: undefined,
+ withTime: true,
+ value: null,
+};
+
+const DatePicker = ({ className, withTime, value, onChange, ...inputProps }) => {
+ const [isDropdownOpen, setDropdownOpen] = useState(false);
+ const $containerRef = useRef();
+
+ useOnOutsideClick($containerRef, isDropdownOpen, () => setDropdownOpen(false));
+
+ const formatValueForInput = () => {
+ if (!value) return '';
+ return withTime ? formatDateTime(value) : formatDate(value);
+ };
+
+ return (
+
+ setDropdownOpen(true)}
+ />
+ {isDropdownOpen && (
+
+
+ {withTime && (
+
+ )}
+
+ )}
+
+ );
+};
+
+DatePicker.propTypes = propTypes;
+DatePicker.defaultProps = defaultProps;
+
+export default DatePicker;
diff --git a/client/src/shared/components/Icon/Styles.js b/client/src/shared/components/Icon/Styles.js
new file mode 100644
index 0000000..2c67970
--- /dev/null
+++ b/client/src/shared/components/Icon/Styles.js
@@ -0,0 +1,20 @@
+import styled from 'styled-components';
+
+export default styled.i`
+ display: inline-block;
+ font-size: ${props => `${props.size}px`};
+ ${props =>
+ props.left || props.top ? `transform: translate(${props.left}px, ${props.top}px);` : ''}
+ &:before {
+ content: "${props => props.code}";
+ font-family: "jira" !important;
+ speak: none;
+ font-style: normal;
+ font-weight: normal;
+ font-variant: normal;
+ text-transform: none;
+ line-height: 1;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+`;
diff --git a/client/src/shared/components/Icon/index.jsx b/client/src/shared/components/Icon/index.jsx
new file mode 100644
index 0000000..921c30c
--- /dev/null
+++ b/client/src/shared/components/Icon/index.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import StyledIcon from './Styles';
+
+const codes = {
+ [`check-circle`]: '\\e86c',
+ [`check-fat`]: '\\f00c',
+ [`arrow-left`]: '\\e900',
+ [`arrow-right`]: '\\e912',
+ [`upload-thin`]: '\\e91f',
+ [`bell`]: '\\e901',
+ [`calendar`]: '\\e903',
+ [`check`]: '\\e904',
+ [`chevron-down`]: '\\e905',
+ [`chevron-left`]: '\\e906',
+ [`chevron-right`]: '\\e907',
+ [`chevron-up`]: '\\e908',
+ [`clock`]: '\\e909',
+ [`download`]: '\\e90a',
+ [`plus`]: '\\e90c',
+ [`refresh`]: '\\e90d',
+ [`search`]: '\\e90e',
+ [`upload`]: '\\e90f',
+ [`close`]: '\\e910',
+ [`archive`]: '\\e915',
+ [`briefcase`]: '\\e916',
+ [`settings`]: '\\e902',
+ [`email`]: '\\e914',
+ [`lock`]: '\\e913',
+ [`dashboard`]: '\\e917',
+ [`alert`]: '\\e911',
+ [`edit`]: '\\e918',
+ [`delete`]: '\\e919',
+ [`sort`]: '\\f0dc',
+ [`sort-up`]: '\\f0d8',
+ [`sort-down`]: '\\f0d7',
+ [`euro`]: '\\f153',
+ [`folder-plus`]: '\\e921',
+ [`folder-minus`]: '\\e920',
+ [`file`]: '\\e90b',
+ [`file-text`]: '\\e924',
+};
+
+const propTypes = {
+ type: PropTypes.oneOf(Object.keys(codes)).isRequired,
+ className: PropTypes.string,
+ size: PropTypes.number,
+ left: PropTypes.number,
+ top: PropTypes.number,
+};
+
+const defaultProps = {
+ className: undefined,
+ size: 16,
+ left: 0,
+ top: 0,
+};
+
+const Icon = ({ type, ...iconProps }) => ;
+
+Icon.propTypes = propTypes;
+Icon.defaultProps = defaultProps;
+
+export default Icon;
diff --git a/client/src/shared/components/Input/Styles.js b/client/src/shared/components/Input/Styles.js
new file mode 100644
index 0000000..1c1b2bc
--- /dev/null
+++ b/client/src/shared/components/Input/Styles.js
@@ -0,0 +1,34 @@
+import styled from 'styled-components';
+
+import { color, font } from 'shared/utils/styles';
+
+export default styled.div`
+ position: relative;
+ display: inline-block;
+ height: 40px;
+ width: 100%;
+ input {
+ height: 100%;
+ width: 100%;
+ padding: 0 15px;
+ border-radius: 4px;
+ border: 1px solid ${color.borderLight};
+ box-shadow: inset 0 0 1px 0 rgba(0, 0, 0, 0.03);
+ background: #fff;
+ ${font.regular}
+ ${font.size(14)}
+ &:focus {
+ border: 1px solid ${color.borderMedium};
+ }
+ ${props => (props.icon ? 'padding-left: 40px;' : '')}
+ ${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
+ }
+ i {
+ position: absolute;
+ top: 12px;
+ left: 14px;
+ font-size: 16px;
+ pointer-events: none;
+ color: ${color.textMedium};
+ }
+`;
diff --git a/client/src/shared/components/Input/index.jsx b/client/src/shared/components/Input/index.jsx
new file mode 100644
index 0000000..428a995
--- /dev/null
+++ b/client/src/shared/components/Input/index.jsx
@@ -0,0 +1,42 @@
+import React, { forwardRef } from 'react';
+import PropTypes from 'prop-types';
+
+import Icon from 'shared/components/Icon';
+import StyledInput from './Styles';
+
+const propTypes = {
+ icon: PropTypes.string,
+ className: PropTypes.string,
+ invalid: PropTypes.bool,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ filter: PropTypes.instanceOf(RegExp),
+ onChange: PropTypes.func,
+};
+
+const defaultProps = {
+ icon: undefined,
+ className: undefined,
+ invalid: false,
+ value: undefined,
+ filter: undefined,
+ onChange: () => {},
+};
+
+const Input = forwardRef(({ icon, className, invalid, filter, onChange, ...inputProps }, ref) => {
+ const handleChange = event => {
+ if (!filter || filter.test(event.target.value)) {
+ onChange(event, event.target.value);
+ }
+ };
+ return (
+
+ {icon && }
+
+
+ );
+});
+
+Input.propTypes = propTypes;
+Input.defaultProps = defaultProps;
+
+export default Input;
diff --git a/client/src/shared/components/Logo.jsx b/client/src/shared/components/Logo.jsx
new file mode 100644
index 0000000..0fc0bb5
--- /dev/null
+++ b/client/src/shared/components/Logo.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const propTypes = {
+ className: PropTypes.string,
+ width: PropTypes.number,
+};
+
+const defaultProps = {
+ className: undefined,
+ width: 28,
+};
+
+const Logo = ({ className, width }) => (
+
+
+
+);
+
+Logo.propTypes = propTypes;
+Logo.defaultProps = defaultProps;
+
+export default Logo;
diff --git a/client/src/shared/components/Modal/Styles.js b/client/src/shared/components/Modal/Styles.js
new file mode 100644
index 0000000..c55abe4
--- /dev/null
+++ b/client/src/shared/components/Modal/Styles.js
@@ -0,0 +1,82 @@
+import styled, { css } from 'styled-components';
+
+import Icon from 'shared/components/Icon';
+import { color, mixin, zIndexValues } from 'shared/utils/styles';
+
+export const ScrollOverlay = styled.div`
+ z-index: ${zIndexValues.modal};
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ ${mixin.scrollableY}
+`;
+
+export const ClickableOverlay = styled.div`
+ min-height: 100%;
+ background: ${mixin.rgba(color.textLightBlue, 0.7)};
+ ${props => clickOverlayStyles[props.variant]}
+`;
+
+const clickOverlayStyles = {
+ center: css`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 50px;
+ `,
+ aside: css`
+ text-align: right;
+ `,
+};
+
+export const StyledModal = styled.div`
+ display: inline-block;
+ position: relative;
+ width: 100%;
+ background: #fff;
+ ${props => modalStyles[props.variant]}
+`;
+
+const modalStyles = {
+ center: css`
+ max-width: 600px;
+ vertical-align: middle;
+ text-align: left;
+ ${mixin.boxShadowMedium}
+ `,
+ aside: css`
+ min-height: 100vh;
+ max-width: 500px;
+ text-align: left;
+ box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.15);
+ `,
+};
+
+export const CloseIcon = styled(Icon)`
+ position: absolute;
+ font-size: 25px;
+ color: ${color.textDark};
+ ${mixin.clickable}
+ ${props => closeIconStyles[props.variant]}
+`;
+
+const closeIconStyles = {
+ center: css`
+ top: 8px;
+ right: 10px;
+ padding: 7px 7px 0;
+ `,
+ aside: css`
+ top: 10px;
+ left: -50px;
+ width: 40px;
+ height: 40px;
+ padding-top: 8px;
+ border-radius: 40px;
+ text-align: center;
+ background: #fff;
+ opacity: 0.5;
+ `,
+};
diff --git a/client/src/shared/components/Modal/index.jsx b/client/src/shared/components/Modal/index.jsx
new file mode 100644
index 0000000..1b86952
--- /dev/null
+++ b/client/src/shared/components/Modal/index.jsx
@@ -0,0 +1,94 @@
+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';
+import { ScrollOverlay, ClickableOverlay, StyledModal, CloseIcon } from './Styles';
+
+const propTypes = {
+ className: PropTypes.string,
+ variant: PropTypes.oneOf(['center', 'aside']),
+ isOpen: PropTypes.bool,
+ onClose: PropTypes.func,
+ renderLink: PropTypes.func,
+ renderContent: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ className: undefined,
+ variant: 'center',
+ isOpen: undefined,
+ onClose: () => {},
+ renderLink: () => {},
+};
+
+const Modal = ({
+ className,
+ variant,
+ isOpen: propsIsOpen,
+ onClose: tellParentToClose,
+ renderLink,
+ renderContent,
+}) => {
+ const [stateIsOpen, setStateOpen] = useState(false);
+ const isControlled = typeof propsIsOpen === 'boolean';
+ const isOpen = isControlled ? propsIsOpen : stateIsOpen;
+
+ const $modalRef = useRef();
+ const modalIdRef = useRef(uniqueIncreasingIntegerId());
+
+ const closeModal = useCallback(() => {
+ if (shouldNotCloseBecauseHasOpenChildModal(modalIdRef.current)) {
+ return;
+ }
+ if (!isControlled) {
+ setStateOpen(false);
+ } else {
+ tellParentToClose();
+ }
+ }, [isControlled, tellParentToClose]);
+
+ useOnOutsideClick($modalRef, isOpen, closeModal);
+ useOnEscapeKeyDown(isOpen, closeModal);
+ useEffect(setBodyScrollLock, [isOpen]);
+
+ const renderModal = () => (
+
+
+
+
+ {renderContent({ close: closeModal })}
+
+
+
+ );
+
+ return (
+ <>
+ {!isControlled && renderLink({ open: () => setStateOpen(true) })}
+ {isOpen && ReactDOM.createPortal(renderModal(), $root)}
+ >
+ );
+};
+
+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 shouldNotCloseBecauseHasOpenChildModal = modalId =>
+ getIdsOfAllOpenModals().some(id => id > modalId);
+
+const setBodyScrollLock = () => {
+ const areAnyModalsOpen = getIdsOfAllOpenModals().length > 0;
+ document.body.style.overflow = areAnyModalsOpen ? 'hidden' : 'visible';
+};
+
+Modal.propTypes = propTypes;
+Modal.defaultProps = defaultProps;
+
+export default Modal;
diff --git a/client/src/shared/components/PageLoader/Styles.js b/client/src/shared/components/PageLoader/Styles.js
new file mode 100644
index 0000000..2454329
--- /dev/null
+++ b/client/src/shared/components/PageLoader/Styles.js
@@ -0,0 +1,7 @@
+import styled from 'styled-components';
+
+export default styled.div`
+ width: 100%;
+ padding: 100px;
+ text-align: center;
+`;
diff --git a/client/src/shared/components/PageLoader/index.jsx b/client/src/shared/components/PageLoader/index.jsx
new file mode 100644
index 0000000..45ef454
--- /dev/null
+++ b/client/src/shared/components/PageLoader/index.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+
+import Spinner from 'shared/components/Spinner';
+import StyledPageLoader from './Styles';
+
+const PageLoader = () => (
+
+
+
+);
+
+export default PageLoader;
diff --git a/client/src/shared/components/Select/Dropdown.jsx b/client/src/shared/components/Select/Dropdown.jsx
new file mode 100644
index 0000000..ae5cb8e
--- /dev/null
+++ b/client/src/shared/components/Select/Dropdown.jsx
@@ -0,0 +1,204 @@
+import React, { useState, useRef } from 'react';
+import PropTypes from 'prop-types';
+import { uniq } from 'lodash';
+
+import { KeyCodes } from 'shared/constants/keyCodes';
+import { ClearIcon, Dropdown, DropdownInput, Options, Option, OptionsNoResults } from './Styles';
+
+const propTypes = {
+ value: PropTypes.any,
+ isValueEmpty: PropTypes.bool.isRequired,
+ searchValue: PropTypes.string.isRequired,
+ setSearchValue: PropTypes.func.isRequired,
+ $inputRef: PropTypes.object.isRequired,
+ deactivateDropdown: PropTypes.func.isRequired,
+ options: PropTypes.array.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onCreate: PropTypes.func,
+ isMulti: PropTypes.bool,
+};
+
+const defaultProps = {
+ value: undefined,
+ onCreate: undefined,
+ isMulti: false,
+};
+
+const SelectDropdown = ({
+ value,
+ isValueEmpty,
+ searchValue,
+ setSearchValue,
+ $inputRef,
+ deactivateDropdown,
+ options,
+ onChange,
+ onCreate,
+ isMulti,
+}) => {
+ const [isCreatingOption, setCreatingOption] = useState(false);
+
+ const $optionsRef = useRef();
+
+ const selectOptionValue = optionValue => {
+ deactivateDropdown();
+ if (isMulti) {
+ onChange(uniq([...value, optionValue]));
+ } else {
+ onChange(optionValue);
+ }
+ };
+
+ const createOption = newOptionLabel => {
+ setCreatingOption(true);
+ onCreate(newOptionLabel, createdOptionValue => {
+ setCreatingOption(false);
+ selectOptionValue(createdOptionValue);
+ });
+ };
+
+ const clearOptionValues = () => {
+ $inputRef.current.value = '';
+ $inputRef.current.focus();
+ onChange(isMulti ? [] : null);
+ };
+
+ const handleInputKeyDown = event => {
+ if (event.keyCode === KeyCodes.escape) {
+ handleInputEscapeKeyDown(event);
+ } else if (event.keyCode === KeyCodes.enter) {
+ handleInputEnterKeyDown(event);
+ } else if (event.keyCode === KeyCodes.arrowDown || event.keyCode === KeyCodes.arrowUp) {
+ handleInputArrowUpOrDownKeyDown(event);
+ }
+ };
+
+ const handleInputEscapeKeyDown = event => {
+ event.nativeEvent.stopImmediatePropagation();
+ deactivateDropdown();
+ };
+
+ const handleInputEnterKeyDown = event => {
+ event.preventDefault();
+ const $active = getActiveOptionNode();
+ if (!$active) return;
+
+ const optionValueToSelect = $active.getAttribute('data-select-option-value');
+ const optionLabelToCreate = $active.getAttribute('data-create-option-label');
+
+ if (optionValueToSelect) {
+ selectOptionValue(optionValueToSelect);
+ } else if (optionLabelToCreate) {
+ createOption(optionLabelToCreate);
+ }
+ };
+
+ const handleInputArrowUpOrDownKeyDown = event => {
+ const $active = getActiveOptionNode();
+ if (!$active) return;
+
+ const $options = $optionsRef.current;
+ const $optionsHeight = $options.getBoundingClientRect().height;
+ const $activeHeight = $active.getBoundingClientRect().height;
+
+ if (event.keyCode === KeyCodes.arrowDown) {
+ if ($options.lastElementChild === $active) {
+ $active.classList.remove(activeOptionClass);
+ $options.firstElementChild.classList.add(activeOptionClass);
+ $options.scrollTop = 0;
+ } else {
+ $active.classList.remove(activeOptionClass);
+ $active.nextElementSibling.classList.add(activeOptionClass);
+ if ($active.offsetTop > $options.scrollTop + $optionsHeight / 1.4) {
+ $options.scrollTop += $activeHeight;
+ }
+ }
+ } else if (event.keyCode === KeyCodes.arrowUp) {
+ if ($options.firstElementChild === $active) {
+ $active.classList.remove(activeOptionClass);
+ $options.lastElementChild.classList.add(activeOptionClass);
+ $options.scrollTop = $options.scrollHeight;
+ } else {
+ $active.classList.remove(activeOptionClass);
+ $active.previousElementSibling.classList.add(activeOptionClass);
+ if ($active.offsetTop < $options.scrollTop + $optionsHeight / 2.4) {
+ $options.scrollTop -= $activeHeight;
+ }
+ }
+ }
+ };
+
+ const handleOptionMouseEnter = event => {
+ const $active = getActiveOptionNode();
+ if ($active) $active.classList.remove(activeOptionClass);
+ event.target.classList.add(activeOptionClass);
+ };
+
+ const getActiveOptionNode = () => $optionsRef.current.querySelector(`.${activeOptionClass}`);
+
+ const optionsFilteredBySearchValue = options.filter(option =>
+ option.label
+ .toString()
+ .toLowerCase()
+ .includes(searchValue.toLowerCase()),
+ );
+
+ const removeSelectedOptions = opts => opts.filter(option => !value.includes(option.value));
+
+ const filteredOptions = isMulti
+ ? removeSelectedOptions(optionsFilteredBySearchValue)
+ : optionsFilteredBySearchValue;
+
+ const searchValueNotInOptions = !options.map(option => option.label).includes(searchValue);
+ const isOptionCreatable = onCreate && searchValue && searchValueNotInOptions;
+
+ const renderSelectableOption = (option, i) => (
+
+ );
+
+ const renderCreatableOption = () => (
+
+ );
+
+ return (
+
+ setSearchValue(event.target.value)}
+ />
+ {!isValueEmpty && }
+
+ {filteredOptions.map(renderSelectableOption)}
+ {isOptionCreatable && renderCreatableOption()}
+ {filteredOptions.length === 0 && No results}
+
+
+ );
+};
+
+const activeOptionClass = 'jira-select-option-is-active';
+
+SelectDropdown.propTypes = propTypes;
+SelectDropdown.defaultProps = defaultProps;
+
+export default SelectDropdown;
diff --git a/client/src/shared/components/Select/Styles.js b/client/src/shared/components/Select/Styles.js
new file mode 100644
index 0000000..076498a
--- /dev/null
+++ b/client/src/shared/components/Select/Styles.js
@@ -0,0 +1,134 @@
+import styled, { css } from 'styled-components';
+
+import { color, font, mixin, zIndexValues } from 'shared/utils/styles';
+import Icon from 'shared/components/Icon';
+
+export const StyledSelect = styled.div`
+ position: relative;
+ width: 100%;
+ border-radius: 4px;
+ border: 1px solid ${color.borderLight};
+ background: #fff;
+ ${font.size(14)}
+ &:focus {
+ outline: none;
+ border: 1px solid ${color.borderMedium};
+ }
+ ${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};
+`;
+
+export const ValueContainer = styled.div`
+ min-height: 38px;
+ width: 100%;
+`;
+
+export const ChevronIcon = styled(Icon)`
+ position: absolute;
+ top: 10px;
+ right: 11px;
+ font-size: 18px;
+ color: ${color.textMedium};
+`;
+
+export const Placeholder = styled.div`
+ padding: 11px 0 0 15px;
+ color: ${color.textLightBlue};
+`;
+
+export const ValueSingle = styled.div`
+ padding: 11px 0 0 15px;
+`;
+
+export const ValueMulti = styled.div`
+ padding: 15px 5px 10px 10px;
+`;
+
+export const ValueMultiItem = styled.div`
+ margin: 0 5px 5px 0;
+ ${mixin.tag}
+`;
+
+export const AddMore = styled.div`
+ display: inline-block;
+ height: 24px;
+ line-height: 22px;
+ padding-right: 5px;
+ ${font.size(12)}
+ ${mixin.link()}
+ i {
+ margin-right: 3px;
+ vertical-align: middle;
+ font-size: 14px;
+ }
+`;
+
+export const Dropdown = styled.div`
+ z-index: ${zIndexValues.dropdown};
+ position: absolute;
+ top: 100%;
+ left: 0;
+ width: 100%;
+ background: #fff;
+ ${mixin.boxShadowBorderMedium}
+`;
+
+export const DropdownInput = styled.input`
+ padding: 10px 15px 8px;
+ width: 100%;
+ border: none;
+ color: ${color.textDarkest};
+ background: none;
+ &:focus {
+ outline: none;
+ }
+`;
+
+export const ClearIcon = styled(Icon)`
+ position: absolute;
+ top: 4px;
+ right: 7px;
+ padding: 5px;
+ font-size: 16px;
+ color: ${color.textMedium};
+ ${mixin.clickable}
+`;
+
+export const Options = styled.div`
+ max-height: 200px;
+ ${mixin.scrollableY};
+ ${mixin.customScrollbar()};
+`;
+
+export const Option = styled.div`
+ padding: 5px 15px;
+ word-break: break-word;
+ &:hover {
+ cursor: pointer;
+ }
+ &:last-of-type {
+ margin-bottom: 8px;
+ }
+ &.jira-select-option-is-active {
+ background: ${mixin.lighten(color.backgroundMedium, 0.05)};
+ }
+ ${props => (props.isSelected ? selectedOptionStyles : '')}
+`;
+
+const selectedOptionStyles = css`
+ color: #fff !important;
+ background: ${color.primary} !important;
+`;
+
+export const OptionsNoResults = styled.div`
+ padding: 5px 15px 15px;
+ color: ${color.textLight};
+`;
diff --git a/client/src/shared/components/Select/index.jsx b/client/src/shared/components/Select/index.jsx
new file mode 100644
index 0000000..8c5b768
--- /dev/null
+++ b/client/src/shared/components/Select/index.jsx
@@ -0,0 +1,164 @@
+import React, { useState, useRef } from 'react';
+import PropTypes from 'prop-types';
+
+import useOnOutsideClick from 'shared/hooks/onOutsideClick';
+import { KeyCodes } from 'shared/constants/keyCodes';
+import Icon from 'shared/components/Icon';
+import Dropdown from './Dropdown';
+import {
+ StyledSelect,
+ StyledIcon,
+ ValueContainer,
+ ChevronIcon,
+ Placeholder,
+ ValueSingle,
+ ValueMulti,
+ ValueMultiItem,
+ AddMore,
+} from './Styles';
+
+const propTypes = {
+ className: PropTypes.string,
+ icon: PropTypes.string,
+ value: PropTypes.any,
+ defaultValue: PropTypes.any,
+ placeholder: PropTypes.string,
+ invalid: PropTypes.bool,
+ options: PropTypes.array.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onCreate: PropTypes.func,
+ isMulti: PropTypes.bool,
+};
+
+const defaultProps = {
+ className: undefined,
+ icon: undefined,
+ value: undefined,
+ defaultValue: undefined,
+ placeholder: '',
+ invalid: false,
+ onCreate: undefined,
+ isMulti: false,
+};
+
+const Select = ({
+ className,
+ icon,
+ value: propsValue,
+ defaultValue,
+ placeholder,
+ invalid,
+ options,
+ onChange,
+ onCreate,
+ isMulti,
+}) => {
+ const [stateValue, setStateValue] = useState(defaultValue || (isMulti ? [] : null));
+ const [isDropdownOpen, setDropdownOpen] = useState(false);
+ const [searchValue, setSearchValue] = useState('');
+
+ const isControlled = propsValue !== undefined;
+ const value = isControlled ? propsValue : stateValue;
+
+ const $selectRef = useRef();
+ const $inputRef = useRef();
+
+ const activateDropdown = () => {
+ if (isDropdownOpen) {
+ $inputRef.current.focus();
+ } else {
+ setDropdownOpen(true);
+ }
+ };
+
+ const deactivateDropdown = () => {
+ setDropdownOpen(false);
+ setSearchValue('');
+ $selectRef.current.focus();
+ };
+
+ useOnOutsideClick($selectRef, isDropdownOpen, deactivateDropdown);
+
+ const handleChange = newValue => {
+ if (!isControlled) {
+ setStateValue(newValue);
+ }
+ onChange(newValue);
+ };
+
+ const removeOptionValue = optionValue => {
+ handleChange(value.filter(val => val !== optionValue));
+ };
+
+ const handleFocusedSelectKeydown = event => {
+ if (isDropdownOpen) return;
+
+ if (event.keyCode === KeyCodes.enter) {
+ event.preventDefault();
+ }
+ if (event.keyCode !== KeyCodes.escape && event.keyCode !== KeyCodes.tab && !event.shiftKey) {
+ setDropdownOpen(true);
+ }
+ };
+
+ const getOption = optionValue => options.find(option => option.value === optionValue);
+ const getOptionLabel = optionValue => (getOption(optionValue) || { label: '' }).label;
+
+ const isValueEmpty = isMulti ? !value.length : !getOption(value);
+
+ const renderSingleValue = () => {getOptionLabel(value)};
+
+ const renderMultiValue = () => (
+
+ {value.map(optionValue => (
+ removeOptionValue(optionValue)}>
+ {getOptionLabel(optionValue)}
+
+
+ ))}
+
+
+ Add more
+
+
+ );
+
+ return (
+
+
+ {icon && }
+ {(!isMulti || isValueEmpty) && }
+ {isValueEmpty && {placeholder}}
+ {!isValueEmpty && !isMulti && renderSingleValue()}
+ {!isValueEmpty && isMulti && renderMultiValue()}
+
+ {isDropdownOpen && (
+
+ )}
+
+ );
+};
+
+Select.propTypes = propTypes;
+Select.defaultProps = defaultProps;
+
+export default Select;
diff --git a/client/src/shared/components/Spinner.jsx b/client/src/shared/components/Spinner.jsx
new file mode 100644
index 0000000..e590f13
--- /dev/null
+++ b/client/src/shared/components/Spinner.jsx
@@ -0,0 +1,219 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { color as colors } from 'shared/utils/styles';
+
+const propTypes = {
+ className: PropTypes.string,
+ size: PropTypes.number,
+ color: PropTypes.string,
+};
+
+const defaultProps = {
+ className: undefined,
+ size: 32,
+ color: colors.textMedium,
+};
+
+const Spinner = ({ className, size, color }) => (
+
+
+
+);
+
+Spinner.propTypes = propTypes;
+Spinner.defaultProps = defaultProps;
+
+export default Spinner;
diff --git a/client/src/shared/components/Textarea/Styles.js b/client/src/shared/components/Textarea/Styles.js
new file mode 100644
index 0000000..f0833e5
--- /dev/null
+++ b/client/src/shared/components/Textarea/Styles.js
@@ -0,0 +1,23 @@
+import styled from 'styled-components';
+
+import { color, font } from 'shared/utils/styles';
+
+export default styled.div`
+ display: inline-block;
+ width: 100%;
+ textarea {
+ width: 100%;
+ padding: 13px 15px 14px;
+ border-radius: 4px;
+ border: 1px solid ${color.borderLight};
+ box-shadow: inset 0 0 1px 0 rgba(0, 0, 0, 0.03);
+ background: #fff;
+ overflow-y: hidden;
+ ${font.regular}
+ ${font.size(14)}
+ &:focus {
+ border: 1px solid ${color.borderMedium};
+ }
+ ${props => (props.invalid ? `&, &:focus { border: 1px solid ${color.danger}; }` : '')}
+ }
+`;
diff --git a/client/src/shared/components/Textarea/index.jsx b/client/src/shared/components/Textarea/index.jsx
new file mode 100644
index 0000000..9b7ca39
--- /dev/null
+++ b/client/src/shared/components/Textarea/index.jsx
@@ -0,0 +1,36 @@
+import React, { forwardRef } from 'react';
+import PropTypes from 'prop-types';
+import TextareaAutoSize from 'react-textarea-autosize';
+
+import StyledTextarea from './Styles';
+
+const propTypes = {
+ className: PropTypes.string,
+ invalid: PropTypes.bool,
+ minRows: PropTypes.number,
+ value: PropTypes.string,
+ onChange: PropTypes.func,
+};
+
+const defaultProps = {
+ className: undefined,
+ invalid: false,
+ minRows: 2,
+ value: undefined,
+ onChange: () => {},
+};
+
+const Textarea = forwardRef(({ className, invalid, onChange, ...textareaProps }, ref) => (
+
+ onChange(event, event.target.value)}
+ ref={ref}
+ />
+
+));
+
+Textarea.propTypes = propTypes;
+Textarea.defaultProps = defaultProps;
+
+export default Textarea;
diff --git a/client/src/shared/components/index.js b/client/src/shared/components/index.js
new file mode 100644
index 0000000..2ee0d0f
--- /dev/null
+++ b/client/src/shared/components/index.js
@@ -0,0 +1,12 @@
+export { default as Avatar } from './Avatar';
+export { default as Button } from './Button';
+export { default as ConfirmModal } from './ConfirmModal';
+export { default as DatePicker } from './DatePicker';
+export { default as Icon } from './Icon';
+export { default as Input } from './Input';
+export { default as Logo } from './Logo';
+export { default as Modal } from './Modal';
+export { default as PageLoader } from './PageLoader';
+export { default as Select } from './Select';
+export { default as Spinner } from './Spinner';
+export { default as Textarea } from './Textarea';
diff --git a/client/src/shared/constants/keyCodes.js b/client/src/shared/constants/keyCodes.js
new file mode 100644
index 0000000..7eae7ba
--- /dev/null
+++ b/client/src/shared/constants/keyCodes.js
@@ -0,0 +1,7 @@
+export const KeyCodes = {
+ escape: 27,
+ tab: 9,
+ enter: 13,
+ arrowUp: 38,
+ arrowDown: 40,
+};
diff --git a/client/src/shared/hooks/onEscapeKeyDown.js b/client/src/shared/hooks/onEscapeKeyDown.js
new file mode 100644
index 0000000..1c125bc
--- /dev/null
+++ b/client/src/shared/hooks/onEscapeKeyDown.js
@@ -0,0 +1,21 @@
+import { useEffect } from 'react';
+
+import { KeyCodes } from 'shared/constants/keyCodes';
+
+const useOnEscapeKeyDown = (isListening, onEscapeKeyDown) => {
+ useEffect(() => {
+ const handleKeyDown = event => {
+ if (event.keyCode === KeyCodes.escape) {
+ onEscapeKeyDown();
+ }
+ };
+ if (isListening) {
+ document.addEventListener('keydown', handleKeyDown);
+ }
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [isListening, onEscapeKeyDown]);
+};
+
+export default useOnEscapeKeyDown;
diff --git a/client/src/shared/hooks/onOutsideClick.js b/client/src/shared/hooks/onOutsideClick.js
new file mode 100644
index 0000000..8f7540d
--- /dev/null
+++ b/client/src/shared/hooks/onOutsideClick.js
@@ -0,0 +1,30 @@
+import { useEffect, useRef } from 'react';
+
+const useOnOutsideClick = ($elementRef, isListening, onOutsideClick) => {
+ const $mouseDownTargetRef = useRef();
+
+ 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)
+ ) {
+ onOutsideClick();
+ }
+ };
+ if (isListening) {
+ document.addEventListener('mousedown', handleMouseDown);
+ document.addEventListener('mouseup', handleMouseUp);
+ }
+ return () => {
+ document.removeEventListener('mousedown', handleMouseDown);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [$elementRef, isListening, onOutsideClick]);
+};
+
+export default useOnOutsideClick;
diff --git a/client/src/shared/utils/dateTime.js b/client/src/shared/utils/dateTime.js
new file mode 100644
index 0000000..7f0b5e6
--- /dev/null
+++ b/client/src/shared/utils/dateTime.js
@@ -0,0 +1,12 @@
+import moment from 'moment';
+
+export const formatDate = (date, format = 'll') => (date ? moment(date).format(format) : date);
+
+export const formatDateTime = (date, format = 'lll') => (date ? moment(date).format(format) : date);
+
+export const formatDateTimeForAPI = date =>
+ date
+ ? moment(date)
+ .utc()
+ .format()
+ : date;
diff --git a/client/src/shared/utils/styles.js b/client/src/shared/utils/styles.js
new file mode 100644
index 0000000..0878322
--- /dev/null
+++ b/client/src/shared/utils/styles.js
@@ -0,0 +1,168 @@
+import Color from 'color';
+
+export const color = {
+ primary: '#2553B3', // blue
+ success: '#29A638', // green
+ danger: '#E13C3C', // red
+ warning: '#F89C1C', // orange
+ accent: '#8A46D7', // purple
+
+ textDarkest: '#323232',
+ textDark: '#616161',
+ textMedium: '#75787D',
+ textMediumBlue: '#78869F',
+ textLight: '#959595',
+ textLightBlue: '#96A1B5',
+
+ backgroundDark: '#8393AD',
+ backgroundMedium: '#D8DDE6',
+ backgroundLight: '#F7F9FB',
+
+ borderLightest: '#E1E6F0',
+ borderLight: '#D8DDE6',
+ borderMedium: '#B9BDC4',
+ borderBlue: '#C5D3EB',
+};
+
+export const sizes = {
+ appNavBarLeftWidth: 75,
+ minViewportWidth: 1000,
+ secondarySideBarWidth: 230,
+};
+
+export const zIndexValues = {
+ modal: 1000,
+ dropdown: 101,
+ navLeft: 100,
+};
+
+export const font = {
+ regular: 'font-family: "CircularStdBook"; font-weight: normal;',
+ medium: 'font-family: "CircularStdMedium"; font-weight: normal;',
+ bold: 'font-family: "CircularStdBold"; font-weight: normal;',
+ black: 'font-family: "CircularStdBlack"; font-weight: normal;',
+ size: size => `font-size: ${size}px;`,
+};
+
+export const mixin = {
+ darken: (colorValue, amount) =>
+ Color(colorValue)
+ .darken(amount)
+ .string(),
+ lighten: (colorValue, amount) =>
+ Color(colorValue)
+ .lighten(amount)
+ .string(),
+ rgba: (colorValue, opacity) =>
+ Color(colorValue)
+ .alpha(opacity)
+ .string(),
+ 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};
+ `,
+ truncateText: `
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ `,
+ clickable: `
+ cursor: pointer;
+ user-select: none;
+ `,
+ hardwareAccelerate: `
+ transform: translateZ(0);
+ `,
+ clearfix: `
+ *zoom: 1;
+ &:before,
+ &:after {
+ content: " ";
+ display: table;
+ }
+ &:after {
+ clear: both;
+ }
+ `,
+ cover: `
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ `,
+ placeholderColor: colorValue => `
+ ::-webkit-input-placeholder {
+ color: ${colorValue} !important;
+ opacity: 1 !important;
+ }
+ :-moz-placeholder {
+ color: ${colorValue} !important;
+ opacity: 1 !important;
+ }
+ ::-moz-placeholder {
+ color: ${colorValue} !important;
+ opacity: 1 !important;
+ }
+ :-ms-input-placeholder {
+ color: ${colorValue} !important;
+ opacity: 1 !important;
+ }
+ `,
+ scrollableY: `
+ overflow-x: hidden;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ `,
+ customScrollbar: ({ width = 8, background = mixin.darken(color.backgroundMedium, 0.2) } = {}) => `
+ &::-webkit-scrollbar {
+ width: ${width}px;
+ }
+ &::-webkit-scrollbar-track {
+ background: none;
+ }
+ &::-webkit-scrollbar-thumb {
+ border-radius: 99px;
+ background: ${background};
+ }
+ `,
+ backgroundImage: `
+ background-position: 50% 50%;
+ background-repeat: no-repeat;
+ background-size: cover;
+ background-color: ${color.backgroundLight};
+ `,
+ link: (colorValue = color.primary) => `
+ cursor: pointer;
+ color: ${colorValue};
+ ${font.medium}
+ &:hover, &:visited, &:active {
+ color: ${colorValue};
+ }
+ &:hover {
+ text-decoration: underline;
+ }
+ `,
+ tag: `
+ display: inline-block;
+ height: 24px;
+ line-height: 22px;
+ padding: 0 6px 0 8px;
+ border: 1px solid ${color.borderLight};
+ border-radius: 4px;
+ cursor: pointer;
+ user-select: none;
+ background: ${color.backgroundLight};
+ ${font.medium}
+ ${font.size(12)}
+ i {
+ margin-left: 4px;
+ vertical-align: middle;
+ font-size: 14px;
+ }
+ `,
+};
diff --git a/client/src/shared/utils/toast.js b/client/src/shared/utils/toast.js
new file mode 100644
index 0000000..c52cbae
--- /dev/null
+++ b/client/src/shared/utils/toast.js
@@ -0,0 +1,21 @@
+import pubsub from 'sweet-pubsub';
+import { get } from 'lodash';
+
+const show = toast => pubsub.emit('toast', toast);
+
+const success = title => show({ title });
+
+const error = err => {
+ show({
+ type: 'danger',
+ title: 'Error',
+ message: get(err, 'message', err),
+ duration: 0,
+ });
+};
+
+export default {
+ show,
+ error,
+ success,
+};
diff --git a/client/src/shared/utils/url.js b/client/src/shared/utils/url.js
new file mode 100644
index 0000000..93c6125
--- /dev/null
+++ b/client/src/shared/utils/url.js
@@ -0,0 +1,13 @@
+import queryString from 'query-string';
+
+export const queryStringToObject = (str, options = {}) =>
+ queryString.parse(str, {
+ arrayFormat: 'bracket',
+ ...options,
+ });
+
+export const objectToQueryString = (obj, options = {}) =>
+ queryString.stringify(obj, {
+ arrayFormat: 'bracket',
+ ...options,
+ });
diff --git a/client/src/shared/utils/validation.js b/client/src/shared/utils/validation.js
new file mode 100644
index 0000000..2b307da
--- /dev/null
+++ b/client/src/shared/utils/validation.js
@@ -0,0 +1,36 @@
+export const is = {
+ match: (testFn, message = '') => (value, fieldValues) => !testFn(value, fieldValues) && message,
+
+ required: () => value => isNilOrEmptyString(value) && 'This field is required',
+
+ minLength: min => value => !!value && value.length < min && `Must be at least ${min} characters`,
+
+ maxLength: max => value => !!value && value.length > max && `Must be at most ${max} characters`,
+
+ notEmptyArray: () => value =>
+ Array.isArray(value) && value.length === 0 && 'Please add at least one item',
+
+ email: () => value => !!value && !/.+@.+\..+/.test(value) && 'Must be a valid email',
+
+ url: () => value =>
+ !!value &&
+ // eslint-disable-next-line no-useless-escape
+ !/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/.test(value) &&
+ 'Must be a valid URL',
+};
+
+const isNilOrEmptyString = value => value === undefined || value === null || value === '';
+
+export const generateErrors = (fieldValues, fieldValidators) => {
+ const errors = {};
+
+ Object.entries(fieldValidators).forEach(([fieldName, validators]) => {
+ [validators].flat().forEach(validator => {
+ const errorMessage = validator(fieldValues[fieldName], fieldValues);
+ if (errorMessage && !errors[fieldName]) {
+ errors[fieldName] = errorMessage;
+ }
+ });
+ });
+ return errors;
+};
diff --git a/client/webpack.config.js b/client/webpack.config.js
index 9acacf8..afa3fda 100644
--- a/client/webpack.config.js
+++ b/client/webpack.config.js
@@ -29,6 +29,7 @@ module.exports = {
],
},
resolve: {
+ // allows us to do absolute imports from "src"
modules: [path.join(__dirname, 'src'), 'node_modules'],
extensions: ['*', '.js', '.jsx'],
},