├── 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 |
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 |
2 |
3 |
4 |
5 |
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 |
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 |
2 |
3 |
4 |
5 |
Add users to group
6 |
7 |
23 |
24 |
47 |
48 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
112 |
113 |
169 |
--------------------------------------------------------------------------------
/client/src/components/DateRangePicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | When do you want this task to be done?
5 |
6 |
7 |
10 | {{planning}}
11 |
12 |
13 |
60 |
61 |
62 | OK
63 |
64 | Cancel
65 |
66 |
67 |
68 |
69 |
70 |
199 |
200 |
--------------------------------------------------------------------------------
/client/src/components/DescriptionField.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Click to add the description
4 |
5 |
13 |
14 | Save
16 |
17 |
18 |
19 |
20 |
21 |
68 |
69 |
99 |
--------------------------------------------------------------------------------
/client/src/components/FolderForm.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Create {{mode}}
8 |
9 |
10 |
12 |
13 |
14 |
28 |
29 |
30 | Create
31 | Cancel
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
130 |
131 |
154 |
--------------------------------------------------------------------------------
/client/src/components/FolderTree.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
9 |
10 |
11 |
12 |
{{ model.name }}
13 |
14 |
15 |
Add Folder
16 |
17 |
18 |
19 |
Delete
20 |
21 |
22 |
23 |
24 |
25 |
26 |
35 |
36 |
37 |
38 |
39 |
40 |
136 |
137 |
139 |
--------------------------------------------------------------------------------
/client/src/components/GroupForm.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Create group
6 |
7 |
21 |
22 |
23 | Avatar color
24 |
32 |
33 |
34 |
79 |
80 |
81 | Create
82 | Cancel
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
188 |
189 |
264 |
--------------------------------------------------------------------------------
/client/src/components/GroupUpdateForm.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Update group
6 |
7 |
21 |
22 |
23 | Avatar color
24 |
32 |
33 |
34 |
35 | Update
36 | Cancel
37 |
38 |
39 |
40 |
41 | Oops, you can't delete this group yet! Remove all users and subgroups from the group first.
42 |
43 | Delete group
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
145 |
146 |
196 |
--------------------------------------------------------------------------------
/client/src/components/InviteUserForm.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Invite users
6 |
7 |
8 | {{ error }}
9 |
10 |
11 |
17 |
18 |
61 |
62 |
63 | Roles
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | Invite users
74 | Cancel
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
168 |
169 |
225 |
--------------------------------------------------------------------------------
/client/src/components/Navigation.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
26 |
27 |
48 |
--------------------------------------------------------------------------------
/client/src/components/NavigationRight.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{getUser.name}}
7 |
8 |
9 |
10 |
11 |
12 | Accounts
13 |
14 | Workspace
15 |
18 |
19 |
20 |
21 |
22 |
57 |
58 |
86 |
--------------------------------------------------------------------------------
/client/src/components/Record.vue:
--------------------------------------------------------------------------------
1 |
2 |
34 |
35 |
36 |
137 |
138 |
--------------------------------------------------------------------------------
/client/src/components/UserDetail.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
{{user.name}}
9 |
{{user.jobTitle}}
10 |
11 | joined:
12 | {{formateDate(user.createdAt)}}
13 |
14 |
15 |
16 |
17 |
21 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | Role |
33 | {{form.role}} |
34 |
35 |
36 | Email |
37 | {{user.email}} |
38 |
39 |
40 | Rate/Salary |
41 |
42 | {{form.rate}} per {{form.rateType}}
43 | |
44 |
45 |
46 | Location |
47 | -- |
48 |
49 |
50 | Phone number |
51 | -- |
52 |
53 |
54 | Time zone |
55 | US/Pacific |
56 |
57 |
58 |
59 |
60 |
63 | Edit settings
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
92 |
93 |
94 | Save changes
95 | Cancel
96 |
97 |
98 |
99 |
100 |
101 |
Member of
102 |
107 |
108 |
109 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
183 |
184 |
385 |
386 |
--------------------------------------------------------------------------------
/client/src/components/icons/Avatar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
13 |
14 |
20 |
21 |
22 |
31 | {{getInitials}}
32 |
33 |
42 |
43 |
44 |
51 | {{number}}
52 |
53 |
54 |
55 |
56 |
57 |
58 |
73 |
74 |
75 |
97 |
--------------------------------------------------------------------------------
/client/src/components/icons/CloseButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
16 |
17 |
--------------------------------------------------------------------------------
/client/src/components/icons/PlusButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
32 |
33 |
--------------------------------------------------------------------------------
/client/src/components/icons/RemoveButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/client/src/components/task/TaskForm.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
82 |
83 |
119 |
--------------------------------------------------------------------------------
/client/src/components/task/TaskHeader.vue:
--------------------------------------------------------------------------------
1 |
2 |
52 |
53 |
54 |
124 |
125 |
183 |
--------------------------------------------------------------------------------
/client/src/components/task/TaskSettingBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
7 | {{ dateLabel }}
8 |
9 |
10 |
11 |
13 |
14 | {{ recordLabel }}
15 |
16 |
17 |
18 |
19 |
21 | {{subtasks.length > 0 ? formatSubtaskCount(subtasks) : 'Add subtask'}}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
68 |
69 |
--------------------------------------------------------------------------------
/client/src/components/task/TaskStateBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
9 | {{task.status}}
10 |
11 |
12 |
13 |
15 |
18 | {{status}}
19 |
20 |
21 |
22 |
23 |
24 |
74 |
75 |
76 |
77 | by {{task.creator.firstname}} {{task.creator.lastname[0]}} at {{formatDate(task.createdAt)}}
78 |
79 |
80 |
81 |
82 |
83 |
84 |
155 |
156 |
--------------------------------------------------------------------------------
/client/src/components/task/TaskTree.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
7 |
8 |
9 |
11 |
12 |
13 |
15 |
17 |
18 |
{{ model.name }}
19 |
20 |
21 |
22 | {{ model.status }}
23 |
24 |
25 |
26 |
35 |
36 |
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 |
2 |
3 |
This is an about page
4 |
5 |
6 |
--------------------------------------------------------------------------------
/client/src/views/Account.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
Users & Groups
8 |
9 |
10 |
11 |
Groups({{getGroups.length}})
12 |
13 |
14 |
15 |
18 | {{group.name}}
19 | {{index ? ungroupedUsers.length : getUsers.length}}
20 |
21 |
24 |
25 |
{{group.name}}
26 |
{{group.users.length}}
27 |
28 |
29 |
30 |
31 |
32 |
{{selectedGroup.name}}
33 | ({{users.length}})
34 |
35 |
37 |
38 | + Add users
39 |
40 |
41 |
55 |
56 |
58 |
59 |
60 | Group settings
61 |
62 |
63 |
64 |
65 |
69 |
70 | {{ users.filter(o => overview.filter.includes(o.role)).length }}
71 |
72 |
{{overview.name}}
73 |
74 |
75 |
76 |
78 |
79 |
80 |
81 |
82 | {{scope.row.name}}
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
101 |
104 |
107 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
250 |
251 |
358 |
--------------------------------------------------------------------------------
/client/src/views/Decline.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
Invitation declined.
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
37 |
38 |
43 |
--------------------------------------------------------------------------------
/client/src/views/Folder.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
108 |
109 |
143 |
--------------------------------------------------------------------------------
/client/src/views/FolderDetail.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
228 |
229 |
306 |
--------------------------------------------------------------------------------
/client/src/views/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
Welcome!
10 |
Enter your email address to start free trial
11 |
12 |
13 | {{ error }}
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | Create my enamel account
23 |
24 |
25 |
26 |
27 | Already have an enamel account?
28 | Log in
29 |
30 |
31 |
32 |
Thank you!
33 |
Please check your email.
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
87 |
88 |
--------------------------------------------------------------------------------
/client/src/views/Login.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
Log in
10 |
11 |
12 | {{ error }}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | Log in
24 |
25 |
26 |
27 |
28 | Don't have an account?
29 | Create an account
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
79 |
80 |
85 |
--------------------------------------------------------------------------------
/client/src/views/Signup.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
Welcome to enamel! Finish setting up your account
10 |
11 |
12 | {{ error }}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | Complete
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
85 |
86 |
96 |
97 |
--------------------------------------------------------------------------------
/client/src/views/Task.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
113 |
114 |
125 |
--------------------------------------------------------------------------------
/client/src/views/Workspace.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
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 | }
--------------------------------------------------------------------------------