├── README.md ├── client ├── .env.development ├── .eslintrc.js ├── .gitignore ├── .postcssrc.js ├── babel.config.js ├── cypress.json ├── netlify.toml ├── package.json ├── public │ ├── favicon.ico │ ├── img │ │ └── icons │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-256x256.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── mstile-150x150.png │ │ │ └── safari-pinned-tab.svg │ ├── index.html │ └── manifest.json ├── src │ ├── App.vue │ ├── assets │ │ ├── css │ │ │ ├── style.scss │ │ │ └── variables.scss │ │ └── img │ │ │ ├── pending-icon.svg │ │ │ └── team.png │ ├── components │ │ ├── AddUsersToGroupForm.vue │ │ ├── DateRangePicker.vue │ │ ├── DescriptionField.vue │ │ ├── FolderForm.vue │ │ ├── FolderTree.vue │ │ ├── GroupForm.vue │ │ ├── GroupUpdateForm.vue │ │ ├── InviteUserForm.vue │ │ ├── Navigation.vue │ │ ├── NavigationRight.vue │ │ ├── Record.vue │ │ ├── UserDetail.vue │ │ ├── icons │ │ │ ├── Avatar.vue │ │ │ ├── CloseButton.vue │ │ │ ├── PlusButton.vue │ │ │ └── RemoveButton.vue │ │ └── task │ │ │ ├── TaskForm.vue │ │ │ ├── TaskHeader.vue │ │ │ ├── TaskSettingBar.vue │ │ │ ├── TaskStateBar.vue │ │ │ └── TaskTree.vue │ ├── constants │ │ └── query.gql │ ├── helpers │ │ └── helpers.js │ ├── main.js │ ├── registerServiceWorker.js │ ├── router.js │ ├── store.js │ └── views │ │ ├── About.vue │ │ ├── Account.vue │ │ ├── Decline.vue │ │ ├── Folder.vue │ │ ├── FolderDetail.vue │ │ ├── Home.vue │ │ ├── Login.vue │ │ ├── Signup.vue │ │ ├── Task.vue │ │ └── Workspace.vue ├── tests │ ├── e2e │ │ ├── .eslintrc │ │ ├── plugins │ │ │ └── index.js │ │ ├── specs │ │ │ └── test.js │ │ └── support │ │ │ ├── commands.js │ │ │ └── index.js │ └── unit │ │ ├── .eslintrc.js │ │ └── HelloWorld.spec.js ├── vue.config.js └── yarn.lock └── server ├── .gitignore ├── package.json ├── src ├── app.js ├── emails.js ├── models.js ├── resolvers.js ├── schema.graphql └── utils.js └── yarn.lock /README.md: -------------------------------------------------------------------------------- 1 | # enamel 2 | 3 | This is a repository for Wrike-clone app. -------------------------------------------------------------------------------- /client/.env.development: -------------------------------------------------------------------------------- 1 | VUE_APP_URI=http://localhost:5500 -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | 'extends': [ 7 | 'plugin:vue/essential', 8 | 'eslint:recommended' 9 | ], 10 | rules: { 11 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 12 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 13 | }, 14 | parserOptions: { 15 | parser: 'babel-eslint' 16 | } 17 | } -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /tests/e2e/videos/ 6 | /tests/e2e/screenshots/ 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw* 25 | -------------------------------------------------------------------------------- /client/.postcssrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } -------------------------------------------------------------------------------- /client/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginsFile": "tests/e2e/plugins/index.js" 3 | } 4 | -------------------------------------------------------------------------------- /client/netlify.toml: -------------------------------------------------------------------------------- 1 | # The following redirect is intended for use with most SPA's that handles routing internally. 2 | [[redirects]] 3 | from = "/*" 4 | to = "/index.html" 5 | status = 200 -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "enamel", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "test:unit": "vue-cli-service test:unit", 10 | "test:e2e": "vue-cli-service test:e2e" 11 | }, 12 | "dependencies": { 13 | "apollo-cache-inmemory": "1.2.5", 14 | "apollo-cache-persist": "0.1.1", 15 | "apollo-client": "2.3.5", 16 | "apollo-link": "1.2.2", 17 | "apollo-link-error": "1.1.0", 18 | "apollo-link-http": "1.5.4", 19 | "apollo-link-state": "0.4.1", 20 | "element-ui": "2.4.3", 21 | "graphql": "0.13.2", 22 | "graphql-tag": "2.9.2", 23 | "moment": "2.22.2", 24 | "register-service-worker": "^1.0.0", 25 | "shortid": "2.2.8", 26 | "vee-validate": "^2.1.0-beta.2", 27 | "vue": "^2.5.16", 28 | "vue-analytics": "^5.12.3", 29 | "vue-apollo": "3.0.0-beta.19", 30 | "vue-router": "^3.0.1", 31 | "vuex": "^3.0.1" 32 | }, 33 | "devDependencies": { 34 | "@vue/cli-plugin-babel": "^3.0.0-beta.15", 35 | "@vue/cli-plugin-e2e-cypress": "^3.0.0-beta.15", 36 | "@vue/cli-plugin-eslint": "^3.0.0-beta.15", 37 | "@vue/cli-plugin-pwa": "^3.0.0-beta.15", 38 | "@vue/cli-plugin-unit-mocha": "^3.0.0-beta.15", 39 | "@vue/cli-service": "^3.0.0-beta.15", 40 | "@vue/test-utils": "^1.0.0-beta.16", 41 | "chai": "^4.1.2", 42 | "lint-staged": "^6.0.0", 43 | "node-sass": "^4.9.0", 44 | "sass-loader": "^7.0.1", 45 | "vue-template-compiler": "^2.5.16" 46 | }, 47 | "browserslist": [ 48 | "> 1%", 49 | "last 2 versions", 50 | "not ie <= 8" 51 | ], 52 | "lint-staged": { 53 | "*.js": [ 54 | "vue-cli-service lint", 55 | "git add" 56 | ], 57 | "*.vue": [ 58 | "vue-cli-service lint", 59 | "git add" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenzotakahashi/enamel/8d365e89fb8eb9f3eaf1700d8d9f4d8c1beadf1f/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenzotakahashi/enamel/8d365e89fb8eb9f3eaf1700d8d9f4d8c1beadf1f/client/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/img/icons/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenzotakahashi/enamel/8d365e89fb8eb9f3eaf1700d8d9f4d8c1beadf1f/client/public/img/icons/android-chrome-256x256.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenzotakahashi/enamel/8d365e89fb8eb9f3eaf1700d8d9f4d8c1beadf1f/client/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /client/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenzotakahashi/enamel/8d365e89fb8eb9f3eaf1700d8d9f4d8c1beadf1f/client/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenzotakahashi/enamel/8d365e89fb8eb9f3eaf1700d8d9f4d8c1beadf1f/client/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenzotakahashi/enamel/8d365e89fb8eb9f3eaf1700d8d9f4d8c1beadf1f/client/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /client/public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 14 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | enamel 12 | 13 | 14 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "enamel", 3 | "short_name": "enamel", 4 | "icons": [ 5 | { 6 | "src": "/img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "/index.html", 17 | "display": "standalone", 18 | "background_color": "#000000", 19 | "theme_color": "#4DBA87" 20 | } 21 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /client/src/assets/css/style.scss: -------------------------------------------------------------------------------- 1 | body { 2 | color: #eff2f4; 3 | margin: 0; 4 | background-color: #4A4090; 5 | -webkit-font-smoothing: antialiased; 6 | font-family: "Open sans","lucida grande","Segoe UI",arial,verdana,tahoma,"Hiragino Kaku Gothic ProN", 7 | "Osaka","Meiryo UI","Yu Gothic UI",sans-serif; 8 | } 9 | 10 | .white { 11 | color: $black; 12 | background: #fff; 13 | } 14 | 15 | h1, h2, h3, h4, h5, h6 { 16 | margin: 0; 17 | padding: 8px 0; 18 | } 19 | 20 | ul, li { 21 | list-style-type: none; 22 | margin: 0; 23 | } 24 | 25 | textarea { 26 | outline: none; 27 | border: none; 28 | resize: none; 29 | font-size: inherit; 30 | } 31 | 32 | input { 33 | outline: none; 34 | border: none; 35 | font-size: inherit; 36 | } 37 | 38 | button { 39 | border: none; 40 | background: none; 41 | box-shadow: none; 42 | outline: none; 43 | padding: 0; 44 | margin: 0; 45 | display: inline-flex; 46 | align-items: center; 47 | max-width: 100%; 48 | border: 1px solid; 49 | border-radius: 2px; 50 | -webkit-font-smoothing: antialiased; 51 | cursor: pointer; 52 | user-select: none; 53 | justify-content: center; 54 | } 55 | 56 | a, a:visited, a:hover, a:active, a:focus { 57 | color: inherit; 58 | text-decoration: none; 59 | border: none; 60 | box-shadow: none; 61 | } 62 | 63 | .error { 64 | color: #FF576F; 65 | } 66 | 67 | a.link { 68 | padding: 0 10px; 69 | font-weight: 700; 70 | color: rgb(77, 208, 225); 71 | &:hover { 72 | text-decoration: underline; 73 | } 74 | } 75 | 76 | .container { 77 | box-sizing: border-box; 78 | display: flex; 79 | position: absolute; 80 | left: 0px; 81 | top: 52px; 82 | } 83 | 84 | .container-center { 85 | max-width: 400px; 86 | margin: auto; 87 | } 88 | 89 | .header-title { 90 | font-size: 18px; 91 | line-height: 21px; 92 | } 93 | 94 | .black-text-button, .black-text-button:focus { 95 | color: rgba(0, 0, 0, 0.9) 96 | } 97 | 98 | .black-text-button:hover { 99 | color: $blue-hover; 100 | } 101 | 102 | .el-header { 103 | padding: 0; 104 | } 105 | 106 | .el-button--text { 107 | font-weight: 300; 108 | } 109 | 110 | .el-table__row { 111 | cursor: pointer; 112 | } 113 | 114 | .el-table__row:hover .close-button.hidden { 115 | visibility: visible; 116 | } 117 | 118 | .fa-angle-down, .fa-angle-right { 119 | font-size: 12px; 120 | } 121 | 122 | .card { 123 | border-radius: 2px; 124 | margin: 0 5px; 125 | } 126 | 127 | 128 | input { 129 | font-size: inherit; 130 | vertical-align: -webkit-baseline-middle; 131 | } 132 | 133 | input.no-outline { 134 | outline: none; 135 | border: none; 136 | width: 100%; 137 | } 138 | 139 | .small-text { 140 | color: rgba(0,0,0,.56); 141 | font-size: 11px; 142 | } 143 | 144 | ul.tree { 145 | padding-left: 1em; 146 | line-height: 1.5em; 147 | } 148 | 149 | .folder { 150 | font-size: 13px; 151 | overflow: hidden; 152 | text-overflow: ellipsis; 153 | letter-spacing: .325px; 154 | } 155 | 156 | .tree-root { 157 | position: relative; 158 | } 159 | 160 | .teamname { 161 | color: #f9fbfd; 162 | font-weight: 600; 163 | } 164 | 165 | .circle { 166 | display: inline-block; 167 | width: 6px; 168 | height: 6px; 169 | background-color: #f9fbfd; 170 | border-radius: 50%; 171 | margin: 12px 13px; 172 | } 173 | 174 | .tree-item { 175 | position: relative; 176 | cursor: pointer; 177 | width: 100%; 178 | line-height: 30px; 179 | display: flex; 180 | box-sizing: border-box; 181 | } 182 | 183 | .fold-button { 184 | width: 18px; 185 | margin: 0 9px; 186 | z-index: 5; 187 | text-align: center; 188 | } 189 | 190 | .fold-button:hover { 191 | background-color: $hover-inverse; 192 | } 193 | 194 | .fold-button.active:hover { 195 | background-color: rgba(255,255,255,.1); 196 | } 197 | 198 | .tree-plate { 199 | display: flex; 200 | position: static; 201 | width: 100%; 202 | height: auto; 203 | } 204 | 205 | .tree-plate:before { 206 | border-radius: 0 3px 3px 0; 207 | /*background-color: #48f;*/ 208 | content: ''; 209 | position: absolute; 210 | left: 0; 211 | right: 0px; 212 | height: 30px; 213 | z-index: -1; 214 | } 215 | 216 | .tree-plate:hover:before { 217 | background-color: $hover-inverse; 218 | } 219 | 220 | .tree-plate.active:before { 221 | background-color: #48f; 222 | } 223 | 224 | .no-select-color::-moz-selection { 225 | background: none; 226 | } 227 | .no-select-color::selection { 228 | background: none; 229 | } 230 | 231 | .comment-box { 232 | color: rgba(0,0,0,.56); 233 | padding: 12px; 234 | border-top: 1px solid; 235 | border-color: rgba(0,0,0,.16); 236 | position: relative; 237 | bottom: 0; 238 | left: 0; 239 | } 240 | 241 | /* ========= dropdown ============*/ 242 | 243 | .dropdown { 244 | // position: relative; 245 | overflow: hidden; 246 | /*display: inline-block;*/ 247 | } 248 | 249 | .dropdown-content { 250 | position: absolute; 251 | background-color: #fff; 252 | padding: 6px 0; 253 | min-width: 160px; 254 | box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.16), 0 0 1px 0 rgba(0, 0, 0, 0.16); 255 | z-index: 10000; 256 | font-size: 14px; 257 | } 258 | 259 | .tree-plate > .dropdown-content { 260 | top: 30px; 261 | } 262 | 263 | .dropdown-content.right { 264 | right: 0; 265 | } 266 | 267 | .dropdown-content.left { 268 | left: 0; 269 | } 270 | 271 | .dropdown-content div { 272 | display: flex; 273 | cursor: pointer; 274 | line-height: 19px; 275 | color: black; 276 | padding: 5px 16px; 277 | text-decoration: none; 278 | } 279 | 280 | .dropdown-content div:hover { 281 | background-color: $hover; 282 | } 283 | 284 | .dropdown:hover .dropdown-content { 285 | display: block; 286 | } 287 | 288 | /* ========= modal ============*/ 289 | 290 | .modal-mask { 291 | position: fixed; 292 | z-index: 2000; 293 | top: 0; 294 | left: 0; 295 | width: 100%; 296 | height: 100%; 297 | background-color: rgba(39, 65, 90, 0.85); 298 | display: table; 299 | transition: opacity .3s ease; 300 | } 301 | 302 | .modal-wrapper { 303 | display: table-cell; 304 | vertical-align: middle; 305 | } 306 | 307 | .modal-container { 308 | display: flex; 309 | position: relative; 310 | flex-direction: column; 311 | box-sizing: border-box; 312 | margin: 0px auto; 313 | padding: 20px 30px; 314 | background-color: #fff; 315 | border-radius: 2px; 316 | box-shadow: 0 2px 8px rgba(0, 0, 0, .33); 317 | transition: all .3s ease; 318 | } 319 | 320 | .modal-container > .close-button { 321 | display: flex; 322 | justify-content: center; 323 | align-items: center; 324 | cursor: pointer; 325 | position: absolute; 326 | width: 24px; 327 | height: 24px; 328 | color: black; 329 | top: 0; 330 | right: 0; 331 | opacity: .7; 332 | } 333 | 334 | .modal-header h3 { 335 | margin-top: 0; 336 | } 337 | 338 | .modal-body { 339 | margin: 20px 0; 340 | } 341 | 342 | .modal-default-button { 343 | float: right; 344 | } 345 | 346 | /* ========= tooltip ============*/ 347 | 348 | .tooltip { 349 | position: relative; 350 | display: inline-block; 351 | } 352 | 353 | .tooltip .tooltip-content { 354 | box-sizing: border-box; 355 | text-align: center; 356 | background-color: #fff; 357 | box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.16), 0 0 1px 0 rgba(0, 0, 0, 0.16); 358 | 359 | /* Position the tooltip */ 360 | position: absolute; 361 | z-index: 10000; 362 | } 363 | 364 | .tooltip .tooltip-content.top { 365 | bottom: 50px; 366 | } 367 | 368 | .tooltip .tooltip-content.bottom { 369 | top: 30px; 370 | } 371 | 372 | .tooltip .tooltip-content.top::after { 373 | content: " "; 374 | position: absolute; 375 | top: 100%; 376 | left: 50%; 377 | margin-left: -5px; 378 | border-width: 8px; 379 | border-style: solid; 380 | border-color: #fff transparent transparent transparent; 381 | } 382 | 383 | .contact-picker-item { 384 | box-sizing: border-box; 385 | cursor: pointer; 386 | :hover { 387 | background-color: $hover; 388 | } 389 | } 390 | 391 | .group-list-item { 392 | box-sizing: border-box; 393 | height: 32px; 394 | display: flex; 395 | font-size: 12px; 396 | cursor: pointer; 397 | padding: 0 10px; 398 | } 399 | 400 | .group-list-item:hover { 401 | background-color: $hover; 402 | } 403 | 404 | .group-list-item > span { 405 | display: flex; 406 | align-items: center; 407 | } 408 | 409 | 410 | .picker-item { 411 | display: block; 412 | height: 48px; 413 | padding: 6px 16px 6px 24px; 414 | box-sizing: border-box; 415 | } 416 | 417 | .picker-item .item { 418 | justify-content: flex-start; 419 | flex-wrap: nowrap; 420 | display: flex; 421 | align-items: center; 422 | height: 100%; 423 | } 424 | 425 | .picker-item .name { 426 | font-size: 14px; 427 | overflow: hidden; 428 | line-height: 1.54; 429 | text-align: left; 430 | } 431 | 432 | .picker-item .email { 433 | color: #a5a5a5; 434 | font-size: 12px; 435 | white-space: nowrap; 436 | text-overflow: ellipsis; 437 | overflow: hidden; 438 | } 439 | 440 | .picker-avatar { 441 | margin-right: 8px; 442 | } 443 | 444 | .cross-wrapper { 445 | padding: 6px 0; 446 | cursor: pointer; 447 | display: flex; 448 | flex-direction: row; 449 | align-items: center; 450 | justify-content: center; 451 | } 452 | 453 | .cross { 454 | width: 13px; 455 | height: 13px; 456 | background: linear-gradient(to bottom, transparent 48%, 457 | rgba(0, 0, 0, 0.9) 48%, 458 | rgba(0, 0, 0, 0.9) 52%, 459 | transparent 52%), 460 | linear-gradient(to right, transparent 48%, 461 | rgba(0, 0, 0, 0.9) 48%, 462 | rgba(0, 0, 0, 0.9) 52%, 463 | transparent 52%), 464 | } 465 | 466 | .user-container { 467 | display: flex; 468 | } 469 | 470 | .group-view { 471 | padding: 12px 0; 472 | width: 174px; 473 | left: 50%; 474 | margin-left: -87px; 475 | } 476 | 477 | .popover-menu { 478 | padding: 0; 479 | } 480 | 481 | .menu-item { 482 | font-size: 14px; 483 | cursor: pointer; 484 | display: block; 485 | height: 32px; 486 | line-height: 32px; 487 | padding: 0 42px 0 24px; 488 | position: relative; 489 | white-space: nowrap; 490 | } 491 | 492 | .menu-item:hover { 493 | background-color: $hover; 494 | } 495 | 496 | -------------------------------------------------------------------------------- /client/src/assets/css/variables.scss: -------------------------------------------------------------------------------- 1 | $hover: #F0F0F0; 2 | $hover-inverse: #526477; 3 | 4 | $blue: #409EFF; 5 | $blue-hover: #66b1ff; 6 | $black: #50596c; -------------------------------------------------------------------------------- /client/src/assets/img/pending-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/src/assets/img/team.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenzotakahashi/enamel/8d365e89fb8eb9f3eaf1700d8d9f4d8c1beadf1f/client/src/assets/img/team.png -------------------------------------------------------------------------------- /client/src/components/AddUsersToGroupForm.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 112 | 113 | 169 | -------------------------------------------------------------------------------- /client/src/components/DateRangePicker.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 199 | 200 | -------------------------------------------------------------------------------- /client/src/components/DescriptionField.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 68 | 69 | 99 | -------------------------------------------------------------------------------- /client/src/components/FolderForm.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 130 | 131 | 154 | -------------------------------------------------------------------------------- /client/src/components/FolderTree.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 136 | 137 | 139 | -------------------------------------------------------------------------------- /client/src/components/GroupForm.vue: -------------------------------------------------------------------------------- 1 | 89 | 90 | 188 | 189 | 264 | -------------------------------------------------------------------------------- /client/src/components/GroupUpdateForm.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 145 | 146 | 196 | -------------------------------------------------------------------------------- /client/src/components/InviteUserForm.vue: -------------------------------------------------------------------------------- 1 | 81 | 82 | 168 | 169 | 225 | -------------------------------------------------------------------------------- /client/src/components/Navigation.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 26 | 27 | 48 | -------------------------------------------------------------------------------- /client/src/components/NavigationRight.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 57 | 58 | 86 | -------------------------------------------------------------------------------- /client/src/components/Record.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 137 | 138 | -------------------------------------------------------------------------------- /client/src/components/UserDetail.vue: -------------------------------------------------------------------------------- 1 | 127 | 128 | 183 | 184 | 385 | 386 | -------------------------------------------------------------------------------- /client/src/components/icons/Avatar.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 73 | 74 | 75 | 97 | -------------------------------------------------------------------------------- /client/src/components/icons/CloseButton.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | 17 | -------------------------------------------------------------------------------- /client/src/components/icons/PlusButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 32 | 33 | -------------------------------------------------------------------------------- /client/src/components/icons/RemoveButton.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /client/src/components/task/TaskForm.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 82 | 83 | 119 | -------------------------------------------------------------------------------- /client/src/components/task/TaskHeader.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 124 | 125 | 183 | -------------------------------------------------------------------------------- /client/src/components/task/TaskSettingBar.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 68 | 69 | -------------------------------------------------------------------------------- /client/src/components/task/TaskStateBar.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 155 | 156 | -------------------------------------------------------------------------------- /client/src/components/task/TaskTree.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 93 | 94 | 158 | -------------------------------------------------------------------------------- /client/src/constants/query.gql: -------------------------------------------------------------------------------- 1 | fragment TaskFields on Task { 2 | id 3 | name 4 | parent { 5 | id 6 | name 7 | } 8 | folders { 9 | id 10 | name 11 | } 12 | assignees { 13 | id 14 | name 15 | firstname 16 | lastname 17 | avatarColor 18 | } 19 | creator { 20 | id 21 | name 22 | firstname 23 | lastname 24 | } 25 | description 26 | startDate 27 | finishDate 28 | duration 29 | importance 30 | status 31 | createdAt 32 | } 33 | 34 | fragment FolderFields on Folder { 35 | id 36 | name 37 | parent 38 | description 39 | shareWith 40 | } 41 | 42 | fragment UserFields on User { 43 | id 44 | name 45 | firstname 46 | lastname 47 | email 48 | jobTitle 49 | avatarColor 50 | role 51 | rate 52 | rateType 53 | status 54 | createdAt 55 | } 56 | 57 | fragment GroupFields on Group { 58 | id 59 | team 60 | name 61 | initials 62 | avatarColor 63 | users 64 | } 65 | 66 | fragment RecordFields on Record { 67 | id 68 | user 69 | task 70 | date 71 | timeSpent 72 | comment 73 | } 74 | 75 | mutation CaptureEmail($email: String!) { 76 | captureEmail(email: $email) { 77 | id 78 | email 79 | } 80 | } 81 | 82 | mutation Invite($emails: [String], $groups: [String], $role: String) { 83 | invite(emails: $emails, groups: $groups, role: $role) { ...UserFields } 84 | } 85 | 86 | mutation Decline($id: String!) { 87 | decline(id: $id) 88 | } 89 | 90 | mutation Signup($id: String!, $firstname: String!, $lastname: String!, $password: String!) { 91 | signup(id: $id, firstname: $firstname, lastname: $lastname, password: $password) { 92 | token 93 | user { 94 | id 95 | email 96 | } 97 | } 98 | } 99 | 100 | mutation Login($email: String!, $password: String!) { 101 | login(email: $email, password: $password) { 102 | token 103 | user { 104 | id 105 | email 106 | } 107 | } 108 | } 109 | 110 | mutation CreateTask($folder: String, $parent: String, $name: String!) { 111 | createTask(folder: $folder, parent: $parent, name: $name) { ...TaskFields } 112 | } 113 | 114 | mutation UpdateTask($id: String!, $input: TaskInput) { 115 | updateTask(id: $id, input: $input) { ...TaskFields } 116 | } 117 | 118 | mutation DeleteTask($id: String!) { 119 | deleteTask(id: $id) 120 | } 121 | 122 | mutation CreateFolder($parent: String, $name: String!, $shareWith: [ShareInput]) { 123 | createFolder(parent: $parent, name: $name, shareWith: $shareWith) { 124 | ...FolderFields 125 | } 126 | } 127 | 128 | mutation UpdateFolder($id: String!, $input: FolderInput) { 129 | updateFolder(id: $id, input: $input) { ...FolderFields } 130 | } 131 | 132 | mutation DeleteFolder($id: String!) { 133 | deleteFolder(id: $id) 134 | } 135 | 136 | mutation CreateGroup($name: String, $initials: String, $avatarColor: String, $users: [String]) { 137 | createGroup(name: $name, initials: $initials, avatarColor: $avatarColor, users: $users) { 138 | ...GroupFields 139 | } 140 | } 141 | 142 | mutation AddUsersToGroup($id: String!, $users: [String]) { 143 | addUsersToGroup(id: $id, users: $users) { 144 | ...GroupFields 145 | } 146 | } 147 | 148 | mutation RemoveUsersFromGroup($id: String!, $users: [String]) { 149 | removeUsersFromGroup(id: $id, users: $users) { 150 | ...GroupFields 151 | } 152 | } 153 | 154 | mutation UpdateGroup($id: String!, $name: String, $initials: String, $avatarColor: String) { 155 | updateGroup(id: $id, name: $name, initials: $initials, avatarColor: $avatarColor) { 156 | ...GroupFields 157 | } 158 | } 159 | 160 | mutation DeleteGroup($id: String!) { 161 | deleteGroup(id: $id) 162 | } 163 | 164 | mutation UpdateUser($id: String!, $input: UserInput) { 165 | updateUser(id: $id, input: $input) { ...UserFields } 166 | } 167 | 168 | mutation CreateRecord($input: RecordInput) { 169 | createRecord(input: $input) { ...RecordFields } 170 | } 171 | 172 | mutation UpdateRecord($id: String!, $input: RecordInput) { 173 | updateRecord(id: $id, input: $input) { ...RecordFields } 174 | } 175 | 176 | mutation DeleteRecord($id: String!) { 177 | deleteRecord(id: $id) 178 | } 179 | 180 | query GetTeam { 181 | getTeam { 182 | id 183 | name 184 | } 185 | } 186 | 187 | query GetUser($id: String) { 188 | getUser(id: $id) { ...UserFields } 189 | } 190 | 191 | query GetUsers { 192 | getUsers { ...UserFields } 193 | } 194 | 195 | query GetGroups { 196 | getGroups { ...GroupFields } 197 | } 198 | 199 | query GetFolders($parent: String) { 200 | getFolders(parent: $parent) { ...FolderFields } 201 | } 202 | 203 | query GetFolder($id: String!) { 204 | getFolder(id: $id) { ...FolderFields } 205 | } 206 | 207 | query GetTasks($parent: String, $folder: String) { 208 | getTasks(parent: $parent, folder: $folder) { ...TaskFields } 209 | } 210 | 211 | query GetTask($id: String!) { 212 | getTask(id: $id) { ...TaskFields } 213 | } 214 | 215 | query GetRecord($id: String, $task: String, $date: String) { 216 | getRecord(id: $id, task: $task, date: $date) { ...RecordFields } 217 | } 218 | 219 | -------------------------------------------------------------------------------- /client/src/helpers/helpers.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | 3 | export const formatDate = (date) => { 4 | const day = moment(date).format('MMM DD') 5 | const today = moment().format('MMM DD') 6 | return today === day ? moment(date).format('HH:mm') : day 7 | } 8 | 9 | export function randomChoice(arr) { 10 | return arr[Math.floor(arr.length * Math.random())] 11 | } 12 | 13 | export const backgroundStrongColorMap = { 14 | New: '#1976d2', 15 | 'In Progress': '#0097a7', 16 | Completed: '#689f38', 17 | 'On Hold': '#616161', 18 | Cancelled: '#616161', 19 | } 20 | 21 | export function validateEmail(email) { 22 | const re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 23 | return re.test(String(email).toLowerCase()) 24 | } -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | // import VeeValidate from 'vee-validate' 3 | import { ApolloClient } from 'apollo-client' 4 | import { InMemoryCache } from 'apollo-cache-inmemory' 5 | import { persistCache } from 'apollo-cache-persist' 6 | import { onError } from "apollo-link-error" 7 | import { ApolloLink } from 'apollo-link' 8 | import { HttpLink } from 'apollo-link-http' 9 | import { withClientState } from 'apollo-link-state' 10 | import { enableExperimentalFragmentVariables } from 'graphql-tag' 11 | import VueApollo from 'vue-apollo' 12 | import ElementUI from 'element-ui' 13 | import VueAnalytics from 'vue-analytics' 14 | 15 | import App from './App.vue' 16 | import router from './router' 17 | import store from './store' 18 | // import './registerServiceWorker' 19 | 20 | import 'element-ui/lib/theme-chalk/index.css' 21 | import './assets/css/style.scss' 22 | 23 | import Avatar from '@/components/icons/Avatar.vue' 24 | import CloseButton from '@/components/icons/CloseButton.vue' 25 | import RemoveButton from '@/components/icons/RemoveButton.vue' 26 | import PlusButton from '@/components/icons/PlusButton.vue' 27 | import Navigation from '@/components/Navigation' 28 | 29 | Vue.component('navigation', Navigation) 30 | Vue.component('avatar', Avatar) 31 | Vue.component('close-button', CloseButton) 32 | Vue.component('remove-button', RemoveButton) 33 | Vue.component('plus-button', PlusButton) 34 | 35 | Vue.use(VueAnalytics, { 36 | id: 'UA-62716182-7' 37 | }) 38 | 39 | Vue.use(ElementUI) 40 | // Vue.use(VeeValidate) 41 | Vue.config.productionTip = false 42 | 43 | const uri = `${process.env.VUE_APP_URI}/graphql` 44 | const httpLink = new HttpLink({ 45 | uri, 46 | }) 47 | 48 | const cache = new InMemoryCache({ 49 | cacheRedirects: { 50 | Query: { 51 | getFolder: (_, args, { getCacheKey }) => { 52 | return getCacheKey({ __typename: 'Folder', id: args.id }) 53 | }, 54 | getTask: (_, args, { getCacheKey }) => { 55 | return getCacheKey({ __typename: 'Task', id: args.id }) 56 | } 57 | }, 58 | }, 59 | }) 60 | 61 | // persistCache({ 62 | // cache, 63 | // storage: window.localStorage, 64 | // }) 65 | 66 | const authMiddleware = new ApolloLink((operation, forward) => { 67 | const token = localStorage.getItem('user-token') 68 | operation.setContext({ 69 | headers: { 70 | authorization: token ? `Bearer ${token}` : null 71 | } 72 | }) 73 | return forward(operation) 74 | }) 75 | 76 | const errorLink = onError(({ graphQLErrors, networkError }) => { 77 | if (graphQLErrors) 78 | graphQLErrors.map(({ message, locations, path }) => 79 | console.log( 80 | `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` 81 | ) 82 | ) 83 | if (networkError) console.log(`[Network error]: ${networkError}`) 84 | }) 85 | 86 | const client = new ApolloClient({ 87 | link: ApolloLink.from([ 88 | errorLink, 89 | authMiddleware, 90 | httpLink 91 | ]), 92 | cache, 93 | connectToDevTools: true, 94 | }) 95 | 96 | const apolloProvider = new VueApollo({ 97 | defaultClient: client, 98 | defaultOptions: { 99 | $loadingKey: 'loading' 100 | } 101 | }) 102 | 103 | Vue.use(VueApollo) 104 | 105 | new Vue({ 106 | router, 107 | provide: apolloProvider.provide(), 108 | store, 109 | render: h => h(App) 110 | }).$mount('#app') 111 | -------------------------------------------------------------------------------- /client/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from 'register-service-worker' 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready () { 8 | console.log( 9 | 'App is being served from cache by a service worker.\n' + 10 | 'For more details, visit https://goo.gl/AFskqB' 11 | ) 12 | }, 13 | cached () { 14 | console.log('Content has been cached for offline use.') 15 | }, 16 | updated () { 17 | console.log('New content is available; please refresh.') 18 | }, 19 | offline () { 20 | console.log('No internet connection found. App is running in offline mode.') 21 | }, 22 | error (error) { 23 | console.error('Error during service worker registration:', error) 24 | } 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /client/src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import Home from './views/Home.vue' 4 | import About from './views/About.vue' 5 | import Signup from './views/Signup.vue' 6 | import Login from './views/Login.vue' 7 | import Workspace from './views/Workspace.vue' 8 | import Folder from './views/Folder.vue' 9 | import FolderDetail from './views/FolderDetail.vue' 10 | import Task from './views/Task.vue' 11 | import Account from './views/Account.vue' 12 | import Decline from './views/Decline.vue' 13 | 14 | Vue.use(Router) 15 | 16 | const login = { 17 | path: '/login', 18 | name: 'login', 19 | component: Login, 20 | meta: { title: 'Login - enamel' } 21 | } 22 | 23 | const workspace = { 24 | path: '/workspace', 25 | name: 'workspace', 26 | component: Workspace, 27 | meta: { title: 'Workspace - enamel', requiresAuth: true }, 28 | children: [ 29 | { 30 | path: 'folder/:id', 31 | component: Folder, 32 | props: true, 33 | children: [ 34 | { 35 | path: '', 36 | name: 'folder', 37 | component: FolderDetail 38 | }, 39 | { 40 | path: 'task/:taskId', 41 | name: 'task', 42 | component: Task, 43 | props: true 44 | } 45 | ] 46 | } 47 | ] 48 | } 49 | 50 | const router = new Router({ 51 | mode: 'history', 52 | routes: [ 53 | { 54 | path: '/', 55 | name: 'home', 56 | component: Home, 57 | meta: { title: 'enamel', redirect: true } 58 | }, 59 | { 60 | path: '/signup/:id', 61 | name: 'signup', 62 | component: Signup, 63 | meta: { title: 'Signup - enamel' } 64 | }, 65 | login, 66 | { 67 | path: '/decline/:id', 68 | name: 'decline', 69 | component: Decline, 70 | meta: { title: 'enamel' } 71 | }, 72 | workspace, 73 | { 74 | path: '/account', 75 | name: 'account', 76 | component: Account, 77 | meta: { title: 'Accounts - enamel', requiresAuth: true } 78 | }, 79 | 80 | ] 81 | }) 82 | 83 | router.beforeEach((to, from, next) => { 84 | const auth = localStorage.getItem('user-id') 85 | if (to.matched.some(record => record.meta.requiresAuth)) { 86 | if(!auth) { 87 | next(login) 88 | } 89 | } else if (to.matched.some(record => record.meta.redirect)) { 90 | if(auth) { 91 | next(workspace) 92 | } 93 | } 94 | next() 95 | }) 96 | 97 | router.afterEach((to, from) => { 98 | document.title = to.meta.title 99 | }) 100 | 101 | export default router -------------------------------------------------------------------------------- /client/src/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | 4 | Vue.use(Vuex) 5 | 6 | export default new Vuex.Store({ 7 | state: { 8 | userId: localStorage.getItem('user-id'), 9 | activeWidget: null, 10 | }, 11 | mutations: { 12 | changeActiveWidget(state, key) { 13 | state.activeWidget = state.activeWidget === key ? null : key 14 | } 15 | }, 16 | actions: { 17 | changeActiveWidget({state, commit}, key) { 18 | commit('changeActiveWidget', key) 19 | } 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /client/src/views/About.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /client/src/views/Account.vue: -------------------------------------------------------------------------------- 1 | 115 | 116 | 250 | 251 | 358 | -------------------------------------------------------------------------------- /client/src/views/Decline.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 37 | 38 | 43 | -------------------------------------------------------------------------------- /client/src/views/Folder.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 108 | 109 | 143 | -------------------------------------------------------------------------------- /client/src/views/FolderDetail.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 228 | 229 | 306 | -------------------------------------------------------------------------------- /client/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 87 | 88 | -------------------------------------------------------------------------------- /client/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 79 | 80 | 85 | -------------------------------------------------------------------------------- /client/src/views/Signup.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 85 | 86 | 96 | 97 | -------------------------------------------------------------------------------- /client/src/views/Task.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 113 | 114 | 125 | -------------------------------------------------------------------------------- /client/src/views/Workspace.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 87 | 88 | -------------------------------------------------------------------------------- /client/tests/e2e/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "cypress" 4 | ], 5 | "env": { 6 | "mocha": true, 7 | "cypress/globals": true 8 | }, 9 | "rules": { 10 | "strict": "off" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/tests/e2e/plugins/index.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/guides/guides/plugins-guide.html 2 | 3 | module.exports = (on, config) => { 4 | return Object.assign({}, config, { 5 | fixturesFolder: 'tests/e2e/fixtures', 6 | integrationFolder: 'tests/e2e/specs', 7 | screenshotsFolder: 'tests/e2e/screenshots', 8 | videosFolder: 'tests/e2e/videos', 9 | supportFile: 'tests/e2e/support/index.js' 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /client/tests/e2e/specs/test.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/api/introduction/api.html 2 | 3 | describe('My First Test', () => { 4 | it('Visits the app root url', () => { 5 | cy.visit('/') 6 | cy.contains('h1', 'Welcome to Your Vue.js App') 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /client/tests/e2e/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /client/tests/e2e/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /client/tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | mocha: true 4 | }, 5 | rules: { 6 | 'import/no-extraneous-dependencies': 'off' 7 | } 8 | } -------------------------------------------------------------------------------- /client/tests/unit/HelloWorld.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { shallowMount } from '@vue/test-utils' 3 | import HelloWorld from '@/components/HelloWorld.vue' 4 | 5 | describe('HelloWorld.vue', () => { 6 | it('renders props.msg when passed', () => { 7 | const msg = 'new message' 8 | const wrapper = shallowMount(HelloWorld, { 9 | propsData: { msg } 10 | }) 11 | expect(wrapper.text()).to.include(msg) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /client/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | lintOnSave: false, 3 | devServer: { 4 | port: 8002, 5 | proxy: 'http://localhost:5500' 6 | }, 7 | pwa: { 8 | appleMobileWebAppCapable: 'yes', 9 | appleMobileWebAppStatusBarStyle: 'default' 10 | }, 11 | configureWebpack: { 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.(graphql|gql)$/, 16 | exclude: /node_modules/, 17 | loader: 'graphql-tag/loader' 18 | } 19 | ] 20 | } 21 | }, 22 | css: { 23 | loaderOptions: { 24 | sass: { 25 | data: `@import "@/assets/css/variables.scss";` 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Editor directories and files 8 | .idea 9 | .vscode 10 | *.suo 11 | *.ntvs* 12 | *.njsproj 13 | *.sln 14 | 15 | scripts/ 16 | .env 17 | 18 | /tests/e2e/videos/ 19 | /tests/e2e/screenshots/ 20 | 21 | # local env files 22 | .env.local 23 | .env.*.local 24 | 25 | now.json -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "enamel_server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node src/app.js", 8 | "dev": "./node_modules/nodemon/bin/nodemon.js src/app.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "bcrypt": "^2.0.1", 15 | "dotenv": "^6.0.0", 16 | "graphql": "^0.13.2", 17 | "graphql-yoga": "1.14.10", 18 | "jsonwebtoken": "^8.3.0", 19 | "moment": "^2.22.2", 20 | "mongoose": "^5.1.5", 21 | "nodemailer": "^4.6.7", 22 | "nodemon": "^1.17.5", 23 | "uuid": "3.3.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/src/app.js: -------------------------------------------------------------------------------- 1 | const { GraphQLServer } = require('graphql-yoga') 2 | const mongoose = require('mongoose') 3 | require('dotenv').config() 4 | const resolvers = require('./resolvers') 5 | 6 | mongoose.connect(process.env.MONGODB_URI) 7 | const db = mongoose.connection 8 | db.on("error", console.error.bind(console, "connection error")) 9 | db.once("open", function(callback){ 10 | console.log("Connection Succeeded") 11 | }) 12 | 13 | const server = new GraphQLServer({ 14 | typeDefs: 'src/schema.graphql', 15 | resolvers, 16 | context: req => req, 17 | }) 18 | 19 | const options = { 20 | port: process.env.PORT || 5500, 21 | endpoint: '/graphql', 22 | subscriptions: '/subscriptions', 23 | playground: '/playground', 24 | } 25 | 26 | server.start(options, ({ port }) => console.log(`Server is running on port ${port}`)) 27 | -------------------------------------------------------------------------------- /server/src/emails.js: -------------------------------------------------------------------------------- 1 | const url = process.env.CLIENT_URL 2 | const fromEmail = process.env.FROM_EMAIL 3 | 4 | module.exports.invitationEmail = function (email, user, thisUser) { 5 | const text = ` 6 | Hi, 7 | 8 | Please accept this invite to enamel, our tool for work management and collaboration. 9 | 10 | Using enamel, we plan and track projects, 11 | discuss ideas, and collaborate to get work done. 12 | 13 | Accept invitation\n 14 | ${url}/signup/${user.id} 15 | 16 | Decline invitation\n 17 | ${url}/decline/${user.id} 18 | 19 | All the best, 20 | ${thisUser.name} 21 | ` 22 | 23 | return { 24 | to: `${email}`, 25 | from: { 26 | address: fromEmail, 27 | name: `${thisUser.name} at enamel` 28 | }, 29 | subject: 'Invitation to enamel', 30 | text 31 | } 32 | } 33 | 34 | module.exports.welcomeEmail = function(email, user) { 35 | const text = ` 36 | Hi, 37 | Thank you for choosing enamel! 38 | You are just one click away from completing your account registration. 39 | 40 | Confirm your email:\n 41 | ${url}/signup/${user.id} 42 | ` 43 | 44 | return { 45 | to: `${email}`, 46 | from: { 47 | address: fromEmail, 48 | name: 'enamel' 49 | }, 50 | subject: 'Please complete your registration', 51 | text 52 | } 53 | } 54 | 55 | module.exports.notificationNewUser = function(email, user) { 56 | const text = ` 57 | New user: 58 | 59 | ${email} 60 | ` 61 | 62 | return { 63 | to: fromEmail, 64 | from: { 65 | address: fromEmail, 66 | name: 'enamel' 67 | }, 68 | subject: 'New user on enamel', 69 | text 70 | } 71 | } 72 | 73 | 74 | -------------------------------------------------------------------------------- /server/src/models.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const moment = require('moment') 3 | const Schema = mongoose.Schema 4 | const ObjectId = Schema.Types.ObjectId 5 | 6 | function buildModel(name, schema, options={}) { 7 | return mongoose.model(name, new Schema(schema, Object.assign({timestamps: true}, options))) 8 | } 9 | 10 | const Folder = buildModel('Folder', { 11 | name: String, 12 | description: String, 13 | shareWith: [{ 14 | kind: String, 15 | item: { type: ObjectId, refPath: 'shareWith.kind' } 16 | }], 17 | parent: { type: ObjectId, ref: 'Folder' }, 18 | }) 19 | module.exports.Folder = Folder 20 | 21 | module.exports.Team = Folder.discriminator('Team', new Schema({ 22 | }, {timestamps: true})) 23 | 24 | module.exports.Task = buildModel('Task', { 25 | folders: [{ type: ObjectId, ref: 'Folder' }], 26 | parent: { type: ObjectId, ref: 'Task' }, 27 | assignees: [{ type: ObjectId, ref: 'User' }], 28 | name: String, 29 | description: { 30 | type: String, 31 | default: '' 32 | }, 33 | creator: { type: ObjectId, ref: 'User' }, 34 | startDate: { 35 | type: Date, 36 | }, 37 | finishDate: { 38 | type: Date, 39 | }, 40 | duration: { 41 | type: Number 42 | }, 43 | status: { 44 | type: String, 45 | default: 'New' 46 | }, 47 | }) 48 | 49 | module.exports.Group = buildModel('Group', { 50 | team: { type: ObjectId, ref: 'Team' }, 51 | name: String, 52 | initials: String, 53 | avatarColor: String, 54 | users: [{ type: ObjectId, ref: 'User' }], 55 | }) 56 | 57 | module.exports.Record = buildModel('Record', { 58 | user: { type: ObjectId, ref: 'User' }, 59 | task: { type: ObjectId, ref: 'Task' }, 60 | date: Date, 61 | timeSpent: String, 62 | comment: String 63 | }) 64 | 65 | module.exports.User = buildModel('User', { 66 | name: { 67 | type: String, 68 | default: '' 69 | }, 70 | firstname: String, 71 | lastname: String, 72 | email: { 73 | type: String, 74 | required: true, 75 | }, 76 | password: { 77 | type: String, 78 | }, 79 | jobTitle: { 80 | type: String, 81 | default: '' 82 | }, 83 | avatarColor: String, 84 | team: { type: ObjectId, ref: 'Team' }, 85 | role: String, 86 | rate: Number, 87 | rateType: String, 88 | status: String 89 | }) 90 | -------------------------------------------------------------------------------- /server/src/resolvers.js: -------------------------------------------------------------------------------- 1 | const { GraphQLScalarType } = require('graphql') 2 | const bcrypt = require('bcrypt') 3 | const jwt = require('jsonwebtoken') 4 | const moment = require('moment') 5 | const nodeMailer = require('nodemailer') 6 | const mongoose = require('mongoose') 7 | const ObjectId = mongoose.Types.ObjectId 8 | 9 | const { User, Folder, Team, Group, Record, Task } = require('./models') 10 | const { getUserId } = require('./utils') 11 | const { welcomeEmail, invitationEmail, notificationNewUser } = require('./emails') 12 | 13 | const JWT_SECRET = process.env.JWT_SECRET 14 | 15 | const transporter = nodeMailer.createTransport({ 16 | host: 'smtp.gmail.com', 17 | port: 465, 18 | secure: true, 19 | auth: { 20 | user: process.env.FROM_EMAIL, 21 | pass: process.env.GMAIL_PASSWORD 22 | } 23 | }) 24 | 25 | async function folderCommon(context, parent, name, shareWith) { 26 | const userId = getUserId(context) 27 | return { 28 | name, 29 | parent: parent || undefined, 30 | shareWith: shareWith.concat(parent ? [] : [{ 31 | kind: 'Team', 32 | item: (await User.findById(userId)).team 33 | }]) 34 | } 35 | } 36 | 37 | async function deleteSubTasks(id) { 38 | const tasks = await Task.find({parent: id}) 39 | for (const task of tasks) { 40 | await deleteSubTasks(task.id) 41 | await Task.deleteOne({_id: task.id}) 42 | } 43 | } 44 | 45 | function populateTask(promise) { 46 | return promise 47 | .populate('folders', 'name') 48 | .populate('parent', 'name') 49 | .populate('assignees', 'name email firstname lastname avatarColor') 50 | .populate('creator', 'name email firstname lastname') 51 | } 52 | 53 | function randomChoice(arr) { 54 | return arr[Math.floor(arr.length * Math.random())] 55 | } 56 | 57 | const avatarColors = [ 58 | "D81B60","F06292","F48FB1","FFB74D","FF9800","F57C00","00897B","4DB6AC","80CBC4", 59 | "80DEEA","4DD0E1","00ACC1","9FA8DA","7986CB","3949AB","8E24AA","BA68C8","CE93D8" 60 | ] 61 | 62 | const resolvers = { 63 | Query: { 64 | async getTeam (_, args, context) { 65 | const userId = getUserId(context) 66 | const user = await User.findById(userId) 67 | return await Team.findById(user.team) 68 | }, 69 | async getGroups (_, args, context) { 70 | const userId = getUserId(context) 71 | const team = (await User.findById(userId)).team 72 | return await Group.find({team}).sort({ createdAt: -1 }) 73 | return group 74 | }, 75 | async getGroup (_, {id}, context) { 76 | const userId = getUserId(context) 77 | const group = await Group.findById(id).populate('users') 78 | return group 79 | }, 80 | async getFolders (_, {parent}, context) { 81 | const userId = getUserId(context) 82 | let folders 83 | if (parent) { 84 | folders = await Folder.find({parent}) 85 | } else { 86 | const user = await User.findById(userId) 87 | const groups = await Group.find({users: ObjectId(userId)}, '_id') 88 | const ids = groups.map(o => o._id).concat( 89 | ['External User', 'Collaborator'].includes(user.role) 90 | ? [ObjectId(userId)] 91 | : [ObjectId(userId), user.team] 92 | ) 93 | folders = await Folder.find({ 'shareWith.item': ids }).populate('shareWith') 94 | } 95 | return folders 96 | }, 97 | async getFolder (_, args, context) { 98 | const userId = getUserId(context) 99 | return await Folder.findById(args.id).populate('shareWith') 100 | }, 101 | async getTasks (_, {parent, folder}, context) { 102 | if (parent) { 103 | return await populateTask(Task.find({ parent })).sort({ createdAt: 1 }) 104 | } else { 105 | return await populateTask(Task.find({ folders: folder })).sort({ createdAt: -1 }) 106 | } 107 | }, 108 | async getTask (_, {id}, context) { 109 | const userId = getUserId(context) 110 | const task = await populateTask(Task.findById(id)) 111 | if (!task) { 112 | throw new Error('Task with that id does not exist') 113 | } 114 | return task 115 | }, 116 | async getUsers (_, args, context) { 117 | const userId = getUserId(context) 118 | const team = (await User.findById(userId)).team 119 | return await User.find({team}) 120 | }, 121 | async getUser (_, {id}, context) { 122 | const userId = getUserId(context) 123 | return await User.findById(id || userId) 124 | }, 125 | async getRecord (_, {id, task, date}, context) { 126 | const user = getUserId(context) 127 | if (id) { 128 | return await Record.findById(id) 129 | } else { 130 | return await Record.findOne({ 131 | user, 132 | task, 133 | date: { 134 | $gte: moment(date).startOf('day'), 135 | $lte: moment(date).endOf('day') 136 | } 137 | }) 138 | } 139 | } 140 | }, 141 | Mutation: { 142 | async createTask(_, {folder, parent, name}, context) { 143 | const userId = getUserId(context) 144 | const task = await Task.create({ 145 | name, 146 | parent, 147 | folders: folder ? [folder] : [], 148 | creator: userId 149 | }) 150 | return await populateTask(Task.findById(task.id)) 151 | }, 152 | async updateTask(_, {id, input}, context) { 153 | const userId = getUserId(context) 154 | return await populateTask(Task.findOneAndUpdate( 155 | { _id: id }, 156 | { $set: input }, 157 | { new: true } 158 | )) 159 | }, 160 | async deleteTask(_, {id}, context) { 161 | const userId = getUserId(context) 162 | await Task.deleteOne({_id: id}) 163 | deleteSubTasks(id) 164 | return true 165 | }, 166 | async createFolder(_, {parent, name, shareWith}, context) { 167 | const folder = await Folder.create(await folderCommon(context, parent, name, shareWith)) 168 | return await Folder.findById(folder.id).populate('shareWith.item') 169 | }, 170 | async updateFolder(_, {id, input}, context) { 171 | const userId = getUserId(context) 172 | return await Folder.findOneAndUpdate( 173 | { _id: id }, 174 | { $set: input }, 175 | { new: true } 176 | ).populate('shareWith') 177 | }, 178 | async deleteFolder(_, {id}, context) { 179 | const userId = getUserId(context) 180 | await Folder.deleteOne({_id: id}) 181 | return true 182 | }, 183 | async captureEmail (_, {email}) { 184 | const isEmailTaken = await User.findOne({email}) 185 | if (isEmailTaken) { 186 | throw new Error('This email is already taken') 187 | } 188 | const user = await User.create({ 189 | email, 190 | role: 'Owner', 191 | status: 'Pending' 192 | }) 193 | transporter.sendMail(welcomeEmail(email, user)) 194 | transporter.sendMail(notificationNewUser(email, user)) 195 | 196 | return user 197 | }, 198 | async invite (_, {emails, groups, role}, context) { 199 | const userId = getUserId(context) 200 | const thisUser = await User.findById(userId) 201 | const team = thisUser.team 202 | const teamMembers = (await User.find({team}, 'email')).map(o => o.email) 203 | const users = [] 204 | for (const email of emails) { 205 | if (teamMembers.includes(email)) { 206 | } else { 207 | const user = await User.create({ 208 | email, 209 | team, 210 | role, 211 | status: 'Pending' 212 | }) 213 | users.push(user) 214 | transporter.sendMail(invitationEmail(email, user, thisUser)) 215 | } 216 | } 217 | const userIds = users.map(o => o.id) 218 | for (const id of groups) { 219 | const group = await Group.findById(id) 220 | group.users = userIds 221 | await group.save() 222 | } 223 | return users 224 | }, 225 | async decline (_, {id}) { 226 | await User.findOneAndUpdate( 227 | { _id: id }, 228 | { $set: { status: 'Declined' } }, 229 | ) 230 | return true 231 | }, 232 | async signup (_, {id, firstname, lastname, password}) { 233 | const user = await User.findById(id) 234 | const common = { 235 | firstname, 236 | lastname, 237 | name: `${firstname} ${lastname}`, 238 | avatarColor: randomChoice(avatarColors), 239 | password: await bcrypt.hash(password, 10), 240 | status: 'Active' 241 | } 242 | if (user.role === 'Owner') { 243 | const team = await Team.create({ 244 | name: `${common.name}'s Team` 245 | }) 246 | user.set(Object.assign(common, { 247 | team: team.id, 248 | jobTitle: 'CEO/Owner/Founder' 249 | })) 250 | } else { 251 | user.set(common) 252 | } 253 | await user.save() 254 | const token = jwt.sign({id: user.id, email: user.email}, JWT_SECRET) 255 | return {token, user} 256 | }, 257 | async login (_, {email, password}) { 258 | const user = await User.findOne({email}) 259 | if (!user) { 260 | throw new Error('No user with that email') 261 | } 262 | const valid = await bcrypt.compare(password, user.password) 263 | if (!valid) { 264 | throw new Error('Incorrect password') 265 | } 266 | const token = jwt.sign({id: user.id, email}, JWT_SECRET) 267 | return {token, user} 268 | }, 269 | async createGroup (_, {name, initials, avatarColor, users}, context) { 270 | const userId = getUserId(context) 271 | const team = (await User.findById(userId)).team 272 | return await Group.create({ 273 | name, 274 | team, 275 | initials, 276 | avatarColor, 277 | users 278 | }) 279 | }, 280 | async addUsersToGroup (_, {id, users}, context) { 281 | const userId = getUserId(context) 282 | return await Group.findOneAndUpdate( 283 | { _id: id }, 284 | { $push: { users: { $each: users } } }, 285 | { new: true } 286 | ) 287 | }, 288 | async removeUsersFromGroup (_, {id, users}, context) { 289 | const userId = getUserId(context) 290 | return await Group.findOneAndUpdate( 291 | { _id: id }, 292 | { $pullAll: { users } }, 293 | { new: true } 294 | ) 295 | }, 296 | async updateGroup (_, {id, name, initials, avatarColor}, context) { 297 | const userId = getUserId(context) 298 | return await Group.findOneAndUpdate( 299 | { _id: id }, 300 | { $set: { name, initials, avatarColor } }, 301 | { new: true } 302 | ) 303 | }, 304 | async deleteGroup (_, {id}, context) { 305 | const userId = getUserId(context) 306 | await Group.deleteOne({_id: id}) 307 | return true 308 | }, 309 | async updateUser(_, {id, input}, context) { 310 | const userId = getUserId(context) 311 | return await User.findOneAndUpdate( 312 | { _id: id }, 313 | { $set: input }, 314 | { new: true } 315 | ) 316 | }, 317 | async createRecord (_, {input}, context) { 318 | const user = getUserId(context) 319 | return await Record.create({ 320 | ...input, 321 | user 322 | }) 323 | }, 324 | async updateRecord (_, {id, input}, context) { 325 | const userId = getUserId(context) 326 | return await Record.findOneAndUpdate( 327 | { _id: id }, 328 | { $set: input }, 329 | { new: true } 330 | ) 331 | }, 332 | async deleteRecord (_, {id}, context) { 333 | const userId = getUserId(context) 334 | await Record.deleteOne({_id: id}) 335 | return true 336 | } 337 | }, 338 | Date: new GraphQLScalarType({ 339 | name: 'Date', 340 | description: 'Date custom scalar type', 341 | parseValue: (value) => moment(value).toDate(), // value from the client 342 | serialize: (value) => value.getTime(), // value sent to the client 343 | parseLiteral: (ast) => ast 344 | }) 345 | } 346 | 347 | module.exports = resolvers 348 | -------------------------------------------------------------------------------- /server/src/schema.graphql: -------------------------------------------------------------------------------- 1 | scalar Date 2 | scalar JSON 3 | 4 | type Query { 5 | getUsers: [User] 6 | getUser(id: String): User 7 | getTeam: Team 8 | getGroups: [Group] 9 | getGroup: Group 10 | getFolders(parent: String): [Folder] 11 | getFolder(id: String!): Folder 12 | getTasks(folder: String, parent: String): [Task] 13 | getTask(id: String!): Task 14 | getRecord(id: String, task: String, date: String): Record 15 | } 16 | 17 | type Mutation { 18 | captureEmail(email: String!): User 19 | invite(emails: [String], groups: [String], role: String): [User] 20 | decline(id: String!): Boolean 21 | signup(id: String!, firstname: String!, lastname: String!, password: String!): AuthPayload! 22 | login(email: String!, password: String!): AuthPayload! 23 | 24 | createFolder(parent: String, name: String!, shareWith: [ShareInput]): Folder 25 | updateFolder(id: String!, input: FolderInput): Folder 26 | deleteFolder(id: String!): Boolean 27 | 28 | createTask(folder: String, parent: String, name: String!): Task 29 | updateTask(id: String!, input: TaskInput): Task 30 | deleteTask(id: String!): Boolean 31 | 32 | createGroup(name: String, initials: String, avatarColor: String, users: [String]): Group 33 | addUsersToGroup(id: String!, users: [String]): Group 34 | removeUsersFromGroup(id: String!, users: [String]): Group 35 | updateGroup(id: String!, name: String, initials: String, avatarColor: String): Group 36 | deleteGroup(id: String!): Boolean 37 | 38 | updateUser(id: String! input: UserInput): User 39 | 40 | createRecord(input: RecordInput): Record 41 | updateRecord(id: String!, input: RecordInput): Record 42 | deleteRecord(id: String!): Boolean 43 | } 44 | 45 | type User { 46 | id: String 47 | name: String 48 | firstname: String 49 | lastname: String 50 | email: String 51 | avatarColor: String 52 | jobTitle: String 53 | team: String 54 | role: String 55 | rate: Float 56 | rateType: String 57 | status: String 58 | createdAt: Date 59 | } 60 | 61 | type Folder { 62 | id: String 63 | name: String 64 | parent: String 65 | description: String 66 | shareWith: [JSON] 67 | } 68 | 69 | type Task { 70 | id: String 71 | folders: [Folder] 72 | assignees: [User] 73 | name: String 74 | description: String 75 | parent: User 76 | creator: User 77 | startDate: Date 78 | finishDate: Date 79 | duration: Int 80 | importance: String 81 | status: String 82 | createdAt: Date 83 | } 84 | 85 | type Team { 86 | id: String 87 | name: String 88 | } 89 | 90 | type Group { 91 | id: String 92 | team: String 93 | name: String 94 | initials: String 95 | avatarColor: String 96 | users: [String] 97 | } 98 | 99 | type Record { 100 | id: String 101 | user: String 102 | task: String 103 | date: Date 104 | timeSpent: String 105 | comment: String 106 | } 107 | 108 | type AuthPayload { 109 | token: String! 110 | user: User! 111 | } 112 | 113 | input ShareInput { 114 | kind: String 115 | item: String 116 | } 117 | 118 | input UserInput { 119 | name: String 120 | firstname: String 121 | lastname: String 122 | email: String 123 | avatarColor: String 124 | jobTitle: String 125 | team: String 126 | role: String 127 | rate: Float 128 | rateType: String 129 | status: String 130 | } 131 | 132 | input TaskInput { 133 | name: String 134 | description: String 135 | parent: String 136 | creator: String 137 | assignees: [String] 138 | startDate: Date 139 | finishDate: Date 140 | duration: Int 141 | importance: String 142 | status: String 143 | } 144 | 145 | input FolderInput { 146 | name: String 147 | parent: String 148 | description: String 149 | shareWith: [ShareInput] 150 | } 151 | 152 | input RecordInput { 153 | task: String 154 | date: Date 155 | timeSpent: String 156 | comment: String 157 | } -------------------------------------------------------------------------------- /server/src/utils.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | require('dotenv').config() 3 | 4 | function getUserId(context) { 5 | const Authorization = context.request.get('Authorization') 6 | if (Authorization) { 7 | const token = Authorization.replace('Bearer ', '') 8 | const {id} = jwt.verify(token, process.env.JWT_SECRET) 9 | return id 10 | } 11 | throw new Error('Not authenticated') 12 | } 13 | 14 | module.exports = { 15 | getUserId, 16 | } --------------------------------------------------------------------------------