Compare commits
149 Commits
0.1.1
...
feat/redes
Author | SHA1 | Date | |
---|---|---|---|
64093e19f6 | |||
4f5aa2deb8 | |||
886b2763ee | |||
0d00fc7518 | |||
df6140a10f | |||
f9a5007104 | |||
de6fe78004 | |||
799d7f3ad0 | |||
3afd860534 | |||
cea99397db | |||
800dd2014c | |||
54553cfbdd | |||
d5d85c5e30 | |||
ef2aadefbb | |||
cf63783174 | |||
fe90631df5 | |||
119a4b2868 | |||
3992e4c2de | |||
ce3afec8a0 | |||
25df251cc5 | |||
2b3084ea52 | |||
d725e42adf | |||
aa84cbabb2 | |||
8b1de30204 | |||
eab33bfd9a | |||
8d724fa3cf | |||
76e398488f | |||
d1b867db35 | |||
aeb97a30d8 | |||
56e925a48d | |||
65cd431c1a | |||
a188c4b0ca | |||
3bfce1825c | |||
2b4f94117c | |||
05799fce90 | |||
b4f37350a9 | |||
8c6a3db0bc | |||
5a9a66effe | |||
167d285d02 | |||
e2634dc490 | |||
04c12e4da9 | |||
3e72271d9b | |||
bd34f4b3ad | |||
f45e359402 | |||
229a53fa0a | |||
3392b3345d | |||
29b7c028ca | |||
0cf4141418 | |||
383d90d747 | |||
0cc1b5a1df | |||
0760edac80 | |||
ceaa49c5a1 | |||
8b22d33dad | |||
61e9249c98 | |||
c347c6bdc3 | |||
35ac12b7b2 | |||
40b27aa1f1 | |||
533b9511c9 | |||
dc50ef3566 | |||
f9e6fba552 | |||
be7e945313 | |||
edc7b649ec | |||
4b83ff594f | |||
c2a0f5e5d0 | |||
ff15e7fb53 | |||
b5744bcf22 | |||
a7c1ca328f | |||
783e1c84c3 | |||
433a4fd55c | |||
eb0727ddcb | |||
77087158a9 | |||
f215418be1 | |||
f051bebd48 | |||
a1c9251a1f | |||
9d7f46907f | |||
dcf53b9077 | |||
d6101d9221 | |||
a8b3809515 | |||
f16cceb0e1 | |||
90b92781d7 | |||
1bac555ebb | |||
668b118b25 | |||
9c051c51a6 | |||
66c603de75 | |||
8d3b0bd510 | |||
9f27bd157f | |||
e25a426e7b | |||
0c9ab8abc2 | |||
c4a80590a1 | |||
978be2218d | |||
19deab0515 | |||
f732b211c9 | |||
b5fd3b1bf1 | |||
ea767f3d19 | |||
7b6624ecc3 | |||
6c7203a4aa | |||
86f2d90668 | |||
92493deedf | |||
a288e06123 | |||
ed4775faa5 | |||
0c7d2e2c9f | |||
4277b7b2a8 | |||
28a53f14ad | |||
0d4fb6a0d0 | |||
0366b4c7f7 | |||
058749cb17 | |||
3d95c6b600 | |||
c7538a98e5 | |||
fe84f97f18 | |||
52c60abcd7 | |||
9fdb3008db | |||
e2ef8a1a19 | |||
61cd376bfd | |||
ba9fc64fd9 | |||
03dafe9b7b | |||
12a767947a | |||
40557ba79f | |||
e4d1e21304 | |||
f7c6ee470e | |||
227ce5966d | |||
aa5e1c0661 | |||
b603081691 | |||
e76ea9da63 | |||
923d7f7372 | |||
009d717d80 | |||
4272fefa28 | |||
25f5cad557 | |||
cb655347be | |||
03cf245828 | |||
09d73fdbce | |||
0caa803d27 | |||
feea209507 | |||
e57033655a | |||
31526a2575 | |||
0a1bdc19f3 | |||
771d598c04 | |||
a9a1576f46 | |||
9541ae70e0 | |||
66583bb4fb | |||
47782d6d86 | |||
4988176220 | |||
dd50baa05a | |||
46e724e731 | |||
8ce19a1ceb | |||
8f410cd9b7 | |||
adae924ca1 | |||
22e4669c34 | |||
81b89f2411 | |||
13480acd7e |
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
custom: ['https://paypal.me/jordanthedev', 'https://www.buymeacoffee.com/jordanknott']
|
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help improve Taskcafe
|
||||||
|
title: ""
|
||||||
|
labels: ""
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is with steps to reproduce the issue.
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
What did you expect to happen?
|
||||||
|
|
||||||
|
**Screenshots / Live demo link**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
|
|
||||||
|
Please send the Taskcafe web service logs if applicable.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Please read the contributing guide before working on any new pull requests!
|
||||||
|
|
||||||
|
If you would like to ask a question regarding a possible bug or feature request, please
|
||||||
|
join the Taskcafe discord - https://discord.gg/JkQDruh
|
||||||
|
|
||||||
|
-->
|
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Feature Request
|
||||||
|
url: https://github.com/JordanKnott/taskcafe/discussions/new?category=ideas
|
||||||
|
about: Share ideas for new features
|
||||||
|
- name: Ask a Question
|
||||||
|
url: https://github.com/JordanKnott/taskcafe/discussions/new?category=q-a
|
||||||
|
about: Ask the community for help
|
19
.github/pull_request_template.md
vendored
Normal file
19
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
* **Please check if the PR fulfills these requirements**
|
||||||
|
- [ ] You have read the contribution guidelines [guidelines](https://github.com/JordanKnott/taskcafe/blob/master/CONTRIBUTING.md)
|
||||||
|
- [ ] The commit message follows our [guidelines](https://github.com/JordanKnott/taskcafe/blob/master/CONTRIBUTING.md#git-commit-message-style)
|
||||||
|
- [ ] Docs have been added / updated (for bug fixes / features)
|
||||||
|
|
||||||
|
|
||||||
|
* **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...)
|
||||||
|
|
||||||
|
|
||||||
|
* **What is the current behavior?** (You can also link to an open issue here)
|
||||||
|
|
||||||
|
|
||||||
|
* **What is the new behavior (if this is a feature change)?**
|
||||||
|
|
||||||
|
|
||||||
|
* **Does this PR introduce a breaking change?** (What changes might users need to make in their application due to this PR?)
|
||||||
|
|
||||||
|
|
||||||
|
* **Other information**:
|
BIN
.github/taskcafe-full.png
vendored
BIN
.github/taskcafe-full.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 18 KiB |
352
.github/taskcafe-full.svg
vendored
352
.github/taskcafe-full.svg
vendored
@ -7,16 +7,16 @@
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
inkscape:export-ydpi="96"
|
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
|
||||||
inkscape:export-xdpi="96"
|
|
||||||
inkscape:export-filename="/home/jordan/Projects/project-citadel/.github/taskcafe-full.png"
|
|
||||||
viewBox="0 0 1100 350"
|
|
||||||
version="1.1"
|
|
||||||
id="svg951"
|
|
||||||
sodipodi:docname="taskcafe-full.svg"
|
|
||||||
width="1100"
|
|
||||||
height="350"
|
height="350"
|
||||||
inkscape:version="1.0 (4035a4fb49, 2020-05-01)">
|
width="1100"
|
||||||
|
sodipodi:docname="taskcafe-full.svg"
|
||||||
|
id="svg951"
|
||||||
|
version="1.1"
|
||||||
|
viewBox="0 0 1100 350"
|
||||||
|
inkscape:export-filename="/home/jordan/Projects/project-citadel/.github/taskcafe-full.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96">
|
||||||
<metadata
|
<metadata
|
||||||
id="metadata957">
|
id="metadata957">
|
||||||
<rdf:RDF>
|
<rdf:RDF>
|
||||||
@ -25,81 +25,70 @@
|
|||||||
<dc:format>image/svg+xml</dc:format>
|
<dc:format>image/svg+xml</dc:format>
|
||||||
<dc:type
|
<dc:type
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
<dc:title></dc:title>
|
<dc:title />
|
||||||
</cc:Work>
|
</cc:Work>
|
||||||
</rdf:RDF>
|
</rdf:RDF>
|
||||||
</metadata>
|
</metadata>
|
||||||
<defs
|
<defs
|
||||||
id="defs955">
|
id="defs955">
|
||||||
<inkscape:path-effect
|
<inkscape:path-effect
|
||||||
effect="bspline"
|
only_selected="false"
|
||||||
|
apply_with_weight="true"
|
||||||
|
apply_no_weight="true"
|
||||||
|
helper_size="0"
|
||||||
|
steps="2"
|
||||||
|
weight="33.333333"
|
||||||
|
lpeversion="1"
|
||||||
|
is_visible="true"
|
||||||
id="path-effect2633"
|
id="path-effect2633"
|
||||||
is_visible="true"
|
effect="bspline" />
|
||||||
lpeversion="1"
|
|
||||||
weight="33.333333"
|
|
||||||
steps="2"
|
|
||||||
helper_size="0"
|
|
||||||
apply_no_weight="true"
|
|
||||||
apply_with_weight="true"
|
|
||||||
only_selected="false" />
|
|
||||||
<inkscape:path-effect
|
<inkscape:path-effect
|
||||||
only_selected="false"
|
effect="bspline"
|
||||||
apply_with_weight="true"
|
|
||||||
apply_no_weight="true"
|
|
||||||
helper_size="0"
|
|
||||||
steps="2"
|
|
||||||
weight="33.333333"
|
|
||||||
lpeversion="1"
|
|
||||||
is_visible="true"
|
|
||||||
id="path-effect2614"
|
id="path-effect2614"
|
||||||
effect="bspline" />
|
|
||||||
<inkscape:path-effect
|
|
||||||
only_selected="false"
|
|
||||||
apply_with_weight="true"
|
|
||||||
apply_no_weight="true"
|
|
||||||
helper_size="0"
|
|
||||||
steps="2"
|
|
||||||
weight="33.333333"
|
|
||||||
lpeversion="1"
|
|
||||||
is_visible="true"
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
weight="33.333333"
|
||||||
|
steps="2"
|
||||||
|
helper_size="0"
|
||||||
|
apply_no_weight="true"
|
||||||
|
apply_with_weight="true"
|
||||||
|
only_selected="false" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="bspline"
|
||||||
id="path-effect2500"
|
id="path-effect2500"
|
||||||
effect="bspline" />
|
|
||||||
<inkscape:path-effect
|
|
||||||
only_selected="false"
|
|
||||||
apply_with_weight="true"
|
|
||||||
apply_no_weight="true"
|
|
||||||
helper_size="0"
|
|
||||||
steps="2"
|
|
||||||
weight="33.333333"
|
|
||||||
lpeversion="1"
|
|
||||||
is_visible="true"
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
weight="33.333333"
|
||||||
|
steps="2"
|
||||||
|
helper_size="0"
|
||||||
|
apply_no_weight="true"
|
||||||
|
apply_with_weight="true"
|
||||||
|
only_selected="false" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="bspline"
|
||||||
id="path-effect2491"
|
id="path-effect2491"
|
||||||
effect="bspline" />
|
|
||||||
<inkscape:path-effect
|
|
||||||
only_selected="false"
|
|
||||||
apply_with_weight="true"
|
|
||||||
apply_no_weight="true"
|
|
||||||
helper_size="0"
|
|
||||||
steps="2"
|
|
||||||
weight="33.333333"
|
|
||||||
lpeversion="1"
|
|
||||||
is_visible="true"
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
weight="33.333333"
|
||||||
|
steps="2"
|
||||||
|
helper_size="0"
|
||||||
|
apply_no_weight="true"
|
||||||
|
apply_with_weight="true"
|
||||||
|
only_selected="false" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="bspline"
|
||||||
id="path-effect1706"
|
id="path-effect1706"
|
||||||
effect="bspline" />
|
|
||||||
<inkscape:path-effect
|
|
||||||
only_selected="false"
|
|
||||||
apply_with_weight="true"
|
|
||||||
apply_no_weight="true"
|
|
||||||
helper_size="0"
|
|
||||||
steps="2"
|
|
||||||
weight="33.333333"
|
|
||||||
lpeversion="1"
|
|
||||||
is_visible="true"
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
weight="33.333333"
|
||||||
|
steps="2"
|
||||||
|
helper_size="0"
|
||||||
|
apply_no_weight="true"
|
||||||
|
apply_with_weight="true"
|
||||||
|
only_selected="false" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="bspline"
|
||||||
id="path-effect1697"
|
id="path-effect1697"
|
||||||
effect="bspline" />
|
|
||||||
<inkscape:path-effect
|
|
||||||
effect="bspline"
|
|
||||||
id="path-effect917"
|
|
||||||
is_visible="true"
|
is_visible="true"
|
||||||
lpeversion="1"
|
lpeversion="1"
|
||||||
weight="33.333333"
|
weight="33.333333"
|
||||||
@ -117,11 +106,11 @@
|
|||||||
weight="33.333333"
|
weight="33.333333"
|
||||||
lpeversion="1"
|
lpeversion="1"
|
||||||
is_visible="true"
|
is_visible="true"
|
||||||
id="path-effect950"
|
id="path-effect917"
|
||||||
effect="bspline" />
|
effect="bspline" />
|
||||||
<inkscape:path-effect
|
<inkscape:path-effect
|
||||||
effect="bspline"
|
effect="bspline"
|
||||||
id="path-effect969"
|
id="path-effect950"
|
||||||
is_visible="true"
|
is_visible="true"
|
||||||
lpeversion="1"
|
lpeversion="1"
|
||||||
weight="33.333333"
|
weight="33.333333"
|
||||||
@ -130,131 +119,150 @@
|
|||||||
apply_no_weight="true"
|
apply_no_weight="true"
|
||||||
apply_with_weight="true"
|
apply_with_weight="true"
|
||||||
only_selected="false" />
|
only_selected="false" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
only_selected="false"
|
||||||
|
apply_with_weight="true"
|
||||||
|
apply_no_weight="true"
|
||||||
|
helper_size="0"
|
||||||
|
steps="2"
|
||||||
|
weight="33.333333"
|
||||||
|
lpeversion="1"
|
||||||
|
is_visible="true"
|
||||||
|
id="path-effect969"
|
||||||
|
effect="bspline" />
|
||||||
</defs>
|
</defs>
|
||||||
<sodipodi:namedview
|
<sodipodi:namedview
|
||||||
inkscape:document-rotation="0"
|
inkscape:current-layer="g2585"
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#666666"
|
|
||||||
borderopacity="1"
|
|
||||||
objecttolerance="10"
|
|
||||||
gridtolerance="10"
|
|
||||||
guidetolerance="10"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pageshadow="2"
|
|
||||||
inkscape:window-width="1920"
|
|
||||||
inkscape:window-height="1032"
|
|
||||||
id="namedview953"
|
|
||||||
showgrid="false"
|
|
||||||
units="px"
|
|
||||||
inkscape:zoom="1.2109091"
|
|
||||||
inkscape:cx="550"
|
|
||||||
inkscape:cy="241.06607"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="-18"
|
|
||||||
inkscape:window-maximized="1"
|
inkscape:window-maximized="1"
|
||||||
inkscape:current-layer="g2585" />
|
inkscape:window-y="-18"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:cy="250.95495"
|
||||||
|
inkscape:cx="534.60693"
|
||||||
|
inkscape:zoom="0.85624204"
|
||||||
|
units="px"
|
||||||
|
showgrid="false"
|
||||||
|
id="namedview953"
|
||||||
|
inkscape:window-height="1032"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
guidetolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
objecttolerance="10"
|
||||||
|
borderopacity="1"
|
||||||
|
bordercolor="#666666"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
inkscape:document-rotation="0" />
|
||||||
<g
|
<g
|
||||||
id="g974"
|
transform="matrix(7.6408653,0,0,7.6408653,40.593299,-53.251254)"
|
||||||
transform="matrix(7.6408653,0,0,7.6408653,40.593299,-53.251254)">
|
id="g974">
|
||||||
<g
|
<g
|
||||||
id="g1721"
|
transform="matrix(0.21947908,0,0,0.21947908,-1.6240521,6.1742219)"
|
||||||
transform="matrix(0.21947908,0,0,0.21947908,-1.6240521,6.1742219)">
|
id="g1721">
|
||||||
<g
|
<g
|
||||||
transform="translate(0,-133.87521)"
|
id="g2454"
|
||||||
id="g2454">
|
transform="translate(0,-133.87521)">
|
||||||
<g
|
<g
|
||||||
transform="translate(-35.365658,-44.89936)"
|
id="g2554"
|
||||||
id="g2554">
|
transform="translate(-35.365658,-44.89936)">
|
||||||
<g
|
<g
|
||||||
transform="translate(0,107.63572)"
|
id="g2433"
|
||||||
id="g2433">
|
transform="translate(0,107.63572)">
|
||||||
<g
|
<g
|
||||||
transform="translate(0.82337254,1.4684449)"
|
id="g2439"
|
||||||
id="g2439">
|
transform="translate(0.82337254,1.4684449)">
|
||||||
<g
|
<g
|
||||||
id="g2472">
|
id="g2472">
|
||||||
<g
|
<g
|
||||||
transform="translate(0,-15.084391)"
|
id="g2585"
|
||||||
id="g2585">
|
transform="translate(0,-15.084391)">
|
||||||
<g
|
<g
|
||||||
id="g2427"
|
transform="translate(1.0665725)"
|
||||||
transform="matrix(1.9229347,0,0,1.9229347,9.8747364,70.748305)">
|
id="g4610">
|
||||||
<path
|
|
||||||
transform="matrix(0.13087523,0,0,0.13087523,-5.3126573,6.9692701)"
|
|
||||||
style="fill:#7367f0;fill-opacity:1;stroke-width:0.399296"
|
|
||||||
d="m 372.01783,403.60348 c -54.69036,0 -99.02539,44.33501 -99.02539,99.02537 0,54.69035 44.33503,99.02537 99.02539,99.02537 54.69036,0 99.02538,-44.33502 99.02538,-99.02537 0,-54.69036 -44.33502,-99.02538 -99.02538,-99.02537 z m 0,19.16619 c 44.13497,0 79.85917,35.71742 79.85917,79.85918 0,44.13497 -35.71741,79.85919 -79.85917,79.85919 -44.13497,0 -79.85918,-35.71742 -79.85918,-79.85919 0,-44.13498 35.71742,-79.85918 79.85918,-79.85918 m 55.98288,52.01507 -8.99853,-9.0712 c -1.86352,-1.87869 -4.89736,-1.89107 -6.77605,-0.0272 L 355.78487,521.674 331.91016,497.60565 c -1.86352,-1.87869 -4.89736,-1.89107 -6.77605,-0.0275 l -9.07159,8.99853 c -1.8787,1.86352 -1.89108,4.89736 -0.0272,6.77646 l 36.24849,36.54196 c 1.86352,1.87869 4.89736,1.89107 6.77605,0.0272 l 68.9141,-68.36106 c 1.87828,-1.86392 1.89025,-4.89778 0.0268,-6.77646 z"
|
|
||||||
id="path1636" />
|
|
||||||
<g
|
<g
|
||||||
transform="translate(-0.16733365,0.61658838)"
|
id="g2560"
|
||||||
id="g2513">
|
transform="translate(-28.409706,-8.3958791)">
|
||||||
<path
|
<text
|
||||||
d="m 274.76901,687.50206 h 187.88752 c 51.86479,0 93.94376,-42.07897 93.94376,-93.94375 h 31.31459 c 69.08781,0 125.25834,-56.17055 125.25834,-125.25835 0,-69.08781 -56.17053,-125.25834 -125.25834,-125.25834 H 204.31119 c -13.01512,0 -23.48594,10.47081 -23.48594,23.48593 v 227.03076 c 0,51.86478 42.07898,93.94375 93.94376,93.94375 z M 587.91488,405.67079 c 34.54391,0 62.62917,28.08527 62.62917,62.62917 0,34.5439 -28.08526,62.62917 -62.62917,62.62917 H 556.60029 V 405.67079 Z m 46.6783,375.77503 H 134.14696 c -46.580452,0 -59.693435,-62.62917 -35.228913,-62.62917 H 669.72424 c 24.46452,0 11.54725,62.62917 -35.13106,62.62917 z"
|
xml:space="preserve"
|
||||||
id="path949"
|
style="font-size:124.328px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.690712"
|
||||||
style="fill:#10163a;fill-opacity:1;stroke-width:0.978574"
|
x="196.89775"
|
||||||
transform="matrix(0.13087523,0,0,0.13087523,-5.3126573,6.9692701)" />
|
y="235.17853"
|
||||||
|
id="text852"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan850"
|
||||||
|
x="196.89775"
|
||||||
|
y="235.17853"
|
||||||
|
style="font-size:124.328px;fill:#10163a;fill-opacity:1;stroke-width:0.690712">Taskcafé</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:22.6594px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.596299"
|
||||||
|
x="223.15549"
|
||||||
|
y="261.83273"
|
||||||
|
id="text856"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan854"
|
||||||
|
x="223.15549"
|
||||||
|
y="261.83273"
|
||||||
|
style="font-size:22.6594px;fill:#262c49;fill-opacity:1;stroke-width:0.596299">An open source project management tool</tspan></text>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g4591"
|
||||||
|
transform="matrix(0.63214637,0,0,0.63214637,34.070751,71.061726)">
|
||||||
<g
|
<g
|
||||||
style="stroke:#10163a;stroke-width:0.999994;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
transform="matrix(1.9229347,0,0,1.9229347,9.8747364,70.748305)"
|
||||||
id="g1711"
|
id="g2427">
|
||||||
transform="translate(-0.91048867,42.172992)">
|
<path
|
||||||
|
id="path1636"
|
||||||
|
d="m 372.01783,403.60348 c -54.69036,0 -99.02539,44.33501 -99.02539,99.02537 0,54.69035 44.33503,99.02537 99.02539,99.02537 54.69036,0 99.02538,-44.33502 99.02538,-99.02537 0,-54.69036 -44.33502,-99.02538 -99.02538,-99.02537 z m 0,19.16619 c 44.13497,0 79.85917,35.71742 79.85917,79.85918 0,44.13497 -35.71741,79.85919 -79.85917,79.85919 -44.13497,0 -79.85918,-35.71742 -79.85918,-79.85919 0,-44.13498 35.71742,-79.85918 79.85918,-79.85918 m 55.98288,52.01507 -8.99853,-9.0712 c -1.86352,-1.87869 -4.89736,-1.89107 -6.77605,-0.0272 L 355.78487,521.674 331.91016,497.60565 c -1.86352,-1.87869 -4.89736,-1.89107 -6.77605,-0.0275 l -9.07159,8.99853 c -1.8787,1.86352 -1.89108,4.89736 -0.0272,6.77646 l 36.24849,36.54196 c 1.86352,1.87869 4.89736,1.89107 6.77605,0.0272 l 68.9141,-68.36106 c 1.87828,-1.86392 1.89025,-4.89778 0.0268,-6.77646 z"
|
||||||
|
style="fill:#7367f0;fill-opacity:1;stroke-width:0.399296"
|
||||||
|
transform="matrix(0.13087523,0,0,0.13087523,-5.3126573,6.9692701)" />
|
||||||
<g
|
<g
|
||||||
id="g2505"
|
id="g2513"
|
||||||
transform="translate(0.09191978,50.168306)">
|
transform="translate(-0.16733365,0.61658838)">
|
||||||
|
<path
|
||||||
|
transform="matrix(0.13087523,0,0,0.13087523,-5.3126573,6.9692701)"
|
||||||
|
style="fill:#10163a;fill-opacity:1;stroke-width:0.978574"
|
||||||
|
id="path949"
|
||||||
|
d="m 274.76901,687.50206 h 187.88752 c 51.86479,0 93.94376,-42.07897 93.94376,-93.94375 h 31.31459 c 69.08781,0 125.25834,-56.17055 125.25834,-125.25835 0,-69.08781 -56.17053,-125.25834 -125.25834,-125.25834 H 204.31119 c -13.01512,0 -23.48594,10.47081 -23.48594,23.48593 v 227.03076 c 0,51.86478 42.07898,93.94375 93.94376,93.94375 z M 587.91488,405.67079 c 34.54391,0 62.62917,28.08527 62.62917,62.62917 0,34.5439 -28.08526,62.62917 -62.62917,62.62917 H 556.60029 V 405.67079 Z m 46.6783,375.77503 H 134.14696 c -46.580452,0 -59.693435,-62.62917 -35.228913,-62.62917 H 669.72424 c 24.46452,0 11.54725,62.62917 -35.13106,62.62917 z" />
|
||||||
<g
|
<g
|
||||||
transform="translate(-0.68526563,40.225035)"
|
transform="translate(-0.91048867,42.172992)"
|
||||||
id="g2638">
|
id="g1711"
|
||||||
<path
|
style="stroke:#10163a;stroke-width:0.999994;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
|
||||||
style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
<g
|
||||||
d="m 34.769434,-84.879608 c -1.513055,-1.532226 -2.968787,-3.006403 -2.895671,-4.268256 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985679,-3.166058 1.805573,-4.837199 -1.180106,-1.671141 -4.987365,-3.242735 -4.929449,-5.195115 0.05792,-1.95238 3.981161,-4.28523 4.510279,-6.39714 0.529119,-2.11191 -2.336082,-4.003 -5.201646,-5.89433"
|
transform="translate(0.09191978,50.168306)"
|
||||||
id="path915"
|
id="g2505">
|
||||||
inkscape:original-d="m 34.769434,-84.879608 c -1.48426,-1.560661 -2.968784,-3.006405 -4.453574,-4.510003 1.631212,-1.019797 3.262093,-2.039821 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.54119 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.14299 -11.422003,-4.71488 3.923669,-2.33269 7.846916,-4.66554 11.769976,-6.99871 -2.86505,-1.8909 -5.730251,-3.78199 -8.595774,-5.67338"
|
<g
|
||||||
inkscape:path-effect="#path-effect917" />
|
id="g2638"
|
||||||
<path
|
transform="translate(-0.68526563,40.225035)">
|
||||||
inkscape:path-effect="#path-effect2614"
|
<path
|
||||||
inkscape:original-d="m 45.467515,-84.87961 c -1.48426,-1.56066 -2.968784,-3.006404 -4.453574,-4.510002 1.631212,-1.019797 3.262093,-2.039821 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.54119 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.142989 -11.422003,-4.714879 3.923669,-2.33269 7.846916,-4.66555 11.769976,-6.99871 -2.86505,-1.8909 -5.730251,-3.78199 -8.595774,-5.67338"
|
inkscape:path-effect="#path-effect917"
|
||||||
id="path915-9"
|
inkscape:original-d="m 34.769434,-84.879608 c -1.48426,-1.560661 -2.968784,-3.006405 -4.453574,-4.510003 1.631212,-1.019797 3.262093,-2.039821 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.54119 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.14299 -11.422003,-4.71488 3.923669,-2.33269 7.846916,-4.66554 11.769976,-6.99871 -2.86505,-1.8909 -5.730251,-3.78199 -8.595774,-5.67338"
|
||||||
d="m 45.467515,-84.87961 c -1.513055,-1.532226 -2.968786,-3.006402 -2.895671,-4.268255 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985679,-3.166058 1.805573,-4.837199 -1.180106,-1.671141 -4.987365,-3.242734 -4.929449,-5.195114 0.05792,-1.95238 3.981165,-4.28524 4.510282,-6.39714 0.529116,-2.11191 -2.336085,-4.003 -5.201649,-5.89433"
|
id="path915"
|
||||||
style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
d="m 34.769434,-84.879608 c -1.513055,-1.532226 -2.968787,-3.006403 -2.895671,-4.268256 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985679,-3.166058 1.805573,-4.837199 -1.180106,-1.671141 -4.987365,-3.242735 -4.929449,-5.195115 0.05792,-1.95238 3.981161,-4.28523 4.510279,-6.39714 0.529119,-2.11191 -2.336082,-4.003 -5.201646,-5.89433"
|
||||||
<path
|
style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||||
style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
<path
|
||||||
d="m 56.1656,-84.87961 c -1.513055,-1.532227 -2.968786,-3.006403 -2.895671,-4.268257 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985678,-3.166057 1.805572,-4.837198 -1.180105,-1.671141 -4.987363,-3.242733 -4.929448,-5.195113 0.05792,-1.95238 3.981162,-4.28524 4.510282,-6.39715 0.529119,-2.11191 -2.336084,-4.00299 -5.201649,-5.89432"
|
style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
id="path915-9-0"
|
d="m 45.467515,-84.87961 c -1.513055,-1.532226 -2.968786,-3.006402 -2.895671,-4.268255 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985679,-3.166058 1.805573,-4.837199 -1.180106,-1.671141 -4.987365,-3.242734 -4.929449,-5.195114 0.05792,-1.95238 3.981165,-4.28524 4.510282,-6.39714 0.529116,-2.11191 -2.336085,-4.003 -5.201649,-5.89433"
|
||||||
inkscape:original-d="m 56.1656,-84.87961 c -1.48426,-1.560662 -2.968784,-3.006405 -4.453574,-4.510004 1.631212,-1.019796 3.262093,-2.03982 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.541189 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.142987 -11.422003,-4.714877 3.923669,-2.33269 7.846916,-4.66555 11.769976,-6.99872 -2.86505,-1.89089 -5.730251,-3.78198 -8.595774,-5.67337"
|
id="path915-9"
|
||||||
inkscape:path-effect="#path-effect2633" />
|
inkscape:original-d="m 45.467515,-84.87961 c -1.48426,-1.56066 -2.968784,-3.006404 -4.453574,-4.510002 1.631212,-1.019797 3.262093,-2.039821 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.54119 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.142989 -11.422003,-4.714879 3.923669,-2.33269 7.846916,-4.66555 11.769976,-6.99871 -2.86505,-1.8909 -5.730251,-3.78199 -8.595774,-5.67338"
|
||||||
|
inkscape:path-effect="#path-effect2614" />
|
||||||
|
<path
|
||||||
|
inkscape:path-effect="#path-effect2633"
|
||||||
|
inkscape:original-d="m 56.1656,-84.87961 c -1.48426,-1.560662 -2.968784,-3.006405 -4.453574,-4.510004 1.631212,-1.019796 3.262093,-2.03982 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.541189 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.142987 -11.422003,-4.714877 3.923669,-2.33269 7.846916,-4.66555 11.769976,-6.99872 -2.86505,-1.89089 -5.730251,-3.78198 -8.595774,-5.67337"
|
||||||
|
id="path915-9-0"
|
||||||
|
d="m 56.1656,-84.87961 c -1.513055,-1.532227 -2.968786,-3.006403 -2.895671,-4.268257 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985678,-3.166057 1.805572,-4.837198 -1.180105,-1.671141 -4.987363,-3.242733 -4.929448,-5.195113 0.05792,-1.95238 3.981162,-4.28524 4.510282,-6.39715 0.529119,-2.11191 -2.336084,-4.00299 -5.201649,-5.89432"
|
||||||
|
style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
<path
|
||||||
|
id="path2782"
|
||||||
|
d="m 93.574758,186.094 c -13.47814,0 -24.40427,10.92613 -24.40427,24.40428 0,13.47814 10.92613,24.40427 24.40427,24.40427 13.478152,0 24.404282,-10.92613 24.404282,-24.40427 0,-13.47815 -10.92613,-24.40428 -24.404282,-24.40428 z m 0,4.72341 c 10.876832,0 19.680872,8.80236 19.680872,19.68087 0,10.87683 -8.80236,19.68086 -19.680872,19.68086 -10.876819,0 -19.680863,-8.80236 -19.680863,-19.68086 0,-10.87684 8.802364,-19.68087 19.680863,-19.68087 m 13.796692,12.81883 -2.21764,-2.23554 c -0.45925,-0.463 -1.20693,-0.46604 -1.66993,-0.007 l -13.909644,13.79786 -5.883794,-5.93153 c -0.459258,-0.46298 -1.206929,-0.46603 -1.669926,-0.007 l -2.235645,2.21764 c -0.462989,0.45926 -0.466044,1.20693 -0.0068,1.67002 l 8.933241,9.00557 c 0.459258,0.46299 1.206929,0.46605 1.669926,0.007 l 16.983502,-16.84721 c 0.4629,-0.45935 0.46585,-1.20702 0.007,-1.67002 z"
|
||||||
|
style="fill:#7367f0;fill-opacity:1;stroke-width:0.0984039" />
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
<g
|
|
||||||
id="g2560"
|
|
||||||
transform="translate(0,15.33842)">
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:124.328px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.690712"
|
|
||||||
x="196.89775"
|
|
||||||
y="235.17853"
|
|
||||||
id="text852"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan850"
|
|
||||||
x="196.89775"
|
|
||||||
y="235.17853"
|
|
||||||
style="font-size:124.328px;fill:#10163a;fill-opacity:1;stroke-width:0.690712">Taskcafé</tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:22.6594px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.596299"
|
|
||||||
x="223.15549"
|
|
||||||
y="261.83273"
|
|
||||||
id="text856"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan854"
|
|
||||||
x="223.15549"
|
|
||||||
y="261.83273"
|
|
||||||
style="font-size:22.6594px;fill:#262c49;fill-opacity:1;stroke-width:0.596299">An open source project management tool</tspan></text>
|
|
||||||
</g>
|
|
||||||
<path
|
|
||||||
style="fill:#7367f0;fill-opacity:1;stroke-width:0.0984039"
|
|
||||||
d="m 93.574758,186.094 c -13.47814,0 -24.40427,10.92613 -24.40427,24.40428 0,13.47814 10.92613,24.40427 24.40427,24.40427 13.478152,0 24.404282,-10.92613 24.404282,-24.40427 0,-13.47815 -10.92613,-24.40428 -24.404282,-24.40428 z m 0,4.72341 c 10.876832,0 19.680872,8.80236 19.680872,19.68087 0,10.87683 -8.80236,19.68086 -19.680872,19.68086 -10.876819,0 -19.680863,-8.80236 -19.680863,-19.68086 0,-10.87684 8.802364,-19.68087 19.680863,-19.68087 m 13.796692,12.81883 -2.21764,-2.23554 c -0.45925,-0.463 -1.20693,-0.46604 -1.66993,-0.007 l -13.909644,13.79786 -5.883794,-5.93153 c -0.459258,-0.46298 -1.206929,-0.46603 -1.669926,-0.007 l -2.235645,2.21764 c -0.462989,0.45926 -0.466044,1.20693 -0.0068,1.67002 l 8.933241,9.00557 c 0.459258,0.46299 1.206929,0.46605 1.669926,0.007 l 16.983502,-16.84721 c 0.4629,-0.45935 0.46585,-1.20702 0.007,-1.67002 z"
|
|
||||||
id="path2782" />
|
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
2
.gitignore
vendored
2
.gitignore
vendored
@ -6,3 +6,5 @@ internal/frontend/frontend_generated.go
|
|||||||
internal/migrations/migrations_generated.go
|
internal/migrations/migrations_generated.go
|
||||||
taskcafe
|
taskcafe
|
||||||
conf/taskcafe.toml
|
conf/taskcafe.toml
|
||||||
|
scripts/gqlgen
|
||||||
|
scripts/sqlc
|
||||||
|
@ -1,4 +1,12 @@
|
|||||||
repos:
|
repos:
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: eslint
|
||||||
|
name: eslint
|
||||||
|
entry: scripts/lint.sh
|
||||||
|
language: system
|
||||||
|
files: \.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx
|
||||||
|
types: [file]
|
||||||
- hooks:
|
- hooks:
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
name: citadel
|
name: taskcafe
|
||||||
root: .
|
root: .
|
||||||
|
|
||||||
on_project_start: docker start test-db
|
|
||||||
|
|
||||||
windows:
|
windows:
|
||||||
- services:
|
- services:
|
||||||
root: ./
|
root: ./
|
||||||
panes:
|
panes:
|
||||||
- api:
|
- api:
|
||||||
- go run cmd/citadel/main.go web
|
- go run cmd/taskcafe/main.go web
|
||||||
- yarn:
|
- yarn:
|
||||||
- cd frontend
|
- cd frontend
|
||||||
- yarn start
|
- yarn start
|
||||||
|
- worker:
|
||||||
|
- go run cmd/taskcafe/main.go worker
|
||||||
- web/editor:
|
- web/editor:
|
||||||
root: ./frontend
|
root: ./frontend
|
||||||
panes:
|
panes:
|
||||||
@ -19,8 +19,8 @@ windows:
|
|||||||
- api/editor:
|
- api/editor:
|
||||||
root: ./
|
root: ./
|
||||||
panes:
|
panes:
|
||||||
- vim cmd/citadel/main.go
|
- vim cmd/taskcafe/main.go
|
||||||
- database:
|
- database:
|
||||||
root: ./
|
root: ./
|
||||||
panes:
|
panes:
|
||||||
- pgcli postgres://postgres:test@localhost:5432/citadel
|
- pgcli postgres://taskcafe:taskcafe_test@localhost:8855/taskcafe
|
||||||
|
36
CHANGELOG.md
Normal file
36
CHANGELOG.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Changelog
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## UNRELEASED
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- On login page, redirects to `/register` if no users exist (to help streamline initial setup)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixes new user popup form so that it can now be submitted
|
||||||
|
|
||||||
|
## [0.3.5] - 2021-09-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Project visibility can now be set to public - meaning anyone can view the project board
|
||||||
|
- When redirected to login page while trying to view a page that requires login, you'll be redirected back to the correct page after login
|
||||||
|
- When creating a new label within the LabelManager on a card, the new label will automatically be applied to the task after creation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Switch primary font to Open Sans
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Any open popups are hidden when closing the Task Details window
|
||||||
|
|
||||||
|
## [0.1.1] - 2020-08-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- fix panic(nil) when loading config if config file actually exists
|
||||||
|
|
||||||
|
## [0.1.0] - 2020-08-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- first "stable" alpha release
|
@ -4,8 +4,15 @@ Thanks for wanting to contribute to Taskcafe!
|
|||||||
|
|
||||||
### Where do I go from here?
|
### Where do I go from here?
|
||||||
|
|
||||||
If you have noticed a bug or have a feature request, make one! If best to get confirmation
|
So you want to contribute to Taskcafe? Great!
|
||||||
of your bug or feature before starting work on a pull request.
|
|
||||||
|
If you have noticed a bug, please [create an issue](https://github.com/JordanKnott/taskcafe/issues/new/choose) for it before starting any work on a pull request.
|
||||||
|
|
||||||
|
If there is a [new feature you'd like added](https://github.com/JordanKnott/taskcafe/discussions/new?category=ideas) or [have a question](https://github.com/JordanKnott/taskcafe/discussions/new?category=q-a), please visit the [discussions section](https://github.com/JordanKnott/taskcafe/discussions)
|
||||||
|
|
||||||
|
Alternatively you can join the [Taskcafe discord](https://discord.gg/JkQDruh) and ask in the #questions channel.
|
||||||
|
|
||||||
|
After the bug is validated or the feature is accepted by a project maintainer, the next step is to fork the repository!
|
||||||
|
|
||||||
### Fork & create a branch
|
### Fork & create a branch
|
||||||
|
|
||||||
@ -27,6 +34,10 @@ The `description` is a decriptive summary of the change the PR will make.
|
|||||||
- One PR per fix or feature
|
- One PR per fix or feature
|
||||||
- Setup & install [pre-commit hooks](https://pre-commit.com/#install) then install the hooks `pre-commit install && pre-commit install --hook-type commit-msg`
|
- Setup & install [pre-commit hooks](https://pre-commit.com/#install) then install the hooks `pre-commit install && pre-commit install --hook-type commit-msg`
|
||||||
|
|
||||||
|
### Unwanted PRs
|
||||||
|
|
||||||
|
- Please do not submit pull requests containing only typo fixes, fixed spelling mistakes, or minor wording changes.
|
||||||
|
|
||||||
### Git Commit Message Style
|
### Git Commit Message Style
|
||||||
|
|
||||||
This project uses the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format.
|
This project uses the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format.
|
||||||
|
12
Pipfile
12
Pipfile
@ -1,12 +0,0 @@
|
|||||||
[[source]]
|
|
||||||
name = "pypi"
|
|
||||||
url = "https://pypi.org/simple"
|
|
||||||
verify_ssl = true
|
|
||||||
|
|
||||||
[dev-packages]
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
pre-commit = "*"
|
|
||||||
|
|
||||||
[requires]
|
|
||||||
python_version = "3.8"
|
|
111
Pipfile.lock
generated
111
Pipfile.lock
generated
@ -1,111 +0,0 @@
|
|||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"hash": {
|
|
||||||
"sha256": "83ec7c0175ee9763b335b1855d3d226b2fe799fcd4cafd8e08eb7294cb5ddd07"
|
|
||||||
},
|
|
||||||
"pipfile-spec": 6,
|
|
||||||
"requires": {
|
|
||||||
"python_version": "3.8"
|
|
||||||
},
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"name": "pypi",
|
|
||||||
"url": "https://pypi.org/simple",
|
|
||||||
"verify_ssl": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"default": {
|
|
||||||
"appdirs": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
|
|
||||||
"sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
|
|
||||||
],
|
|
||||||
"version": "==1.4.4"
|
|
||||||
},
|
|
||||||
"cfgv": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d",
|
|
||||||
"sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"
|
|
||||||
],
|
|
||||||
"markers": "python_full_version >= '3.6.1'",
|
|
||||||
"version": "==3.2.0"
|
|
||||||
},
|
|
||||||
"distlib": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb",
|
|
||||||
"sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"
|
|
||||||
],
|
|
||||||
"version": "==0.3.1"
|
|
||||||
},
|
|
||||||
"filelock": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
|
|
||||||
"sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"
|
|
||||||
],
|
|
||||||
"version": "==3.0.12"
|
|
||||||
},
|
|
||||||
"identify": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:69c4769f085badafd0e04b1763e847258cbbf6d898e8678ebffc91abdb86f6c6",
|
|
||||||
"sha256:d6ae6daee50ba1b493e9ca4d36a5edd55905d2cf43548fdc20b2a14edef102e7"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
|
||||||
"version": "==1.4.28"
|
|
||||||
},
|
|
||||||
"nodeenv": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc"
|
|
||||||
],
|
|
||||||
"version": "==1.4.0"
|
|
||||||
},
|
|
||||||
"pre-commit": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1657663fdd63a321a4a739915d7d03baedd555b25054449090f97bb0cb30a915",
|
|
||||||
"sha256:e8b1315c585052e729ab7e99dcca5698266bedce9067d21dc909c23e3ceed626"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.6.0"
|
|
||||||
},
|
|
||||||
"pyyaml": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
|
|
||||||
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
|
|
||||||
"sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
|
|
||||||
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
|
|
||||||
"sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
|
|
||||||
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
|
|
||||||
"sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
|
|
||||||
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
|
|
||||||
"sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
|
|
||||||
"sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
|
|
||||||
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
|
|
||||||
],
|
|
||||||
"version": "==5.3.1"
|
|
||||||
},
|
|
||||||
"six": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
|
||||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
|
|
||||||
"version": "==1.15.0"
|
|
||||||
},
|
|
||||||
"toml": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
|
|
||||||
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
|
|
||||||
],
|
|
||||||
"version": "==0.10.1"
|
|
||||||
},
|
|
||||||
"virtualenv": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc",
|
|
||||||
"sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
|
||||||
"version": "==20.0.31"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"develop": {}
|
|
||||||
}
|
|
75
README.md
75
README.md
@ -1,32 +1,53 @@
|
|||||||

|
<p align="center">
|
||||||
|
<img width="450px" src="./.github/taskcafe-full.png" align="center" alt="Taskcafe logo" />
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://discord.gg/JkQDruh">
|
||||||
|
<img alt="Discord" src="https://img.shields.io/discord/745396499613220955" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/JordanKnott/taskcafe/releases">
|
||||||
|
<img alt="Releases" src="https://img.shields.io/github/v/release/JordanKnott/taskcafe" />
|
||||||
|
</a>
|
||||||
|
<a href="https://hub.docker.com/repository/docker/taskcafe/taskcafe">
|
||||||
|
<img alt="Dockerhub" src="https://img.shields.io/docker/v/taskcafe/taskcafe?label=docker&sort=semver" />
|
||||||
|
</a>
|
||||||
|
<a href="https://goreportcard.com/report/github.com/JordanKnott/taskcafe">
|
||||||
|
<img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/JordanKnott/taskcafe" />
|
||||||
|
</a>
|
||||||
|
<a href="">
|
||||||
|
<img alt="Docker pulls" src="https://img.shields.io/docker/pulls/taskcafe/taskcafe" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
[](https://discord.gg/JkQDruh)
|
<p align="center">
|
||||||
[](https://github.com/JordanKnott/taskcafe/releases)
|
<a href="https://github.com/JordanKnott/taskcafe/issues/new?assignees=&labels=&template=bug_report.md&title=">Report Bug</a>
|
||||||
[](https://hub.docker.com/repository/docker/taskcafe/taskcafe)
|
·
|
||||||
[](https://goreportcard.com/report/github.com/JordanKnott/taskcafe)
|
<a href="https://github.com/JordanKnott/taskcafe/discussions/new?category=ideas">Request Feature</a>
|
||||||
|
·
|
||||||
|
<a href="https://github.com/JordanKnott/taskcafe/discussions/new?category=q-a">Ask a Question</a>
|
||||||
## Overview
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
Was this project useful? Please consider <a href="https://www.buymeacoffee.com/jordanknott">donating</a> to help me improve it!
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
This project is still in <strong>alpha development</strong></p>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
A free & open source alternative project management tool.
|
|
||||||
|
|
||||||
**Please note that this project is still in active development. Some options may not work yet!**
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
Currently Taskcafe only offers basic task tracking through a Kanban board.
|
The following features have been implemented:
|
||||||
|
|
||||||
Currently you can do the following to tasks:
|
- Manage tasks through a Kanban board interface (set due dates, labels, add checklists)
|
||||||
|
- View all your current assigned tasks through the My Tasks view
|
||||||
|
- Personal projects
|
||||||
|
- Task comments and activity
|
||||||
|
|
||||||
- Add colors & named labels
|
This project is still in active development, so some options may not be fully implemented yet.
|
||||||
- Add due dates
|
|
||||||
- Descriptions written in Markdown
|
|
||||||
- Assign members
|
|
||||||
- Checklists
|
|
||||||
- Mark tasks as complete
|
|
||||||
|
|
||||||
For a list of planned features, check out the [roadmap](https://github.com/JordanKnott/taskcafe/wiki/Roadmap)!
|
**For updates on development, join the [Discord server](https://discord.gg/JkQDruh).**
|
||||||
|
|
||||||
|
For a list of planned features, check out the [Roadmap](https://github.com/JordanKnott/taskcafe/wiki/Roadmap)!
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@ -44,7 +65,6 @@ Now do the following:
|
|||||||
|
|
||||||
``` bash
|
``` bash
|
||||||
docker-compose -p taskcafe up -d
|
docker-compose -p taskcafe up -d
|
||||||
docker-compose -p taskcafe -f docker-compose.yml -f docker-compose.migrate.yml run --rm migrate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start a postgres instance as well as a taskcafe instance.
|
This will start a postgres instance as well as a taskcafe instance.
|
||||||
@ -80,25 +100,28 @@ This will:
|
|||||||
|
|
||||||
The newly created `taskcafe` binary can be found in the __dist__ folder.
|
The newly created `taskcafe` binary can be found in the __dist__ folder.
|
||||||
|
|
||||||
It contains everything neccessary to run except the config file. An example config file can be found in `conf/app.example.toml`
|
It contains everything neccessary to run except the config file. An example config file can be found in `conf/app.example.toml`.
|
||||||
|
|
||||||
|
For more information on configuration, please read the [wiki](https://github.com/JordanKnott/taskcafe/wiki/Configuration).
|
||||||
The config will need to be copied to a `conf/app.toml` in the same place the binary is.
|
The config will need to be copied to a `conf/app.toml` in the same place the binary is.
|
||||||
|
|
||||||
Make sure to fill out the database section of the config in order to connect it to your database.
|
Make sure to fill out the database section of the config in order to connect it to your database.
|
||||||
|
|
||||||
Then run the database migrations with `taskcafe migrate`.
|
Then run the database migrations with `taskcafe migrate`.
|
||||||
|
|
||||||
Now you can run the web interface by running `taskcafe web`
|
Now you can run the web interface by running `taskcafe web`.
|
||||||
|
|
||||||
|
[A more detailed guide for installing on Ubuntu/Debian](https://github.com/JordanKnott/taskcafe/wiki/Installation-(ubuntu-debian))
|
||||||
|
|
||||||
## How is this different from X (Trello, NextCloud, etc)?
|
## How is this different from X (Trello, NextCloud, etc)?
|
||||||
|
|
||||||
One of the primary goal's of Taskcafe is to provide a project management tool that I personally enjoy using for my
|
One of the primary goals of Taskcafe is to provide a project management tool that I personally enjoy using for my
|
||||||
own projects and fits my workflow.
|
own projects and fits my workflow.
|
||||||
|
|
||||||
During alpha developement, the current plan is to build the "basic" features - features that are pretty much
|
During alpha development, the current plan is to build the "basic" features - features that are pretty much
|
||||||
standard across all kanban boards / project management tools.
|
standard across all kanban boards / project management tools.
|
||||||
|
|
||||||
Once Taskcafe is out of alpha, there are many features that I plan on adding that will differentiate it from other products (checkout the Roadmap for ideas on future plans).
|
Once Taskcafe is out of alpha, there are many features that I plan on adding that will differentiate it from other products (check out the [Roadmap](https://github.com/JordanKnott/taskcafe/wiki/Roadmap) for ideas on future plans).
|
||||||
|
|
||||||
## Contributing & community
|
## Contributing & community
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
//go:build ignore
|
||||||
// +build ignore
|
// +build ignore
|
||||||
|
|
||||||
package main
|
package main
|
||||||
@ -8,4 +9,6 @@ import (
|
|||||||
"github.com/magefile/mage/mage"
|
"github.com/magefile/mage/mage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() { os.Exit(mage.Main()) }
|
func main() {
|
||||||
|
os.Exit(mage.Main())
|
||||||
|
}
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/jordanknott/taskcafe/internal/commands"
|
"github.com/jordanknott/taskcafe/internal/command"
|
||||||
_ "github.com/lib/pq"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
commands.Execute()
|
command.Execute()
|
||||||
}
|
}
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
[general]
|
|
||||||
host = '0.0.0.0:3333'
|
|
||||||
|
|
||||||
[email_notifications]
|
|
||||||
enabled = true
|
|
||||||
display_name = "No Reply"
|
|
||||||
from_address = "example.com"
|
|
||||||
|
|
||||||
[storage]
|
|
||||||
storage_system = 'local_storage'
|
|
||||||
upload_dir_path = 'uploads'
|
|
||||||
|
|
||||||
[database]
|
|
||||||
host = 'postgres'
|
|
||||||
name = 'taskcafe'
|
|
||||||
user = 'taskcafe'
|
|
||||||
password = 'taskcafe_test'
|
|
||||||
|
|
||||||
[smtp]
|
|
||||||
username = 'admin@example.com'
|
|
||||||
password = 'example'
|
|
||||||
server = 'mail.example.com'
|
|
||||||
port = 465
|
|
||||||
connection_security = 'STARTTLS'
|
|
@ -2,39 +2,30 @@ version: "3"
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:12.3-alpine
|
image: postgres:12.3-alpine
|
||||||
restart: always
|
|
||||||
networks:
|
networks:
|
||||||
- taskcafe-test
|
- taskcafe-dev
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: taskcafe
|
POSTGRES_USER: taskcafe
|
||||||
POSTGRES_PASSWORD: taskcafe_test
|
POSTGRES_PASSWORD: taskcafe_dev
|
||||||
POSTGRES_DB: taskcafe
|
POSTGRES_DB: taskcafe
|
||||||
volumes:
|
volumes:
|
||||||
- taskcafe-postgres:/var/lib/postgresql/data
|
- taskcafe-dev-postgres:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5433:5432
|
||||||
mailhog:
|
mailhog:
|
||||||
image: mailhog/mailhog:latest
|
image: mailhog/mailhog:latest
|
||||||
restart: always
|
|
||||||
ports:
|
ports:
|
||||||
- 1025:1025
|
- 1026:1025
|
||||||
- 8025:8025
|
- 8026:8025
|
||||||
broker:
|
redis:
|
||||||
image: rabbitmq:3-management
|
image: redis:6.2
|
||||||
restart: always
|
|
||||||
ports:
|
ports:
|
||||||
- 8060:15672
|
- 6380:6379
|
||||||
- 5672:5672
|
|
||||||
result_store:
|
|
||||||
image: memcached:1.6-alpine
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- 11211:11211
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
taskcafe-postgres:
|
taskcafe-dev-postgres:
|
||||||
external: false
|
external: false
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
taskcafe-test:
|
taskcafe-dev:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
|
||||||
migrate:
|
|
||||||
build: .
|
|
||||||
entrypoint: ./taskcafe migrate
|
|
||||||
volumes:
|
|
||||||
- ./migrations:/root/migrations
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
networks:
|
|
||||||
- taskcafe-test
|
|
@ -1,32 +0,0 @@
|
|||||||
version: "3"
|
|
||||||
services:
|
|
||||||
web:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "3333:3333"
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
networks:
|
|
||||||
- taskcafe-test
|
|
||||||
environment:
|
|
||||||
TASKCAFE_DATABASE_HOST: postgres
|
|
||||||
TASKCAFE_MIGRATE: "true"
|
|
||||||
postgres:
|
|
||||||
image: postgres:12.3-alpine
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- taskcafe-test
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: taskcafe
|
|
||||||
POSTGRES_PASSWORD: taskcafe_test
|
|
||||||
POSTGRES_DB: taskcafe
|
|
||||||
volumes:
|
|
||||||
- taskcafe-postgres:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
taskcafe-postgres:
|
|
||||||
external: false
|
|
||||||
|
|
||||||
networks:
|
|
||||||
taskcafe-test:
|
|
||||||
driver: bridge
|
|
@ -1,13 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
end_of_line = lf
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
charset = utf-8
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
insert_final_newline = true
|
|
||||||
|
|
||||||
[Makefile]
|
|
||||||
indent_style = tab
|
|
||||||
indent_size = 2
|
|
@ -1,3 +0,0 @@
|
|||||||
src/shared/generated/*.tsx
|
|
||||||
src/shared/generated/*.ts
|
|
||||||
src/react-app-env.d.ts
|
|
@ -21,16 +21,28 @@
|
|||||||
"airbnb",
|
"airbnb",
|
||||||
"plugin:prettier/recommended",
|
"plugin:prettier/recommended",
|
||||||
"plugin:@typescript-eslint/eslint-recommended",
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
"plugin:@typescript-eslint/recommended"
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:storybook/recommended"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"prettier/prettier": "error",
|
"prettier/prettier": "warn",
|
||||||
|
"no-shadow": "off",
|
||||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||||
"@typescript-eslint/explicit-function-return-type": "off",
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
"react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }],
|
"react/jsx-filename-extension": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"extensions": [".js", ".jsx", ".ts", ".tsx"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/require-default-props": "off",
|
||||||
|
"no-case-declarations": "off",
|
||||||
|
"no-plusplus": "off",
|
||||||
"react/prop-types": 0,
|
"react/prop-types": 0,
|
||||||
|
"react/no-unused-prop-types": "off",
|
||||||
|
"no-continue": "off",
|
||||||
"react/jsx-props-no-spreading": "off",
|
"react/jsx-props-no-spreading": "off",
|
||||||
"no-param-reassign": "off",
|
"no-param-reassign": "off",
|
||||||
"import/extensions": [
|
"import/extensions": [
|
||||||
@ -44,10 +56,15 @@
|
|||||||
"tsx": "never"
|
"tsx": "never"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"import/prefer-default-export": "off",
|
||||||
|
"no-use-before-define": "off",
|
||||||
|
"react/no-unescaped-entities": "off",
|
||||||
|
"import/no-unresolved": "off",
|
||||||
|
"@typescript-eslint/no-use-before-define": ["error"],
|
||||||
"import/no-extraneous-dependencies": [
|
"import/no-extraneous-dependencies": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"devDependencies": [".storybook/**", "src/shared/components/**/*.stories.tsx"]
|
"devDependencies": ["src/stories/**", "src/shared/components/**/*.stories.tsx", "**/*.stories.tsx"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@ -18,7 +18,6 @@
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
report*
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
@ -1,18 +1,15 @@
|
|||||||
const path = require('path');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
stories: ['../src/shared/components/**/*.stories.tsx'],
|
"stories": [
|
||||||
addons: [
|
"../src/**/*.stories.mdx",
|
||||||
'@storybook/addon-actions/register',
|
"../src/**/*.stories.@(js|jsx|ts|tsx)"
|
||||||
'@storybook/addon-links',
|
|
||||||
'@storybook/addon-storysource',
|
|
||||||
'@storybook/addon-knobs/register',
|
|
||||||
'@storybook/addon-docs/register',
|
|
||||||
'@storybook/addon-viewport/register',
|
|
||||||
'@storybook/addon-backgrounds/register',
|
|
||||||
],
|
],
|
||||||
webpackFinal: async config => {
|
"addons": [
|
||||||
config.resolve.modules.push(path.resolve(__dirname, '../src'));
|
"@storybook/addon-links",
|
||||||
return config;
|
"@storybook/addon-essentials",
|
||||||
},
|
"@storybook/preset-create-react-app"
|
||||||
};
|
],
|
||||||
|
"framework": "@storybook/react",
|
||||||
|
"core": {
|
||||||
|
"builder": "webpack5"
|
||||||
|
}
|
||||||
|
}
|
6
frontend/.storybook/preview-head.html
Normal file
6
frontend/.storybook/preview-head.html
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Open Sans';
|
||||||
|
src: url(/assets/fonts/OpenSans-Regular.ttf) format('truetype');
|
||||||
|
}
|
||||||
|
</style>
|
47
frontend/.storybook/preview.js
Normal file
47
frontend/.storybook/preview.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import BaseStyles from 'app/BaseStyles';
|
||||||
|
import NormalizeStyles from 'app/NormalizeStyles';
|
||||||
|
import themes from 'app/ThemeStyles';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from 'styled-components';
|
||||||
|
|
||||||
|
export const parameters = {
|
||||||
|
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const globalTypes = {
|
||||||
|
theme: {
|
||||||
|
name: 'Theme',
|
||||||
|
description: 'Global theme for components',
|
||||||
|
defaultValue: 'darkTheme',
|
||||||
|
toolbar: {
|
||||||
|
icon: 'circlehollow',
|
||||||
|
// Array of plain string values or MenuItem shape (see below)
|
||||||
|
items: ['lightTheme', 'darkTheme'],
|
||||||
|
// Property that specifies if the name of the item will be displayed
|
||||||
|
showName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decorators = [
|
||||||
|
(Story, context) => {
|
||||||
|
const theme = themes[context.globals.theme];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<BaseStyles />
|
||||||
|
<NormalizeStyles />
|
||||||
|
<MemoryRouter>
|
||||||
|
<Story />
|
||||||
|
</MemoryRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
];
|
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
start:
|
|
||||||
yarn start
|
|
46
frontend/README.md
Normal file
46
frontend/README.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Getting Started with Create React App
|
||||||
|
|
||||||
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm start`
|
||||||
|
|
||||||
|
Runs the app in the development mode.\
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
|
|
||||||
|
The page will reload if you make edits.\
|
||||||
|
You will also see any lint errors in the console.
|
||||||
|
|
||||||
|
### `npm test`
|
||||||
|
|
||||||
|
Launches the test runner in the interactive watch mode.\
|
||||||
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `build` folder.\
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.\
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||||
|
|
||||||
|
### `npm run eject`
|
||||||
|
|
||||||
|
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||||
|
|
||||||
|
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
|
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||||
|
|
||||||
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||||
|
|
||||||
|
To learn React, check out the [React documentation](https://reactjs.org/).
|
@ -1,18 +0,0 @@
|
|||||||
overwrite: true
|
|
||||||
schema:
|
|
||||||
- '../internal/graph/schema.graphqls'
|
|
||||||
documents:
|
|
||||||
- 'src/shared/graphql/*.graphqls'
|
|
||||||
- 'src/shared/graphql/**/*.ts'
|
|
||||||
generates:
|
|
||||||
src/shared/generated/graphql.tsx:
|
|
||||||
plugins:
|
|
||||||
- 'typescript'
|
|
||||||
- 'typescript-operations'
|
|
||||||
- 'typescript-react-apollo'
|
|
||||||
config:
|
|
||||||
withHOC: false
|
|
||||||
withComponent: false
|
|
||||||
withHooks: true
|
|
||||||
scalars:
|
|
||||||
UUID: string
|
|
@ -1,80 +1,44 @@
|
|||||||
{
|
{
|
||||||
"name": "app",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.0.0-rc.8",
|
"@testing-library/jest-dom": "^5.16.2",
|
||||||
"@apollo/react-common": "^3.1.4",
|
"@testing-library/react": "^12.1.2",
|
||||||
"@apollo/react-hooks": "^3.1.3",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.27",
|
"color": "^4.2.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^5.12.1",
|
"react": "^17.0.2",
|
||||||
"@fortawesome/free-regular-svg-icons": "^5.12.1",
|
"react-dom": "^17.0.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.12.1",
|
"react-hook-form": "^7.25.3",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.8",
|
"react-router": "^6.2.1",
|
||||||
"@testing-library/jest-dom": "^4.2.4",
|
"react-router-dom": "^6.2.1",
|
||||||
"@testing-library/react": "^9.3.2",
|
"react-scripts": "5.0.0",
|
||||||
"@testing-library/user-event": "^7.1.2",
|
"styled-components": "^5.3.3",
|
||||||
"@types/axios": "^0.14.0",
|
"typescript": "^4.5.5"
|
||||||
"@types/color": "^3.0.1",
|
|
||||||
"@types/date-fns": "^2.6.0",
|
|
||||||
"@types/jest": "^24.0.0",
|
|
||||||
"@types/jwt-decode": "^2.2.1",
|
|
||||||
"@types/lodash": "^4.14.149",
|
|
||||||
"@types/node": "^12.0.0",
|
|
||||||
"@types/react": "^16.9.21",
|
|
||||||
"@types/react-beautiful-dnd": "^12.1.1",
|
|
||||||
"@types/react-datepicker": "^2.11.0",
|
|
||||||
"@types/react-dom": "^16.9.5",
|
|
||||||
"@types/react-router": "^5.1.4",
|
|
||||||
"@types/react-router-dom": "^5.1.3",
|
|
||||||
"@types/react-select": "^3.0.13",
|
|
||||||
"@types/styled-components": "^5.0.0",
|
|
||||||
"apollo-cache-inmemory": "^1.6.5",
|
|
||||||
"apollo-client": "^2.6.8",
|
|
||||||
"apollo-link": "^1.2.13",
|
|
||||||
"apollo-link-error": "^1.1.12",
|
|
||||||
"apollo-link-http": "^1.5.16",
|
|
||||||
"apollo-link-state": "^0.4.2",
|
|
||||||
"apollo-utilities": "^1.3.3",
|
|
||||||
"axios": "^0.19.2",
|
|
||||||
"axios-auth-refresh": "^2.2.7",
|
|
||||||
"color": "^3.1.2",
|
|
||||||
"date-fns": "^2.14.0",
|
|
||||||
"graphql": "^15.0.0",
|
|
||||||
"graphql-tag": "^2.10.3",
|
|
||||||
"history": "^4.10.1",
|
|
||||||
"immer": "^6.0.3",
|
|
||||||
"jwt-decode": "^2.2.0",
|
|
||||||
"lodash": "^4.17.15",
|
|
||||||
"moment": "^2.24.0",
|
|
||||||
"prop-types": "^15.7.2",
|
|
||||||
"react": "^16.12.0",
|
|
||||||
"react-autosize-textarea": "^7.0.0",
|
|
||||||
"react-beautiful-dnd": "^13.0.0",
|
|
||||||
"react-datepicker": "^2.14.1",
|
|
||||||
"react-dom": "^16.12.0",
|
|
||||||
"react-hook-form": "^6.0.6",
|
|
||||||
"react-markdown": "^4.3.1",
|
|
||||||
"react-router": "^5.1.2",
|
|
||||||
"react-router-dom": "^5.1.2",
|
|
||||||
"react-scripts": "3.4.0",
|
|
||||||
"react-select": "^3.1.0",
|
|
||||||
"styled-components": "^5.0.1",
|
|
||||||
"typescript": "~3.7.2"
|
|
||||||
},
|
},
|
||||||
"proxy": "http://localhost:3333",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"storybook": "start-storybook -p 9009 -s public",
|
"storybook": "start-storybook -p 6006 -s public",
|
||||||
"build-storybook": "build-storybook -s public",
|
"build-storybook": "build-storybook -s public"
|
||||||
"generate": "graphql-codegen",
|
|
||||||
"lint": "eslint --ext js,ts,tsx src"
|
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": "react-app"
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/*.stories.*"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"import/no-anonymous-default-export": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
@ -89,30 +53,36 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "^1.13.2",
|
"@graphql-codegen/cli": "^1.21.4",
|
||||||
"@graphql-codegen/typescript": "^1.13.2",
|
"@graphql-codegen/typescript": "^1.22.0",
|
||||||
"@graphql-codegen/typescript-operations": "^1.13.2",
|
"@graphql-codegen/typescript-operations": "^1.17.16",
|
||||||
"@graphql-codegen/typescript-react-apollo": "^1.13.2",
|
"@graphql-codegen/typescript-react-apollo": "^2.2.4",
|
||||||
"@storybook/addon-actions": "^5.3.13",
|
"@storybook/addon-actions": "^6.4.16",
|
||||||
"@storybook/addon-links": "^5.3.13",
|
"@storybook/addon-essentials": "^6.4.16",
|
||||||
"@storybook/addon-backgrounds": "^5.3.17",
|
"@storybook/addon-links": "^6.4.16",
|
||||||
"@storybook/addon-docs": "^5.3.17",
|
"@storybook/builder-webpack5": "^6.4.16",
|
||||||
"@storybook/addon-knobs": "^5.3.17",
|
"@storybook/manager-webpack5": "^6.4.16",
|
||||||
"@storybook/addon-storysource": "^5.3.17",
|
"@storybook/node-logger": "^6.4.16",
|
||||||
"@storybook/addon-viewport": "^5.3.17",
|
"@storybook/preset-create-react-app": "^4.0.0",
|
||||||
"@storybook/addons": "^5.3.13",
|
"@storybook/react": "^6.4.16",
|
||||||
"@storybook/preset-create-react-app": "^1.5.2",
|
"@types/color": "^3.0.2",
|
||||||
"@storybook/react": "^5.3.13",
|
"@types/jest": "^27.4.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^2.20.0",
|
"@types/node": "^16.11.21",
|
||||||
"@typescript-eslint/parser": "^2.20.0",
|
"@types/react": "^17.0.38",
|
||||||
"eslint": "^6.8.0",
|
"@types/react-dom": "^17.0.11",
|
||||||
|
"@types/styled-components": "^5.1.21",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.22.0",
|
||||||
|
"@typescript-eslint/parser": "^4.22.0",
|
||||||
|
"eslint": "^7.25.0",
|
||||||
"eslint-config-airbnb": "^18.0.1",
|
"eslint-config-airbnb": "^18.0.1",
|
||||||
"eslint-config-prettier": "^6.10.0",
|
"eslint-config-prettier": "^8.3.0",
|
||||||
"eslint-plugin-import": "^2.20.1",
|
"eslint-plugin-import": "^2.25.4",
|
||||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||||
"eslint-plugin-prettier": "^3.1.2",
|
"eslint-plugin-prettier": "^3.4.0",
|
||||||
"eslint-plugin-react": "^7.18.3",
|
"eslint-plugin-react": "^7.23.2",
|
||||||
"eslint-plugin-react-hooks": "^1.7.0",
|
"eslint-plugin-react-hooks": "^4.2.0",
|
||||||
"prettier": "^1.19.1"
|
"eslint-plugin-storybook": "^0.5.6",
|
||||||
|
"prettier": "^2.2.1",
|
||||||
|
"webpack": "^5.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
frontend/public/assets/fonts/OpenSans-Regular.ttf
Normal file
BIN
frontend/public/assets/fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 3.8 KiB |
@ -2,19 +2,19 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Web site created using create-react-app"
|
content="Web site created using create-react-app"
|
||||||
/>
|
/>
|
||||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
<!--
|
<!--
|
||||||
manifest.json provides metadata used when your web app is installed on a
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
-->
|
-->
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
<!--
|
<!--
|
||||||
Notice the use of %PUBLIC_URL% in the tags above.
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
@ -24,7 +24,7 @@
|
|||||||
work correctly both with client-side routing and a non-root public URL.
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
-->
|
-->
|
||||||
<title>Taskcafé</title>
|
<title>React App</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
@ -1,243 +0,0 @@
|
|||||||
import React, { useEffect, useState, useContext } from 'react';
|
|
||||||
import Admin from 'shared/components/Admin';
|
|
||||||
import Select from 'shared/components/Select';
|
|
||||||
import GlobalTopNavbar from 'App/TopNavbar';
|
|
||||||
import {
|
|
||||||
useUsersQuery,
|
|
||||||
useDeleteUserAccountMutation,
|
|
||||||
useCreateUserAccountMutation,
|
|
||||||
UsersDocument,
|
|
||||||
UsersQuery,
|
|
||||||
} from 'shared/generated/graphql';
|
|
||||||
import Input from 'shared/components/Input';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import Button from 'shared/components/Button';
|
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
|
||||||
import produce from 'immer';
|
|
||||||
import updateApolloCache from 'shared/utils/cache';
|
|
||||||
import UserContext, { useCurrentUser } from 'App/context';
|
|
||||||
import { Redirect } from 'react-router';
|
|
||||||
|
|
||||||
const DeleteUserWrapper = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DeleteUserDescription = styled.p`
|
|
||||||
font-size: 14px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DeleteUserButton = styled(Button)`
|
|
||||||
margin-top: 6px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type DeleteUserPopupProps = {
|
|
||||||
onDeleteUser: () => void;
|
|
||||||
};
|
|
||||||
const DeleteUserPopup: React.FC<DeleteUserPopupProps> = ({ onDeleteUser }) => {
|
|
||||||
return (
|
|
||||||
<DeleteUserWrapper>
|
|
||||||
<DeleteUserDescription>Deleting this user will remove all user related data.</DeleteUserDescription>
|
|
||||||
<DeleteUserButton onClick={() => onDeleteUser()} color="danger">
|
|
||||||
Delete user
|
|
||||||
</DeleteUserButton>
|
|
||||||
</DeleteUserWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
type RoleCodeOption = {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
type CreateUserData = {
|
|
||||||
email: string;
|
|
||||||
username: string;
|
|
||||||
fullName: string;
|
|
||||||
initials: string;
|
|
||||||
password: string;
|
|
||||||
roleCode: RoleCodeOption;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CreateUserForm = styled.form`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 12px;
|
|
||||||
`;
|
|
||||||
const CreateUserButton = styled(Button)`
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AddUserInput = styled(Input)`
|
|
||||||
margin-bottom: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const InputError = styled.span`
|
|
||||||
color: rgba(${props => props.theme.colors.danger});
|
|
||||||
font-size: 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type AddUserPopupProps = {
|
|
||||||
onAddUser: (user: CreateUserData) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
|
|
||||||
const { register, handleSubmit, errors, setValue, control } = useForm<CreateUserData>();
|
|
||||||
|
|
||||||
const createUser = (data: CreateUserData) => {
|
|
||||||
onAddUser(data);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<CreateUserForm onSubmit={handleSubmit(createUser)}>
|
|
||||||
<AddUserInput
|
|
||||||
floatingLabel
|
|
||||||
width="100%"
|
|
||||||
label="Full Name"
|
|
||||||
id="fullName"
|
|
||||||
name="fullName"
|
|
||||||
variant="alternate"
|
|
||||||
ref={register({ required: 'Full name is required' })}
|
|
||||||
/>
|
|
||||||
{errors.fullName && <InputError>{errors.fullName.message}</InputError>}
|
|
||||||
<AddUserInput
|
|
||||||
floatingLabel
|
|
||||||
width="100%"
|
|
||||||
label="Email"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
variant="alternate"
|
|
||||||
ref={register({ required: 'Email is required' })}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="roleCode"
|
|
||||||
rules={{ required: 'Role is required' }}
|
|
||||||
render={({ onChange, onBlur, value }) => (
|
|
||||||
<Select
|
|
||||||
label="Role"
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
options={[
|
|
||||||
{ label: 'Admin', value: 'admin' },
|
|
||||||
{ label: 'Member', value: 'member' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{errors.roleCode && errors.roleCode.value && <InputError>{errors.roleCode.value.message}</InputError>}
|
|
||||||
<AddUserInput
|
|
||||||
floatingLabel
|
|
||||||
width="100%"
|
|
||||||
label="Username"
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
variant="alternate"
|
|
||||||
ref={register({ required: 'Username is required' })}
|
|
||||||
/>
|
|
||||||
{errors.username && <InputError>{errors.username.message}</InputError>}
|
|
||||||
<AddUserInput
|
|
||||||
floatingLabel
|
|
||||||
width="100%"
|
|
||||||
label="Initials"
|
|
||||||
id="initials"
|
|
||||||
name="initials"
|
|
||||||
variant="alternate"
|
|
||||||
ref={register({ required: 'Initials is required' })}
|
|
||||||
/>
|
|
||||||
{errors.initials && <InputError>{errors.initials.message}</InputError>}
|
|
||||||
<AddUserInput
|
|
||||||
floatingLabel
|
|
||||||
width="100%"
|
|
||||||
label="Password"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
variant="alternate"
|
|
||||||
type="password"
|
|
||||||
ref={register({ required: 'Password is required' })}
|
|
||||||
/>
|
|
||||||
{errors.password && <InputError>{errors.password.message}</InputError>}
|
|
||||||
<CreateUserButton type="submit">Create</CreateUserButton>
|
|
||||||
</CreateUserForm>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AdminRoute = () => {
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = 'Taskcafé | Admin';
|
|
||||||
}, []);
|
|
||||||
const { loading, data } = useUsersQuery();
|
|
||||||
const { showPopup, hidePopup } = usePopup();
|
|
||||||
const { user } = useCurrentUser();
|
|
||||||
const [deleteUser] = useDeleteUserAccountMutation({
|
|
||||||
update: (client, response) => {
|
|
||||||
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.users = cache.users.filter(u => u.id !== response.data.deleteUserAccount.userAccount.id);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [createUser] = useCreateUserAccountMutation({
|
|
||||||
update: (client, createData) => {
|
|
||||||
const cacheData: any = client.readQuery({
|
|
||||||
query: UsersDocument,
|
|
||||||
});
|
|
||||||
const newData = produce(cacheData, (draftState: any) => {
|
|
||||||
draftState.users = [...draftState.users, { ...createData.data.createUserAccount }];
|
|
||||||
});
|
|
||||||
|
|
||||||
client.writeQuery({
|
|
||||||
query: UsersDocument,
|
|
||||||
data: {
|
|
||||||
...newData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (loading) {
|
|
||||||
return <GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />;
|
|
||||||
}
|
|
||||||
if (data && user) {
|
|
||||||
if (user.roles.org != 'admin') {
|
|
||||||
return <Redirect to="/" />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />
|
|
||||||
<Admin
|
|
||||||
initialTab={0}
|
|
||||||
users={data.users}
|
|
||||||
canInviteUser={user.roles.org == 'admin'}
|
|
||||||
onInviteUser={() => {}}
|
|
||||||
onUpdateUserPassword={(user, password) => {
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
onDeleteUser={(userID, newOwnerID) => {
|
|
||||||
deleteUser({ variables: { userID, newOwnerID } });
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
onAddUser={$target => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<Popup tab={0} title="Add member" onClose={() => hidePopup()}>
|
|
||||||
<AddUserPopup
|
|
||||||
onAddUser={user => {
|
|
||||||
const { roleCode, ...userData } = user;
|
|
||||||
createUser({ variables: { ...userData, roleCode: roleCode.value } });
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span>error</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminRoute;
|
|
@ -1,42 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {Router, Switch, Route} from 'react-router-dom';
|
|
||||||
import * as H from 'history';
|
|
||||||
|
|
||||||
import Dashboard from 'Dashboard';
|
|
||||||
import Admin from 'Admin';
|
|
||||||
import Projects from 'Projects';
|
|
||||||
import Project from 'Projects/Project';
|
|
||||||
import Teams from 'Teams';
|
|
||||||
import Login from 'Auth';
|
|
||||||
import Install from 'Install';
|
|
||||||
import Profile from 'Profile';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
const MainContent = styled.div`
|
|
||||||
padding: 0 0 0 0;
|
|
||||||
background: #262c49;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
`;
|
|
||||||
type RoutesProps = {
|
|
||||||
history: H.History;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Routes = ({history}: RoutesProps) => (
|
|
||||||
<Switch>
|
|
||||||
<Route exact path="/login" component={Login} />
|
|
||||||
<Route exact path="/install" component={Install} />
|
|
||||||
<MainContent>
|
|
||||||
<Route exact path="/" component={Dashboard} />
|
|
||||||
<Route exact path="/projects" component={Projects} />
|
|
||||||
<Route path="/projects/:projectID" component={Project} />
|
|
||||||
<Route path="/teams/:teamID" component={Teams} />
|
|
||||||
<Route path="/profile" component={Profile} />
|
|
||||||
<Route path="/admin" component={Admin} />
|
|
||||||
</MainContent>
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Routes;
|
|
@ -1,28 +0,0 @@
|
|||||||
import { createGlobalStyle, DefaultTheme } from 'styled-components';
|
|
||||||
|
|
||||||
const theme: DefaultTheme = {
|
|
||||||
borderRadius: {
|
|
||||||
primary: '3px',
|
|
||||||
alternate: '6px',
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
primary: '115, 103, 240',
|
|
||||||
secondary: '216, 93, 216',
|
|
||||||
alternate: '65, 69, 97',
|
|
||||||
success: '40, 199, 111',
|
|
||||||
danger: '234, 84, 85',
|
|
||||||
warning: '255, 159, 67',
|
|
||||||
dark: '30, 30, 30',
|
|
||||||
text: {
|
|
||||||
primary: '194, 198, 220',
|
|
||||||
secondary: '255, 255, 255',
|
|
||||||
},
|
|
||||||
border: '65, 69, 97',
|
|
||||||
bg: {
|
|
||||||
primary: '16, 22, 58',
|
|
||||||
secondary: '38, 44, 73',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export { theme };
|
|
@ -1,385 +0,0 @@
|
|||||||
import React, { useState, useContext, useEffect } from 'react';
|
|
||||||
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
|
|
||||||
import styled from 'styled-components/macro';
|
|
||||||
import DropdownMenu, { ProfileMenu } from 'shared/components/DropdownMenu';
|
|
||||||
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
|
|
||||||
import { useHistory } from 'react-router';
|
|
||||||
import { UserContext, PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
|
|
||||||
import {
|
|
||||||
RoleCode,
|
|
||||||
useMeQuery,
|
|
||||||
useDeleteProjectMutation,
|
|
||||||
useGetProjectsQuery,
|
|
||||||
GetProjectsDocument,
|
|
||||||
} from 'shared/generated/graphql';
|
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
|
||||||
import { History } from 'history';
|
|
||||||
import produce from 'immer';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import MiniProfile from 'shared/components/MiniProfile';
|
|
||||||
import cache from 'App/cache';
|
|
||||||
|
|
||||||
const TeamContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TeamTitle = styled.h3`
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TeamProjects = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TeamProjectLink = styled(Link)`
|
|
||||||
display: flex;
|
|
||||||
font-weight: 700;
|
|
||||||
height: 36px;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0;
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
user-select: none;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TeamProjectBackground = styled.div<{ color: string }>`
|
|
||||||
background-image: url(null);
|
|
||||||
background-color: ${props => props.color};
|
|
||||||
|
|
||||||
background-size: cover;
|
|
||||||
background-position: 50%;
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 36px;
|
|
||||||
opacity: 1;
|
|
||||||
border-radius: 3px;
|
|
||||||
&:before {
|
|
||||||
background: rgba(${props => props.theme.colors.bg.secondary});
|
|
||||||
bottom: 0;
|
|
||||||
content: '';
|
|
||||||
left: 0;
|
|
||||||
opacity: 0.88;
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TeamProjectAvatar = styled.div<{ color: string }>`
|
|
||||||
background-image: url(null);
|
|
||||||
background-color: ${props => props.color};
|
|
||||||
|
|
||||||
display: inline-block;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background-size: cover;
|
|
||||||
border-radius: 3px 0 0 3px;
|
|
||||||
height: 36px;
|
|
||||||
width: 36px;
|
|
||||||
position: relative;
|
|
||||||
opacity: 0.7;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TeamProjectContent = styled.div`
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 0 9px 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TeamProjectTitle = styled.div`
|
|
||||||
font-weight: 700;
|
|
||||||
display: block;
|
|
||||||
padding-right: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TeamProjectContainer = styled.div`
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: 3px;
|
|
||||||
position: relative;
|
|
||||||
margin: 0 4px 4px 0;
|
|
||||||
min-width: 0;
|
|
||||||
&:hover ${TeamProjectTitle} {
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary});
|
|
||||||
}
|
|
||||||
&:hover ${TeamProjectAvatar} {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
&:hover ${TeamProjectBackground}:before {
|
|
||||||
opacity: 0.78;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
|
|
||||||
|
|
||||||
const ProjectFinder = () => {
|
|
||||||
const { loading, data } = useGetProjectsQuery();
|
|
||||||
if (loading) {
|
|
||||||
return <span>loading</span>;
|
|
||||||
}
|
|
||||||
if (data) {
|
|
||||||
const { projects, teams, organizations } = data;
|
|
||||||
const projectTeams = teams.map(team => {
|
|
||||||
return {
|
|
||||||
id: team.id,
|
|
||||||
name: team.name,
|
|
||||||
projects: projects.filter(project => project.team.id === team.id),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{projectTeams.map(team => (
|
|
||||||
<TeamContainer key={team.id}>
|
|
||||||
<TeamTitle>{team.name}</TeamTitle>
|
|
||||||
<TeamProjects>
|
|
||||||
{team.projects.map((project, idx) => (
|
|
||||||
<TeamProjectContainer key={project.id}>
|
|
||||||
<TeamProjectLink to={`/projects/${project.id}`}>
|
|
||||||
<TeamProjectBackground color={colors[idx % 5]} />
|
|
||||||
<TeamProjectAvatar color={colors[idx % 5]} />
|
|
||||||
<TeamProjectContent>
|
|
||||||
<TeamProjectTitle>{project.name}</TeamProjectTitle>
|
|
||||||
</TeamProjectContent>
|
|
||||||
</TeamProjectLink>
|
|
||||||
</TeamProjectContainer>
|
|
||||||
))}
|
|
||||||
</TeamProjects>
|
|
||||||
</TeamContainer>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span>error</span>;
|
|
||||||
};
|
|
||||||
type ProjectPopupProps = {
|
|
||||||
history: History<History.PoorMansUnknown>;
|
|
||||||
name: string;
|
|
||||||
projectID: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, projectID }) => {
|
|
||||||
const { hidePopup, setTab } = usePopup();
|
|
||||||
const [deleteProject] = useDeleteProjectMutation({
|
|
||||||
update: (client, deleteData) => {
|
|
||||||
const cacheData: any = client.readQuery({
|
|
||||||
query: GetProjectsDocument,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newData = produce(cacheData, (draftState: any) => {
|
|
||||||
draftState.projects = draftState.projects.filter(
|
|
||||||
(project: any) => project.id !== deleteData.data.deleteProject.project.id,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.writeQuery({
|
|
||||||
query: GetProjectsDocument,
|
|
||||||
data: {
|
|
||||||
...newData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Popup title={null} tab={0}>
|
|
||||||
<ProjectSettings
|
|
||||||
onDeleteProject={() => {
|
|
||||||
setTab(1, 300);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
<Popup title={`Delete the "${name}" project?`} tab={1}>
|
|
||||||
<DeleteConfirm
|
|
||||||
description={DELETE_INFO.DELETE_PROJECTS.description}
|
|
||||||
deletedItems={DELETE_INFO.DELETE_PROJECTS.deletedItems}
|
|
||||||
onConfirmDelete={() => {
|
|
||||||
if (projectID) {
|
|
||||||
deleteProject({ variables: { projectID } });
|
|
||||||
hidePopup();
|
|
||||||
history.push('/projects');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type GlobalTopNavbarProps = {
|
|
||||||
nameOnly?: boolean;
|
|
||||||
projectID: string | null;
|
|
||||||
teamID?: string | null;
|
|
||||||
onChangeProjectOwner?: (userID: string) => void;
|
|
||||||
name: string | null;
|
|
||||||
currentTab?: number;
|
|
||||||
popupContent?: JSX.Element;
|
|
||||||
menuType?: Array<MenuItem>;
|
|
||||||
onChangeRole?: (userID: string, roleCode: RoleCode) => void;
|
|
||||||
projectMembers?: null | Array<TaskUser>;
|
|
||||||
onSaveProjectName?: (projectName: string) => void;
|
|
||||||
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
|
|
||||||
onSetTab?: (tab: number) => void;
|
|
||||||
onRemoveFromBoard?: (userID: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
|
||||||
currentTab,
|
|
||||||
onSetTab,
|
|
||||||
menuType,
|
|
||||||
projectID,
|
|
||||||
teamID,
|
|
||||||
onChangeProjectOwner,
|
|
||||||
onChangeRole,
|
|
||||||
name,
|
|
||||||
popupContent,
|
|
||||||
projectMembers,
|
|
||||||
onInviteUser,
|
|
||||||
onSaveProjectName,
|
|
||||||
onRemoveFromBoard,
|
|
||||||
nameOnly,
|
|
||||||
}) => {
|
|
||||||
const { user, setUserRoles, setUser } = useCurrentUser();
|
|
||||||
const { loading, data } = useMeQuery({
|
|
||||||
onCompleted: data => {
|
|
||||||
if (user && user.roles) {
|
|
||||||
setUserRoles({
|
|
||||||
org: user.roles.org,
|
|
||||||
teams: data.me.teamRoles.reduce((map, obj) => {
|
|
||||||
map.set(obj.teamID, obj.roleCode);
|
|
||||||
return map;
|
|
||||||
}, new Map<string, string>()),
|
|
||||||
projects: data.me.projectRoles.reduce((map, obj) => {
|
|
||||||
map.set(obj.projectID, obj.roleCode);
|
|
||||||
return map;
|
|
||||||
}, new Map<string, string>()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { showPopup, hidePopup, setTab } = usePopup();
|
|
||||||
const history = useHistory();
|
|
||||||
const onLogout = () => {
|
|
||||||
fetch('/auth/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
}).then(async x => {
|
|
||||||
const { status } = x;
|
|
||||||
if (status === 200) {
|
|
||||||
cache.reset();
|
|
||||||
history.replace('/login');
|
|
||||||
setUser(null);
|
|
||||||
hidePopup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const onProfileClick = ($target: React.RefObject<HTMLElement>) => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<Popup title={null} tab={0}>
|
|
||||||
<ProfileMenu
|
|
||||||
onLogout={onLogout}
|
|
||||||
showAdminConsole={user ? user.roles.org === 'admin' : false}
|
|
||||||
onAdminConsole={() => {
|
|
||||||
history.push('/admin');
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
onProfile={() => {
|
|
||||||
history.push('/profile');
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
195,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOpenSettings = ($target: React.RefObject<HTMLElement>) => {
|
|
||||||
if (popupContent) {
|
|
||||||
showPopup($target, popupContent, 185);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
|
|
||||||
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
|
|
||||||
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
|
|
||||||
const warning =
|
|
||||||
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
|
||||||
if (member) {
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<MiniProfile
|
|
||||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
|
||||||
canChangeRole={userIsTeamOrProjectAdmin}
|
|
||||||
onChangeRole={roleCode => {
|
|
||||||
if (onChangeRole) {
|
|
||||||
onChangeRole(member.id, roleCode);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onRemoveFromBoard={
|
|
||||||
member.role && member.role.code === 'owner'
|
|
||||||
? undefined
|
|
||||||
: () => {
|
|
||||||
if (onRemoveFromBoard) {
|
|
||||||
onRemoveFromBoard(member.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
user={member}
|
|
||||||
bio=""
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TopNavbar
|
|
||||||
name={name}
|
|
||||||
menuType={menuType}
|
|
||||||
onOpenProjectFinder={$target => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<Popup tab={0} title={null}>
|
|
||||||
<ProjectFinder />
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
currentTab={currentTab}
|
|
||||||
user={data ? data.me.user : null}
|
|
||||||
canEditProjectName={userIsTeamOrProjectAdmin}
|
|
||||||
canInviteUser={userIsTeamOrProjectAdmin}
|
|
||||||
onMemberProfile={onMemberProfile}
|
|
||||||
onInviteUser={onInviteUser}
|
|
||||||
onChangeRole={onChangeRole}
|
|
||||||
onChangeProjectOwner={onChangeProjectOwner}
|
|
||||||
onNotificationClick={() => {}}
|
|
||||||
onSetTab={onSetTab}
|
|
||||||
onRemoveFromBoard={onRemoveFromBoard}
|
|
||||||
onDashboardClick={() => {
|
|
||||||
history.push('/');
|
|
||||||
}}
|
|
||||||
projectMembers={projectMembers}
|
|
||||||
onProfileClick={onProfileClick}
|
|
||||||
onSaveName={onSaveProjectName}
|
|
||||||
onOpenSettings={onOpenSettings}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GlobalTopNavbar;
|
|
@ -1,5 +0,0 @@
|
|||||||
import { InMemoryCache } from 'apollo-cache-inmemory';
|
|
||||||
|
|
||||||
const cache = new InMemoryCache();
|
|
||||||
|
|
||||||
export default cache;
|
|
@ -1,80 +0,0 @@
|
|||||||
import React, { useContext } from 'react';
|
|
||||||
|
|
||||||
export enum PermissionLevel {
|
|
||||||
ORG,
|
|
||||||
TEAM,
|
|
||||||
PROJECT,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PermissionObjectType {
|
|
||||||
ORG,
|
|
||||||
TEAM,
|
|
||||||
PROJECT,
|
|
||||||
TASK,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CurrentUserRoles = {
|
|
||||||
org: string;
|
|
||||||
teams: Map<string, string>;
|
|
||||||
projects: Map<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CurrentUserRaw {
|
|
||||||
id: string;
|
|
||||||
roles: CurrentUserRoles;
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserContextState = {
|
|
||||||
user: CurrentUserRaw | null;
|
|
||||||
setUser: (user: CurrentUserRaw | null) => void;
|
|
||||||
setUserRoles: (roles: CurrentUserRoles) => void;
|
|
||||||
};
|
|
||||||
export const UserContext = React.createContext<UserContextState>({
|
|
||||||
user: null,
|
|
||||||
setUser: _user => null,
|
|
||||||
setUserRoles: roles => null,
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface CurrentUser extends CurrentUserRaw {
|
|
||||||
isAdmin: (level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) => boolean;
|
|
||||||
isVisible: (level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCurrentUser = () => {
|
|
||||||
const { user, setUser, setUserRoles } = useContext(UserContext);
|
|
||||||
let currentUser: CurrentUser | null = null;
|
|
||||||
if (user) {
|
|
||||||
currentUser = {
|
|
||||||
...user,
|
|
||||||
isAdmin(level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) {
|
|
||||||
if (user.roles.org === 'admin') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
switch (level) {
|
|
||||||
case PermissionLevel.TEAM:
|
|
||||||
return subjectID ? this.roles.teams.get(subjectID) === 'admin' : false;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isVisible(level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) {
|
|
||||||
if (user.roles.org === 'admin') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
switch (level) {
|
|
||||||
case PermissionLevel.TEAM:
|
|
||||||
return subjectID ? this.roles.teams.get(subjectID) !== null : false;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
user: currentUser,
|
|
||||||
setUser,
|
|
||||||
setUserRoles,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UserContext;
|
|
@ -1,81 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import jwtDecode from 'jwt-decode';
|
|
||||||
import { createBrowserHistory } from 'history';
|
|
||||||
import { Router } from 'react-router';
|
|
||||||
import { PopupProvider } from 'shared/components/PopupMenu';
|
|
||||||
import { setAccessToken } from 'shared/utils/accessToken';
|
|
||||||
import styled, { ThemeProvider } from 'styled-components';
|
|
||||||
import NormalizeStyles from './NormalizeStyles';
|
|
||||||
import BaseStyles from './BaseStyles';
|
|
||||||
import { theme } from './ThemeStyles';
|
|
||||||
import Routes from './Routes';
|
|
||||||
import { UserContext, CurrentUserRaw, CurrentUserRoles, PermissionLevel, PermissionObjectType } from './context';
|
|
||||||
|
|
||||||
const history = createBrowserHistory();
|
|
||||||
type RefreshTokenResponse = {
|
|
||||||
accessToken: string;
|
|
||||||
isInstalled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [user, setUser] = useState<CurrentUserRaw | null>(null);
|
|
||||||
const setUserRoles = (roles: CurrentUserRoles) => {
|
|
||||||
if (user) {
|
|
||||||
setUser({
|
|
||||||
...user,
|
|
||||||
roles,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/auth/refresh_token', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
}).then(async x => {
|
|
||||||
const { status } = x;
|
|
||||||
if (status === 400) {
|
|
||||||
history.replace('/login');
|
|
||||||
} else {
|
|
||||||
const response: RefreshTokenResponse = await x.json();
|
|
||||||
const { accessToken, isInstalled } = response;
|
|
||||||
const claims: JWTToken = jwtDecode(accessToken);
|
|
||||||
const currentUser = {
|
|
||||||
id: claims.userId,
|
|
||||||
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
|
|
||||||
};
|
|
||||||
setUser(currentUser);
|
|
||||||
setAccessToken(accessToken);
|
|
||||||
if (!isInstalled) {
|
|
||||||
history.replace('/install');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<UserContext.Provider value={{ user, setUser, setUserRoles }}>
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<NormalizeStyles />
|
|
||||||
<BaseStyles />
|
|
||||||
<Router history={history}>
|
|
||||||
<PopupProvider>
|
|
||||||
{loading ? (
|
|
||||||
<div>loading</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Routes history={history} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PopupProvider>
|
|
||||||
</Router>
|
|
||||||
</ThemeProvider>
|
|
||||||
</UserContext.Provider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
@ -1,13 +0,0 @@
|
|||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
export const Container = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LoginWrapper = styled.div`
|
|
||||||
width: 60%;
|
|
||||||
`;
|
|
@ -1,72 +0,0 @@
|
|||||||
import React, { useState, useEffect, useContext } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useHistory } from 'react-router';
|
|
||||||
|
|
||||||
import { setAccessToken } from 'shared/utils/accessToken';
|
|
||||||
|
|
||||||
import Login from 'shared/components/Login';
|
|
||||||
import { Container, LoginWrapper } from './Styles';
|
|
||||||
import UserContext, { PermissionLevel, PermissionObjectType } from 'App/context';
|
|
||||||
import JwtDecode from 'jwt-decode';
|
|
||||||
|
|
||||||
const Auth = () => {
|
|
||||||
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
|
|
||||||
const history = useHistory();
|
|
||||||
const { setUser } = useContext(UserContext);
|
|
||||||
const login = (
|
|
||||||
data: LoginFormData,
|
|
||||||
setComplete: (val: boolean) => void,
|
|
||||||
setError: (name: 'username' | 'password', error: ErrorOption) => void,
|
|
||||||
) => {
|
|
||||||
fetch('/auth/login', {
|
|
||||||
credentials: 'include',
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: data.username,
|
|
||||||
password: data.password,
|
|
||||||
}),
|
|
||||||
}).then(async x => {
|
|
||||||
if (x.status === 401) {
|
|
||||||
setInvalidLoginAttempt(invalidLoginAttempt + 1);
|
|
||||||
setError('username', { type: 'error', message: 'Invalid username' });
|
|
||||||
setError('password', { type: 'error', message: 'Invalid password' });
|
|
||||||
setComplete(true);
|
|
||||||
} else {
|
|
||||||
const response = await x.json();
|
|
||||||
const { accessToken } = response;
|
|
||||||
const claims: JWTToken = JwtDecode(accessToken);
|
|
||||||
const currentUser = {
|
|
||||||
id: claims.userId,
|
|
||||||
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
|
|
||||||
};
|
|
||||||
setUser(currentUser);
|
|
||||||
setComplete(true);
|
|
||||||
setAccessToken(accessToken);
|
|
||||||
|
|
||||||
history.push('/');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/auth/refresh_token', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
}).then(async x => {
|
|
||||||
const { status } = x;
|
|
||||||
if (status === 200) {
|
|
||||||
history.replace('/projects');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<LoginWrapper>
|
|
||||||
<Login onSubmit={login} />
|
|
||||||
</LoginWrapper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Auth;
|
|
@ -1,8 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Redirect } from 'react-router';
|
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
|
||||||
return <Redirect to="/projects" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Dashboard;
|
|
@ -1,13 +0,0 @@
|
|||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
export const Container = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LoginWrapper = styled.div`
|
|
||||||
width: 60%;
|
|
||||||
`;
|
|
@ -1,93 +0,0 @@
|
|||||||
import React, { useEffect, useContext } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import Register from 'shared/components/Register';
|
|
||||||
import { Container, LoginWrapper } from './Styles';
|
|
||||||
import { useCreateUserAccountMutation, useMeQuery, MeDocument, MeQuery } from 'shared/generated/graphql';
|
|
||||||
import { useHistory } from 'react-router';
|
|
||||||
import { getAccessToken, setAccessToken } from 'shared/utils/accessToken';
|
|
||||||
import updateApolloCache from 'shared/utils/cache';
|
|
||||||
import produce from 'immer';
|
|
||||||
import { useApolloClient } from '@apollo/react-hooks';
|
|
||||||
import UserContext, { PermissionLevel, PermissionObjectType } from 'App/context';
|
|
||||||
import jwtDecode from 'jwt-decode';
|
|
||||||
|
|
||||||
const Install = () => {
|
|
||||||
const client = useApolloClient();
|
|
||||||
const history = useHistory();
|
|
||||||
const { setUser } = useContext(UserContext);
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/auth/refresh_token', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
}).then(async x => {
|
|
||||||
const { status } = x;
|
|
||||||
const response: RefreshTokenResponse = await x.json();
|
|
||||||
const { isInstalled } = response;
|
|
||||||
if (status === 200 && isInstalled) {
|
|
||||||
history.replace('/projects');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<LoginWrapper>
|
|
||||||
<Register
|
|
||||||
onSubmit={(data, setComplete, setError) => {
|
|
||||||
const accessToken = getAccessToken();
|
|
||||||
if (data.password !== data.password_confirm) {
|
|
||||||
setError('password', { type: 'error', message: 'Passwords must match' });
|
|
||||||
setError('password_confirm', { type: 'error', message: 'Passwords must match' });
|
|
||||||
} else {
|
|
||||||
axios
|
|
||||||
.post(
|
|
||||||
'/auth/install',
|
|
||||||
{
|
|
||||||
user: {
|
|
||||||
username: data.username,
|
|
||||||
roleCode: 'admin',
|
|
||||||
email: data.email,
|
|
||||||
password: data.password,
|
|
||||||
initials: data.initials,
|
|
||||||
fullname: data.fullname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.then(async x => {
|
|
||||||
const { status } = x;
|
|
||||||
if (status === 400) {
|
|
||||||
history.replace('/login');
|
|
||||||
} else {
|
|
||||||
const response: RefreshTokenResponse = await x.data;
|
|
||||||
const { accessToken, isInstalled } = response;
|
|
||||||
const claims: JWTToken = jwtDecode(accessToken);
|
|
||||||
const currentUser = {
|
|
||||||
id: claims.userId,
|
|
||||||
roles: {
|
|
||||||
org: claims.orgRole,
|
|
||||||
teams: new Map<string, string>(),
|
|
||||||
projects: new Map<string, string>(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setUser(currentUser);
|
|
||||||
setAccessToken(accessToken);
|
|
||||||
if (!isInstalled) {
|
|
||||||
history.replace('/install');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
history.push('/projects');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setComplete(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</LoginWrapper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Install;
|
|
@ -1,79 +0,0 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
|
||||||
import styled from 'styled-components/macro';
|
|
||||||
import GlobalTopNavbar from 'App/TopNavbar';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { getAccessToken } from 'shared/utils/accessToken';
|
|
||||||
import Settings from 'shared/components/Settings';
|
|
||||||
import { useMeQuery, useClearProfileAvatarMutation, useUpdateUserPasswordMutation } from 'shared/generated/graphql';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { useCurrentUser } from 'App/context';
|
|
||||||
|
|
||||||
const MainContent = styled.div`
|
|
||||||
padding: 0 0 50px 80px;
|
|
||||||
height: 100%;
|
|
||||||
background: #262c49;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Projects = () => {
|
|
||||||
const $fileUpload = useRef<HTMLInputElement>(null);
|
|
||||||
const [clearProfileAvatar] = useClearProfileAvatarMutation();
|
|
||||||
const { user } = useCurrentUser();
|
|
||||||
const [updateUserPassword] = useUpdateUserPasswordMutation();
|
|
||||||
const { loading, data, refetch } = useMeQuery();
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = 'Profile | Taskcafé';
|
|
||||||
}, []);
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
name="file"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
ref={$fileUpload}
|
|
||||||
onChange={e => {
|
|
||||||
if (e.target.files) {
|
|
||||||
const fileData = new FormData();
|
|
||||||
fileData.append('file', e.target.files[0]);
|
|
||||||
const accessToken = getAccessToken();
|
|
||||||
axios
|
|
||||||
.post('/users/me/avatar', fileData, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(res => {
|
|
||||||
if ($fileUpload && $fileUpload.current) {
|
|
||||||
$fileUpload.current.value = '';
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />
|
|
||||||
{!loading && data && (
|
|
||||||
<Settings
|
|
||||||
profile={data.me.user}
|
|
||||||
onProfileAvatarChange={() => {
|
|
||||||
if ($fileUpload && $fileUpload.current) {
|
|
||||||
$fileUpload.current.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onResetPassword={(password, done) => {
|
|
||||||
updateUserPassword({ variables: { userID: user.id, password } });
|
|
||||||
done();
|
|
||||||
}}
|
|
||||||
onProfileAvatarRemove={() => {
|
|
||||||
clearProfileAvatar();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Projects;
|
|
@ -1,570 +0,0 @@
|
|||||||
import React, { useState, useRef, useContext, useEffect } from 'react';
|
|
||||||
import { MENU_TYPES } from 'shared/components/TopNavbar';
|
|
||||||
import updateApolloCache from 'shared/utils/cache';
|
|
||||||
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
|
|
||||||
import LabelManagerEditor from '../LabelManagerEditor';
|
|
||||||
import styled, { css } from 'styled-components/macro';
|
|
||||||
import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
|
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
|
||||||
import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
useUpdateProjectMemberRoleMutation,
|
|
||||||
useCreateProjectMemberMutation,
|
|
||||||
useDeleteProjectMemberMutation,
|
|
||||||
useSetTaskCompleteMutation,
|
|
||||||
useToggleTaskLabelMutation,
|
|
||||||
useUpdateProjectNameMutation,
|
|
||||||
useFindProjectQuery,
|
|
||||||
useUpdateTaskGroupNameMutation,
|
|
||||||
useUpdateTaskNameMutation,
|
|
||||||
useUpdateProjectLabelMutation,
|
|
||||||
useCreateTaskMutation,
|
|
||||||
useDeleteProjectLabelMutation,
|
|
||||||
useDeleteTaskMutation,
|
|
||||||
useUpdateTaskLocationMutation,
|
|
||||||
useUpdateTaskGroupLocationMutation,
|
|
||||||
useCreateTaskGroupMutation,
|
|
||||||
useDeleteTaskGroupMutation,
|
|
||||||
useUpdateTaskDescriptionMutation,
|
|
||||||
useAssignTaskMutation,
|
|
||||||
DeleteTaskDocument,
|
|
||||||
FindProjectDocument,
|
|
||||||
useCreateProjectLabelMutation,
|
|
||||||
useUnassignTaskMutation,
|
|
||||||
useUpdateTaskDueDateMutation,
|
|
||||||
FindProjectQuery,
|
|
||||||
useUsersQuery,
|
|
||||||
} from 'shared/generated/graphql';
|
|
||||||
|
|
||||||
import QuickCardEditor from 'shared/components/QuickCardEditor';
|
|
||||||
import ListActions from 'shared/components/ListActions';
|
|
||||||
import MemberManager from 'shared/components/MemberManager';
|
|
||||||
import SimpleLists from 'shared/components/Lists';
|
|
||||||
import produce from 'immer';
|
|
||||||
import MiniProfile from 'shared/components/MiniProfile';
|
|
||||||
import DueDateManager from 'shared/components/DueDateManager';
|
|
||||||
import UserContext, { useCurrentUser } from 'App/context';
|
|
||||||
import LabelManager from 'shared/components/PopupMenu/LabelManager';
|
|
||||||
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
|
|
||||||
import EmptyBoard from 'shared/components/EmptyBoard';
|
|
||||||
|
|
||||||
const ProjectBar = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectActions = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectAction = styled.div<{ disabled?: boolean }>`
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 15px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary});
|
|
||||||
}
|
|
||||||
${props =>
|
|
||||||
props.disabled &&
|
|
||||||
css`
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
pointer-events: none;
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectActionText = styled.span`
|
|
||||||
padding-left: 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface QuickCardEditorState {
|
|
||||||
isOpen: boolean;
|
|
||||||
target: React.RefObject<HTMLElement> | null;
|
|
||||||
taskID: string | null;
|
|
||||||
taskGroupID: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialQuickCardEditorState: QuickCardEditorState = {
|
|
||||||
taskID: null,
|
|
||||||
taskGroupID: null,
|
|
||||||
isOpen: false,
|
|
||||||
target: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProjectBoardProps = {
|
|
||||||
onCardLabelClick?: () => void;
|
|
||||||
cardLabelVariant?: CardLabelVariant;
|
|
||||||
projectID: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BoardLoading = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ProjectBar>
|
|
||||||
<ProjectActions>
|
|
||||||
<ProjectAction disabled>
|
|
||||||
<CheckCircle width={13} height={13} />
|
|
||||||
<ProjectActionText>All Tasks</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
<ProjectAction disabled>
|
|
||||||
<Filter width={13} height={13} />
|
|
||||||
<ProjectActionText>Filter</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
<ProjectAction disabled>
|
|
||||||
<Sort width={13} height={13} />
|
|
||||||
<ProjectActionText>Sort</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
</ProjectActions>
|
|
||||||
<ProjectActions>
|
|
||||||
<ProjectAction>
|
|
||||||
<Tags width={13} height={13} />
|
|
||||||
<ProjectActionText>Labels</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
<ProjectAction disabled>
|
|
||||||
<ToggleOn width={13} height={13} />
|
|
||||||
<ProjectActionText>Fields</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
<ProjectAction disabled>
|
|
||||||
<Bolt width={13} height={13} />
|
|
||||||
<ProjectActionText>Rules</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
</ProjectActions>
|
|
||||||
</ProjectBar>
|
|
||||||
<EmptyBoard />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick, cardLabelVariant }) => {
|
|
||||||
const [assignTask] = useAssignTaskMutation();
|
|
||||||
const [unassignTask] = useUnassignTaskMutation();
|
|
||||||
const $labelsRef = useRef<HTMLDivElement>(null);
|
|
||||||
const match = useRouteMatch();
|
|
||||||
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
|
||||||
const { showPopup, hidePopup } = usePopup();
|
|
||||||
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
|
||||||
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
|
|
||||||
const { user } = useCurrentUser();
|
|
||||||
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
|
|
||||||
const history = useHistory();
|
|
||||||
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
|
|
||||||
update: (client, deletedTaskGroupData) => {
|
|
||||||
updateApolloCache<FindProjectQuery>(
|
|
||||||
client,
|
|
||||||
FindProjectDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter(
|
|
||||||
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
{ projectID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [updateTaskName] = useUpdateTaskNameMutation();
|
|
||||||
const [createTask] = useCreateTaskMutation({
|
|
||||||
update: (client, newTaskData) => {
|
|
||||||
updateApolloCache<FindProjectQuery>(
|
|
||||||
client,
|
|
||||||
FindProjectDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
const { taskGroups } = cache.findProject;
|
|
||||||
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id);
|
|
||||||
if (idx !== -1) {
|
|
||||||
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ projectID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [createTaskGroup] = useCreateTaskGroupMutation({
|
|
||||||
update: (client, newTaskGroupData) => {
|
|
||||||
updateApolloCache<FindProjectQuery>(
|
|
||||||
client,
|
|
||||||
FindProjectDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
|
|
||||||
}),
|
|
||||||
{ projectID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({});
|
|
||||||
const { loading, data } = useFindProjectQuery({
|
|
||||||
variables: { projectID },
|
|
||||||
});
|
|
||||||
|
|
||||||
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
|
|
||||||
const [setTaskComplete] = useSetTaskCompleteMutation();
|
|
||||||
const [updateTaskLocation] = useUpdateTaskLocationMutation({
|
|
||||||
update: (client, newTask) => {
|
|
||||||
updateApolloCache<FindProjectQuery>(
|
|
||||||
client,
|
|
||||||
FindProjectDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
|
|
||||||
if (previousTaskGroupID !== task.taskGroup.id) {
|
|
||||||
const { taskGroups } = cache.findProject;
|
|
||||||
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
|
|
||||||
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
|
|
||||||
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
|
|
||||||
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
|
|
||||||
(t: Task) => t.id !== task.id,
|
|
||||||
);
|
|
||||||
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
|
|
||||||
...taskGroups[newTaskGroupIdx].tasks,
|
|
||||||
{ ...task },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ projectID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [deleteTask] = useDeleteTaskMutation();
|
|
||||||
const [toggleTaskLabel] = useToggleTaskLabelMutation({
|
|
||||||
onCompleted: newTaskLabel => {
|
|
||||||
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onCreateTask = (taskGroupID: string, name: string) => {
|
|
||||||
if (data) {
|
|
||||||
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
|
|
||||||
if (taskGroup) {
|
|
||||||
let position = 65535;
|
|
||||||
if (taskGroup.tasks.length !== 0) {
|
|
||||||
const [lastTask] = taskGroup.tasks
|
|
||||||
.slice()
|
|
||||||
.sort((a: any, b: any) => a.position - b.position)
|
|
||||||
.slice(-1);
|
|
||||||
position = Math.ceil(lastTask.position) * 2 + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
createTask({
|
|
||||||
variables: { taskGroupID, name, position },
|
|
||||||
optimisticResponse: {
|
|
||||||
__typename: 'Mutation',
|
|
||||||
createTask: {
|
|
||||||
__typename: 'Task',
|
|
||||||
id: `${Math.round(Math.random() * -1000000)}`,
|
|
||||||
name,
|
|
||||||
complete: false,
|
|
||||||
taskGroup: {
|
|
||||||
__typename: 'TaskGroup',
|
|
||||||
id: taskGroup.id,
|
|
||||||
name: taskGroup.name,
|
|
||||||
position: taskGroup.position,
|
|
||||||
},
|
|
||||||
badges: {
|
|
||||||
__typename: 'TaskBadges',
|
|
||||||
checklist: null,
|
|
||||||
},
|
|
||||||
position,
|
|
||||||
dueDate: null,
|
|
||||||
description: null,
|
|
||||||
labels: [],
|
|
||||||
assigned: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCreateList = (listName: string) => {
|
|
||||||
if (data && projectID) {
|
|
||||||
const [lastColumn] = data.findProject.taskGroups.sort((a, b) => a.position - b.position).slice(-1);
|
|
||||||
let position = 65535;
|
|
||||||
if (lastColumn) {
|
|
||||||
position = lastColumn.position * 2 + 1;
|
|
||||||
}
|
|
||||||
createTaskGroup({ variables: { projectID, name: listName, position } });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <BoardLoading />;
|
|
||||||
}
|
|
||||||
if (data) {
|
|
||||||
labelsRef.current = data.findProject.labels;
|
|
||||||
const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => {
|
|
||||||
if ($target && $target.current) {
|
|
||||||
const pos = $target.current.getBoundingClientRect();
|
|
||||||
const height = 120;
|
|
||||||
if (window.innerHeight - pos.bottom < height) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
|
|
||||||
const currentTask = taskGroup ? taskGroup.tasks.find(t => t.id === taskID) : null;
|
|
||||||
if (currentTask) {
|
|
||||||
setQuickCardEditor({
|
|
||||||
target: $target,
|
|
||||||
isOpen: true,
|
|
||||||
taskID: currentTask.id,
|
|
||||||
taskGroupID: currentTask.taskGroup.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let currentQuickTask = null;
|
|
||||||
if (quickCardEditor.taskID && quickCardEditor.taskGroupID) {
|
|
||||||
const targetGroup = data.findProject.taskGroups.find(t => t.id === quickCardEditor.taskGroupID);
|
|
||||||
if (targetGroup) {
|
|
||||||
currentQuickTask = targetGroup.tasks.find(t => t.id === quickCardEditor.taskID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ProjectBar>
|
|
||||||
<ProjectActions>
|
|
||||||
<ProjectAction disabled>
|
|
||||||
<CheckCircle width={13} height={13} />
|
|
||||||
<ProjectActionText>All Tasks</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
<ProjectAction disabled>
|
|
||||||
<Filter width={13} height={13} />
|
|
||||||
<ProjectActionText>Filter</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
<ProjectAction disabled>
|
|
||||||
<Sort width={13} height={13} />
|
|
||||||
<ProjectActionText>Sort</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
</ProjectActions>
|
|
||||||
<ProjectActions>
|
|
||||||
<ProjectAction
|
|
||||||
ref={$labelsRef}
|
|
||||||
onClick={() => {
|
|
||||||
showPopup(
|
|
||||||
$labelsRef,
|
|
||||||
<LabelManagerEditor
|
|
||||||
taskLabels={null}
|
|
||||||
labelColors={data.labelColors}
|
|
||||||
labels={labelsRef}
|
|
||||||
projectID={projectID ?? ''}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tags width={13} height={13} />
|
|
||||||
<ProjectActionText>Labels</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
<ProjectAction disabled>
|
|
||||||
<ToggleOn width={13} height={13} />
|
|
||||||
<ProjectActionText>Fields</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
<ProjectAction disabled>
|
|
||||||
<Bolt width={13} height={13} />
|
|
||||||
<ProjectActionText>Rules</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
</ProjectActions>
|
|
||||||
</ProjectBar>
|
|
||||||
<SimpleLists
|
|
||||||
onTaskClick={task => {
|
|
||||||
history.push(`${match.url}/c/${task.id}`);
|
|
||||||
}}
|
|
||||||
onCardLabelClick={onCardLabelClick ?? (() => {})}
|
|
||||||
cardLabelVariant={cardLabelVariant ?? 'large'}
|
|
||||||
onTaskDrop={(droppedTask, previousTaskGroupID) => {
|
|
||||||
updateTaskLocation({
|
|
||||||
variables: {
|
|
||||||
taskID: droppedTask.id,
|
|
||||||
taskGroupID: droppedTask.taskGroup.id,
|
|
||||||
position: droppedTask.position,
|
|
||||||
},
|
|
||||||
optimisticResponse: {
|
|
||||||
__typename: 'Mutation',
|
|
||||||
updateTaskLocation: {
|
|
||||||
__typename: 'UpdateTaskLocationPayload',
|
|
||||||
previousTaskGroupID,
|
|
||||||
task: {
|
|
||||||
...droppedTask,
|
|
||||||
__typename: 'Task',
|
|
||||||
name: droppedTask.name,
|
|
||||||
id: droppedTask.id,
|
|
||||||
position: droppedTask.position,
|
|
||||||
taskGroup: {
|
|
||||||
id: droppedTask.taskGroup.id,
|
|
||||||
__typename: 'TaskGroup',
|
|
||||||
},
|
|
||||||
createdAt: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onTaskGroupDrop={droppedTaskGroup => {
|
|
||||||
updateTaskGroupLocation({
|
|
||||||
variables: { taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position },
|
|
||||||
optimisticResponse: {
|
|
||||||
__typename: 'Mutation',
|
|
||||||
updateTaskGroupLocation: {
|
|
||||||
id: droppedTaskGroup.id,
|
|
||||||
position: droppedTaskGroup.position,
|
|
||||||
__typename: 'TaskGroup',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
taskGroups={data.findProject.taskGroups}
|
|
||||||
onCreateTask={onCreateTask}
|
|
||||||
onCreateTaskGroup={onCreateList}
|
|
||||||
onCardMemberClick={($targetRef, taskID, memberID) => {
|
|
||||||
const member = data.findProject.members.find(m => m.id === memberID);
|
|
||||||
if (member) {
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<MiniProfile
|
|
||||||
user={member}
|
|
||||||
bio="None"
|
|
||||||
onRemoveFromTask={() => {
|
|
||||||
/* unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); */
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChangeTaskGroupName={(taskGroupID, name) => {
|
|
||||||
updateTaskGroupName({ variables: { taskGroupID, name } });
|
|
||||||
}}
|
|
||||||
onQuickEditorOpen={onQuickEditorOpen}
|
|
||||||
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<Popup title="List actions" tab={0} onClose={() => hidePopup()}>
|
|
||||||
<ListActions
|
|
||||||
taskGroupID={taskGroupID}
|
|
||||||
onArchiveTaskGroup={tgID => {
|
|
||||||
deleteTaskGroup({ variables: { taskGroupID: tgID } });
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{quickCardEditor.isOpen && currentQuickTask && quickCardEditor.target && (
|
|
||||||
<QuickCardEditor
|
|
||||||
task={currentQuickTask}
|
|
||||||
onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)}
|
|
||||||
onEditCard={(_taskGroupID: string, taskID: string, cardName: string) => {
|
|
||||||
updateTaskName({ variables: { taskID, name: cardName } });
|
|
||||||
}}
|
|
||||||
onOpenMembersPopup={($targetRef, task) => {
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<Popup title="Members" tab={0} onClose={() => hidePopup()}>
|
|
||||||
<MemberManager
|
|
||||||
availableMembers={data.findProject.members}
|
|
||||||
activeMembers={task.assigned ?? []}
|
|
||||||
onMemberChange={(member, isActive) => {
|
|
||||||
if (isActive) {
|
|
||||||
assignTask({ variables: { taskID: task.id, userID: member.id } });
|
|
||||||
} else {
|
|
||||||
unassignTask({ variables: { taskID: task.id, userID: member.id } });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onCardMemberClick={($targetRef, taskID, memberID) => {
|
|
||||||
const member = data.findProject.members.find(m => m.id === memberID);
|
|
||||||
if (member) {
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<MiniProfile
|
|
||||||
bio="None"
|
|
||||||
user={member}
|
|
||||||
onRemoveFromTask={() => {
|
|
||||||
/* unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); */
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onOpenLabelsPopup={($targetRef, task) => {
|
|
||||||
taskLabelsRef.current = task.labels;
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<LabelManagerEditor
|
|
||||||
onLabelToggle={labelID => {
|
|
||||||
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
|
|
||||||
}}
|
|
||||||
labelColors={data.labelColors}
|
|
||||||
labels={labelsRef}
|
|
||||||
taskLabels={taskLabelsRef}
|
|
||||||
projectID={projectID ?? ''}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onArchiveCard={(_listId: string, cardId: string) =>
|
|
||||||
deleteTask({
|
|
||||||
variables: { taskID: cardId },
|
|
||||||
update: client => {
|
|
||||||
updateApolloCache<FindProjectQuery>(
|
|
||||||
client,
|
|
||||||
FindProjectDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.findProject.taskGroups = cache.findProject.taskGroups.map(taskGroup => ({
|
|
||||||
...taskGroup,
|
|
||||||
tasks: taskGroup.tasks.filter(t => t.id !== cardId),
|
|
||||||
}));
|
|
||||||
}),
|
|
||||||
{ projectID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onOpenDueDatePopup={($targetRef, task) => {
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<Popup title="Change Due Date" tab={0} onClose={() => hidePopup()}>
|
|
||||||
<DueDateManager
|
|
||||||
task={task}
|
|
||||||
onRemoveDueDate={t => {
|
|
||||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
onDueDateChange={(t, newDueDate) => {
|
|
||||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
onCancel={() => {}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onToggleComplete={task => {
|
|
||||||
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
|
|
||||||
}}
|
|
||||||
target={quickCardEditor.target}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span>Error</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProjectBoard;
|
|
@ -1,518 +0,0 @@
|
|||||||
import React, { useState, useContext, useEffect } from 'react';
|
|
||||||
import Modal from 'shared/components/Modal';
|
|
||||||
import TaskDetails from 'shared/components/TaskDetails';
|
|
||||||
import PopupMenu, { Popup, usePopup } from 'shared/components/PopupMenu';
|
|
||||||
import MemberManager from 'shared/components/MemberManager';
|
|
||||||
import { useRouteMatch, useHistory } from 'react-router';
|
|
||||||
import {
|
|
||||||
useDeleteTaskChecklistMutation,
|
|
||||||
useUpdateTaskChecklistNameMutation,
|
|
||||||
useUpdateTaskChecklistItemLocationMutation,
|
|
||||||
useCreateTaskChecklistMutation,
|
|
||||||
useFindTaskQuery,
|
|
||||||
useUpdateTaskDueDateMutation,
|
|
||||||
useSetTaskCompleteMutation,
|
|
||||||
useAssignTaskMutation,
|
|
||||||
useUnassignTaskMutation,
|
|
||||||
useSetTaskChecklistItemCompleteMutation,
|
|
||||||
useUpdateTaskChecklistLocationMutation,
|
|
||||||
useDeleteTaskChecklistItemMutation,
|
|
||||||
useUpdateTaskChecklistItemNameMutation,
|
|
||||||
useCreateTaskChecklistItemMutation,
|
|
||||||
FindTaskDocument,
|
|
||||||
FindTaskQuery,
|
|
||||||
} from 'shared/generated/graphql';
|
|
||||||
import UserContext, { useCurrentUser } from 'App/context';
|
|
||||||
import MiniProfile from 'shared/components/MiniProfile';
|
|
||||||
import DueDateManager from 'shared/components/DueDateManager';
|
|
||||||
import produce from 'immer';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import Button from 'shared/components/Button';
|
|
||||||
import Input from 'shared/components/Input';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import updateApolloCache from 'shared/utils/cache';
|
|
||||||
|
|
||||||
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
|
|
||||||
const total = checklists.reduce((prev: any, next: any) => {
|
|
||||||
return (
|
|
||||||
prev +
|
|
||||||
next.items.reduce((innerPrev: any, _item: any) => {
|
|
||||||
return innerPrev + 1;
|
|
||||||
}, 0)
|
|
||||||
);
|
|
||||||
}, 0);
|
|
||||||
const complete = checklists.reduce(
|
|
||||||
(prev: any, next: any) =>
|
|
||||||
prev +
|
|
||||||
next.items.reduce((innerPrev: any, item: any) => {
|
|
||||||
return innerPrev + (item.complete ? 1 : 0);
|
|
||||||
}, 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return { total, complete };
|
|
||||||
};
|
|
||||||
|
|
||||||
const DeleteChecklistButton = styled(Button)`
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
`;
|
|
||||||
type CreateChecklistData = {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
const CreateChecklistForm = styled.form`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const CreateChecklistButton = styled(Button)`
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const CreateChecklistInput = styled(Input)`
|
|
||||||
margin-bottom: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const InputError = styled.span`
|
|
||||||
color: rgba(${props => props.theme.colors.danger});
|
|
||||||
font-size: 12px;
|
|
||||||
`;
|
|
||||||
type CreateChecklistPopupProps = {
|
|
||||||
onCreateChecklist: (data: CreateChecklistData) => void;
|
|
||||||
};
|
|
||||||
const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => {
|
|
||||||
const { register, handleSubmit, errors } = useForm<CreateChecklistData>();
|
|
||||||
const createUser = (data: CreateChecklistData) => {
|
|
||||||
onCreateChecklist(data);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<CreateChecklistForm onSubmit={handleSubmit(createUser)}>
|
|
||||||
<CreateChecklistInput
|
|
||||||
floatingLabel
|
|
||||||
autoFocus
|
|
||||||
autoSelect
|
|
||||||
defaultValue="Checklist"
|
|
||||||
width="100%"
|
|
||||||
label="Name"
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
variant="alternate"
|
|
||||||
ref={register({ required: 'Checklist name is required' })}
|
|
||||||
/>
|
|
||||||
<CreateChecklistButton type="submit">Create</CreateChecklistButton>
|
|
||||||
</CreateChecklistForm>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type DetailsProps = {
|
|
||||||
taskID: string;
|
|
||||||
projectURL: string;
|
|
||||||
onTaskNameChange: (task: Task, newName: string) => void;
|
|
||||||
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
|
|
||||||
onDeleteTask: (task: Task) => void;
|
|
||||||
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
|
||||||
availableMembers: Array<TaskUser>;
|
|
||||||
refreshCache: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 };
|
|
||||||
|
|
||||||
const Details: React.FC<DetailsProps> = ({
|
|
||||||
projectURL,
|
|
||||||
taskID,
|
|
||||||
onTaskNameChange,
|
|
||||||
onTaskDescriptionChange,
|
|
||||||
onDeleteTask,
|
|
||||||
onOpenAddLabelPopup,
|
|
||||||
availableMembers,
|
|
||||||
refreshCache,
|
|
||||||
}) => {
|
|
||||||
const { user } = useCurrentUser();
|
|
||||||
const { showPopup, hidePopup } = usePopup();
|
|
||||||
const history = useHistory();
|
|
||||||
const match = useRouteMatch();
|
|
||||||
const [currentMemberTask, setCurrentMemberTask] = useState('');
|
|
||||||
const [memberPopupData, setMemberPopupData] = useState(initialMemberPopupState);
|
|
||||||
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
|
|
||||||
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
|
|
||||||
update: (client, response) => {
|
|
||||||
updateApolloCache<FindTaskQuery>(
|
|
||||||
client,
|
|
||||||
FindTaskDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
|
|
||||||
if (checklistID !== prevChecklistID) {
|
|
||||||
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
|
|
||||||
const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID);
|
|
||||||
if (oldIdx > -1 && newIdx > -1) {
|
|
||||||
const item = cache.findTask.checklists[oldIdx].items.find(item => item.id === checklistItem.id);
|
|
||||||
if (item) {
|
|
||||||
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
|
|
||||||
i => i.id !== checklistItem.id,
|
|
||||||
);
|
|
||||||
draftCache.findTask.checklists[newIdx].items.push({
|
|
||||||
...item,
|
|
||||||
position: checklistItem.position,
|
|
||||||
taskChecklistID: checklistID,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ taskID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({
|
|
||||||
update: client => {
|
|
||||||
updateApolloCache<FindTaskQuery>(
|
|
||||||
client,
|
|
||||||
FindTaskDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
|
||||||
draftCache.findTask.badges.checklist = {
|
|
||||||
__typename: 'ChecklistBadge',
|
|
||||||
complete,
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
{ taskID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [deleteTaskChecklist] = useDeleteTaskChecklistMutation({
|
|
||||||
update: (client, deleteData) => {
|
|
||||||
updateApolloCache<FindTaskQuery>(
|
|
||||||
client,
|
|
||||||
FindTaskDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
const { checklists } = cache.findTask;
|
|
||||||
draftCache.findTask.checklists = checklists.filter(
|
|
||||||
c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id,
|
|
||||||
);
|
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
|
||||||
draftCache.findTask.badges.checklist = {
|
|
||||||
__typename: 'ChecklistBadge',
|
|
||||||
complete,
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
if (complete === 0 && total === 0) {
|
|
||||||
draftCache.findTask.badges.checklist = null;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ taskID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [updateTaskChecklistItemName] = useUpdateTaskChecklistItemNameMutation();
|
|
||||||
const [createTaskChecklist] = useCreateTaskChecklistMutation({
|
|
||||||
update: (client, createData) => {
|
|
||||||
updateApolloCache<FindTaskQuery>(
|
|
||||||
client,
|
|
||||||
FindTaskDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
const item = createData.data.createTaskChecklist;
|
|
||||||
draftCache.findTask.checklists.push({ ...item });
|
|
||||||
}),
|
|
||||||
{ taskID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [updateTaskChecklistName] = useUpdateTaskChecklistNameMutation();
|
|
||||||
const [deleteTaskChecklistItem] = useDeleteTaskChecklistItemMutation({
|
|
||||||
update: (client, deleteData) => {
|
|
||||||
updateApolloCache<FindTaskQuery>(
|
|
||||||
client,
|
|
||||||
FindTaskDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
|
|
||||||
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
|
|
||||||
if (targetIdx > -1) {
|
|
||||||
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
|
|
||||||
c => item.id !== c.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
|
||||||
draftCache.findTask.badges.checklist = {
|
|
||||||
__typename: 'ChecklistBadge',
|
|
||||||
complete,
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
{ taskID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
|
|
||||||
update: (client, newTaskItem) => {
|
|
||||||
updateApolloCache<FindTaskQuery>(
|
|
||||||
client,
|
|
||||||
FindTaskDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
const item = newTaskItem.data.createTaskChecklistItem;
|
|
||||||
const { checklists } = cache.findTask;
|
|
||||||
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
|
|
||||||
if (idx !== -1) {
|
|
||||||
draftCache.findTask.checklists[idx].items.push({ ...item });
|
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
|
||||||
draftCache.findTask.badges.checklist = {
|
|
||||||
__typename: 'ChecklistBadge',
|
|
||||||
complete,
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ taskID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
|
|
||||||
const [setTaskComplete] = useSetTaskCompleteMutation();
|
|
||||||
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
|
|
||||||
onCompleted: () => {
|
|
||||||
refetch();
|
|
||||||
refreshCache();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [assignTask] = useAssignTaskMutation({
|
|
||||||
onCompleted: () => {
|
|
||||||
refetch();
|
|
||||||
refreshCache();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [unassignTask] = useUnassignTaskMutation({
|
|
||||||
onCompleted: () => {
|
|
||||||
refetch();
|
|
||||||
refreshCache();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (loading) {
|
|
||||||
return <div>loading</div>;
|
|
||||||
}
|
|
||||||
if (!data) {
|
|
||||||
return <div>loading</div>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
width={768}
|
|
||||||
onClose={() => {
|
|
||||||
history.push(projectURL);
|
|
||||||
}}
|
|
||||||
renderContent={() => {
|
|
||||||
return (
|
|
||||||
<TaskDetails
|
|
||||||
task={data.findTask}
|
|
||||||
onChecklistDrop={checklist => {
|
|
||||||
updateTaskChecklistLocation({
|
|
||||||
variables: { checklistID: checklist.id, position: checklist.position },
|
|
||||||
|
|
||||||
optimisticResponse: {
|
|
||||||
__typename: 'Mutation',
|
|
||||||
updateTaskChecklistLocation: {
|
|
||||||
__typename: 'UpdateTaskChecklistLocationPayload',
|
|
||||||
checklist: {
|
|
||||||
__typename: 'TaskChecklist',
|
|
||||||
position: checklist.position,
|
|
||||||
id: checklist.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onChecklistItemDrop={(prevChecklistID, checklistID, checklistItem) => {
|
|
||||||
updateTaskChecklistItemLocation({
|
|
||||||
variables: { checklistID, checklistItemID: checklistItem.id, position: checklistItem.position },
|
|
||||||
optimisticResponse: {
|
|
||||||
__typename: 'Mutation',
|
|
||||||
updateTaskChecklistItemLocation: {
|
|
||||||
__typename: 'UpdateTaskChecklistItemLocationPayload',
|
|
||||||
prevChecklistID,
|
|
||||||
checklistID,
|
|
||||||
checklistItem: {
|
|
||||||
__typename: 'TaskChecklistItem',
|
|
||||||
position: checklistItem.position,
|
|
||||||
id: checklistItem.id,
|
|
||||||
taskChecklistID: checklistID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onTaskNameChange={onTaskNameChange}
|
|
||||||
onTaskDescriptionChange={onTaskDescriptionChange}
|
|
||||||
onToggleTaskComplete={task => {
|
|
||||||
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
|
|
||||||
}}
|
|
||||||
onDeleteTask={onDeleteTask}
|
|
||||||
onChangeItemName={(itemID, itemName) => {
|
|
||||||
updateTaskChecklistItemName({ variables: { taskChecklistItemID: itemID, name: itemName } });
|
|
||||||
}}
|
|
||||||
onCloseModal={() => history.push(projectURL)}
|
|
||||||
onChangeChecklistName={(checklistID, newName) => {
|
|
||||||
updateTaskChecklistName({ variables: { taskChecklistID: checklistID, name: newName } });
|
|
||||||
}}
|
|
||||||
onDeleteItem={(checklistID, itemID) => {
|
|
||||||
deleteTaskChecklistItem({
|
|
||||||
variables: { taskChecklistItemID: itemID },
|
|
||||||
optimisticResponse: {
|
|
||||||
__typename: 'Mutation',
|
|
||||||
deleteTaskChecklistItem: {
|
|
||||||
__typename: 'DeleteTaskChecklistItemPayload',
|
|
||||||
ok: true,
|
|
||||||
taskChecklistItem: {
|
|
||||||
__typename: 'TaskChecklistItem',
|
|
||||||
id: itemID,
|
|
||||||
taskChecklistID: checklistID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onToggleChecklistItem={(itemID, complete) => {
|
|
||||||
setTaskChecklistItemComplete({
|
|
||||||
variables: { taskChecklistItemID: itemID, complete },
|
|
||||||
optimisticResponse: {
|
|
||||||
__typename: 'Mutation',
|
|
||||||
setTaskChecklistItemComplete: {
|
|
||||||
__typename: 'TaskChecklistItem',
|
|
||||||
id: itemID,
|
|
||||||
complete,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onAddItem={(taskChecklistID, name, position) => {
|
|
||||||
createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
|
|
||||||
}}
|
|
||||||
onMemberProfile={($targetRef, memberID) => {
|
|
||||||
const member = data.findTask.assigned.find(m => m.id === memberID);
|
|
||||||
if (member) {
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<Popup title={null} onClose={() => {}} tab={0}>
|
|
||||||
<MiniProfile
|
|
||||||
user={member}
|
|
||||||
bio="None"
|
|
||||||
onRemoveFromTask={() => {
|
|
||||||
if (user) {
|
|
||||||
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onOpenAddMemberPopup={(task, $targetRef) => {
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<Popup title="Members" tab={0} onClose={() => {}}>
|
|
||||||
<MemberManager
|
|
||||||
availableMembers={availableMembers}
|
|
||||||
activeMembers={data.findTask.assigned}
|
|
||||||
onMemberChange={(member, isActive) => {
|
|
||||||
if (user) {
|
|
||||||
if (isActive) {
|
|
||||||
assignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
|
|
||||||
} else {
|
|
||||||
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onOpenAddLabelPopup={onOpenAddLabelPopup}
|
|
||||||
onOpenAddChecklistPopup={(_task, $target) => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<Popup
|
|
||||||
title="Add checklist"
|
|
||||||
tab={0}
|
|
||||||
onClose={() => {
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CreateChecklistPopup
|
|
||||||
onCreateChecklist={checklistData => {
|
|
||||||
let position = 65535;
|
|
||||||
if (data.findTask.checklists) {
|
|
||||||
const [lastChecklist] = data.findTask.checklists.slice(-1);
|
|
||||||
if (lastChecklist) {
|
|
||||||
position = lastChecklist.position * 2 + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createTaskChecklist({
|
|
||||||
variables: {
|
|
||||||
taskID: data.findTask.id,
|
|
||||||
name: checklistData.name,
|
|
||||||
position,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onDeleteChecklist={($target, checklistID) => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<Popup tab={0} title="Delete checklist?" onClose={() => hidePopup()}>
|
|
||||||
<p>Deleting a checklist is permanent and there is no way to get it back.</p>
|
|
||||||
<DeleteChecklistButton
|
|
||||||
color="danger"
|
|
||||||
onClick={() => {
|
|
||||||
deleteTaskChecklist({ variables: { taskChecklistID: checklistID } });
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete Checklist
|
|
||||||
</DeleteChecklistButton>
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onOpenDueDatePopop={(task, $targetRef) => {
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<Popup
|
|
||||||
title={'Change Due Date'}
|
|
||||||
tab={0}
|
|
||||||
onClose={() => {
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DueDateManager
|
|
||||||
task={task}
|
|
||||||
onRemoveDueDate={t => {
|
|
||||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
onDueDateChange={(t, newDueDate) => {
|
|
||||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
onCancel={() => {}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Details;
|
|
@ -1,152 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import updateApolloCache from 'shared/utils/cache';
|
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
|
||||||
import produce from 'immer';
|
|
||||||
import {
|
|
||||||
useUpdateProjectMemberRoleMutation,
|
|
||||||
useCreateProjectMemberMutation,
|
|
||||||
useDeleteProjectMemberMutation,
|
|
||||||
useSetTaskCompleteMutation,
|
|
||||||
useToggleTaskLabelMutation,
|
|
||||||
useUpdateProjectNameMutation,
|
|
||||||
useFindProjectQuery,
|
|
||||||
useUpdateTaskGroupNameMutation,
|
|
||||||
useUpdateTaskNameMutation,
|
|
||||||
useUpdateProjectLabelMutation,
|
|
||||||
useCreateTaskMutation,
|
|
||||||
useDeleteProjectLabelMutation,
|
|
||||||
useDeleteTaskMutation,
|
|
||||||
useUpdateTaskLocationMutation,
|
|
||||||
useUpdateTaskGroupLocationMutation,
|
|
||||||
useCreateTaskGroupMutation,
|
|
||||||
useDeleteTaskGroupMutation,
|
|
||||||
useUpdateTaskDescriptionMutation,
|
|
||||||
useAssignTaskMutation,
|
|
||||||
DeleteTaskDocument,
|
|
||||||
FindProjectDocument,
|
|
||||||
useCreateProjectLabelMutation,
|
|
||||||
useUnassignTaskMutation,
|
|
||||||
useUpdateTaskDueDateMutation,
|
|
||||||
FindProjectQuery,
|
|
||||||
useUsersQuery,
|
|
||||||
} from 'shared/generated/graphql';
|
|
||||||
import LabelManager from 'shared/components/PopupMenu/LabelManager';
|
|
||||||
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
|
|
||||||
|
|
||||||
type LabelManagerEditorProps = {
|
|
||||||
labels: React.RefObject<Array<ProjectLabel>>;
|
|
||||||
taskLabels: null | React.RefObject<Array<TaskLabel>>;
|
|
||||||
projectID: string;
|
|
||||||
labelColors: Array<LabelColor>;
|
|
||||||
onLabelToggle?: (labelId: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
|
|
||||||
labels: labelsRef,
|
|
||||||
projectID,
|
|
||||||
labelColors,
|
|
||||||
onLabelToggle,
|
|
||||||
taskLabels: taskLabelsRef,
|
|
||||||
}) => {
|
|
||||||
const [currentLabel, setCurrentLabel] = useState('');
|
|
||||||
const { setTab, hidePopup } = usePopup();
|
|
||||||
const [createProjectLabel] = useCreateProjectLabelMutation({
|
|
||||||
update: (client, newLabelData) => {
|
|
||||||
updateApolloCache<FindProjectQuery>(
|
|
||||||
client,
|
|
||||||
FindProjectDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel });
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
projectID,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [updateProjectLabel] = useUpdateProjectLabelMutation();
|
|
||||||
const [deleteProjectLabel] = useDeleteProjectLabelMutation({
|
|
||||||
update: (client, newLabelData) => {
|
|
||||||
updateApolloCache<FindProjectQuery>(
|
|
||||||
client,
|
|
||||||
FindProjectDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.findProject.labels = cache.findProject.labels.filter(
|
|
||||||
label => label.id !== newLabelData.data.deleteProjectLabel.id,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
{ projectID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const labels = labelsRef.current ? labelsRef.current : [];
|
|
||||||
const taskLabels = taskLabelsRef && taskLabelsRef.current ? taskLabelsRef.current : [];
|
|
||||||
const [currentTaskLabels, setCurrentTaskLabels] = useState(taskLabels);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Popup title="Labels" tab={0} onClose={() => hidePopup()}>
|
|
||||||
<LabelManager
|
|
||||||
labels={labels}
|
|
||||||
taskLabels={currentTaskLabels}
|
|
||||||
onLabelCreate={() => {
|
|
||||||
setTab(2);
|
|
||||||
}}
|
|
||||||
onLabelEdit={labelId => {
|
|
||||||
setCurrentLabel(labelId);
|
|
||||||
setTab(1);
|
|
||||||
}}
|
|
||||||
onLabelToggle={labelId => {
|
|
||||||
if (onLabelToggle) {
|
|
||||||
if (currentTaskLabels.find(t => t.projectLabel.id === labelId)) {
|
|
||||||
setCurrentTaskLabels(currentTaskLabels.filter(t => t.projectLabel.id !== labelId));
|
|
||||||
} else {
|
|
||||||
const newProjectLabel = labels.find(l => l.id === labelId);
|
|
||||||
if (newProjectLabel) {
|
|
||||||
setCurrentTaskLabels([
|
|
||||||
...currentTaskLabels,
|
|
||||||
{ id: '', assignedDate: '', projectLabel: { ...newProjectLabel } },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setCurrentLabel(labelId);
|
|
||||||
onLabelToggle(labelId);
|
|
||||||
} else {
|
|
||||||
setCurrentLabel(labelId);
|
|
||||||
setTab(1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
<Popup onClose={() => hidePopup()} title="Edit label" tab={1}>
|
|
||||||
<LabelEditor
|
|
||||||
labelColors={labelColors}
|
|
||||||
label={labels.find(label => label.id === currentLabel) ?? null}
|
|
||||||
onLabelEdit={(projectLabelID, name, color) => {
|
|
||||||
if (projectLabelID) {
|
|
||||||
updateProjectLabel({ variables: { projectLabelID, labelColorID: color.id, name: name ?? '' } });
|
|
||||||
}
|
|
||||||
setTab(0);
|
|
||||||
}}
|
|
||||||
onLabelDelete={labelID => {
|
|
||||||
deleteProjectLabel({ variables: { projectLabelID: labelID } });
|
|
||||||
setTab(0);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
<Popup onClose={() => hidePopup()} title="Create new label" tab={2}>
|
|
||||||
<LabelEditor
|
|
||||||
labelColors={labelColors}
|
|
||||||
label={null}
|
|
||||||
onLabelEdit={(_labelId, name, color) => {
|
|
||||||
createProjectLabel({ variables: { projectID, labelColorID: color.id, name: name ?? '' } });
|
|
||||||
setTab(0);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LabelManagerEditor;
|
|
@ -1,301 +0,0 @@
|
|||||||
// LOC830
|
|
||||||
import React, { useState, useRef, useEffect, useContext } from 'react';
|
|
||||||
import updateApolloCache from 'shared/utils/cache';
|
|
||||||
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
|
|
||||||
import styled from 'styled-components/macro';
|
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
|
||||||
import LabelManagerEditor from './LabelManagerEditor';
|
|
||||||
import {
|
|
||||||
useParams,
|
|
||||||
Route,
|
|
||||||
useRouteMatch,
|
|
||||||
useHistory,
|
|
||||||
RouteComponentProps,
|
|
||||||
useLocation,
|
|
||||||
Redirect,
|
|
||||||
} from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
useUpdateProjectMemberRoleMutation,
|
|
||||||
useCreateProjectMemberMutation,
|
|
||||||
useDeleteProjectMemberMutation,
|
|
||||||
useToggleTaskLabelMutation,
|
|
||||||
useUpdateProjectNameMutation,
|
|
||||||
useFindProjectQuery,
|
|
||||||
useUpdateTaskNameMutation,
|
|
||||||
useCreateTaskMutation,
|
|
||||||
useDeleteTaskMutation,
|
|
||||||
useUpdateTaskLocationMutation,
|
|
||||||
useUpdateTaskGroupLocationMutation,
|
|
||||||
useCreateTaskGroupMutation,
|
|
||||||
useUpdateTaskDescriptionMutation,
|
|
||||||
FindProjectDocument,
|
|
||||||
FindProjectQuery,
|
|
||||||
} from 'shared/generated/graphql';
|
|
||||||
|
|
||||||
import produce from 'immer';
|
|
||||||
import UserContext, { useCurrentUser } from 'App/context';
|
|
||||||
import Input from 'shared/components/Input';
|
|
||||||
import Member from 'shared/components/Member';
|
|
||||||
import Board, { BoardLoading } from './Board';
|
|
||||||
import Details from './Details';
|
|
||||||
import EmptyBoard from 'shared/components/EmptyBoard';
|
|
||||||
|
|
||||||
const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant';
|
|
||||||
|
|
||||||
const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => {
|
|
||||||
const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || '');
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
localStorage.setItem(localStorageKey, value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return [value, setValue];
|
|
||||||
};
|
|
||||||
|
|
||||||
const SearchInput = styled(Input)`
|
|
||||||
margin: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const UserMember = styled(Member)`
|
|
||||||
padding: 4px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
background: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
|
||||||
}
|
|
||||||
border-radius: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberList = styled.div`
|
|
||||||
margin: 8px 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type UserManagementPopupProps = {
|
|
||||||
users: Array<User>;
|
|
||||||
projectMembers: Array<TaskUser>;
|
|
||||||
onAddProjectMember: (userID: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, projectMembers, onAddProjectMember }) => {
|
|
||||||
return (
|
|
||||||
<Popup tab={0} title="Invite a user">
|
|
||||||
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
|
|
||||||
<MemberList>
|
|
||||||
{users
|
|
||||||
.filter(u => u.id !== projectMembers.find(p => p.id === u.id)?.id)
|
|
||||||
.map(user => (
|
|
||||||
<UserMember
|
|
||||||
key={user.id}
|
|
||||||
onCardMemberClick={() => onAddProjectMember(user.id)}
|
|
||||||
showName
|
|
||||||
member={user}
|
|
||||||
taskID=""
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</MemberList>
|
|
||||||
</Popup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type TaskRouteProps = {
|
|
||||||
taskID: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface QuickCardEditorState {
|
|
||||||
isOpen: boolean;
|
|
||||||
target: React.RefObject<HTMLElement> | null;
|
|
||||||
taskID: string | null;
|
|
||||||
taskGroupID: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectParams {
|
|
||||||
projectID: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialQuickCardEditorState: QuickCardEditorState = {
|
|
||||||
taskID: null,
|
|
||||||
taskGroupID: null,
|
|
||||||
isOpen: false,
|
|
||||||
target: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const Project = () => {
|
|
||||||
const { projectID } = useParams<ProjectParams>();
|
|
||||||
const history = useHistory();
|
|
||||||
const match = useRouteMatch();
|
|
||||||
|
|
||||||
const [updateTaskDescription] = useUpdateTaskDescriptionMutation();
|
|
||||||
const [toggleTaskLabel] = useToggleTaskLabelMutation({
|
|
||||||
onCompleted: newTaskLabel => {
|
|
||||||
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY);
|
|
||||||
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
|
|
||||||
|
|
||||||
const [deleteTask] = useDeleteTaskMutation();
|
|
||||||
|
|
||||||
const [updateTaskName] = useUpdateTaskNameMutation();
|
|
||||||
|
|
||||||
const { loading, data } = useFindProjectQuery({
|
|
||||||
variables: { projectID },
|
|
||||||
});
|
|
||||||
|
|
||||||
const [updateProjectName] = useUpdateProjectNameMutation({
|
|
||||||
update: (client, newName) => {
|
|
||||||
updateApolloCache<FindProjectQuery>(
|
|
||||||
client,
|
|
||||||
FindProjectDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.findProject.name = newName.data.updateProjectName.name;
|
|
||||||
}),
|
|
||||||
{ projectID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [createProjectMember] = useCreateProjectMemberMutation({
|
|
||||||
update: (client, response) => {
|
|
||||||
updateApolloCache<FindProjectQuery>(
|
|
||||||
client,
|
|
||||||
FindProjectDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.findProject.members.push({ ...response.data.createProjectMember.member });
|
|
||||||
}),
|
|
||||||
{ projectID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [deleteProjectMember] = useDeleteProjectMemberMutation({
|
|
||||||
update: (client, response) => {
|
|
||||||
updateApolloCache<FindProjectQuery>(
|
|
||||||
client,
|
|
||||||
FindProjectDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.findProject.members = cache.findProject.members.filter(
|
|
||||||
m => m.id !== response.data.deleteProjectMember.member.id,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
{ projectID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { user } = useCurrentUser();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const { showPopup, hidePopup } = usePopup();
|
|
||||||
const $labelsRef = useRef<HTMLDivElement>(null);
|
|
||||||
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
|
||||||
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
document.title = `${data.findProject.name} | Taskcafé`;
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<GlobalTopNavbar onSaveProjectName={projectName => {}} name="" projectID={null} />
|
|
||||||
<BoardLoading />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (data) {
|
|
||||||
labelsRef.current = data.findProject.labels;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<GlobalTopNavbar
|
|
||||||
onChangeRole={(userID, roleCode) => {
|
|
||||||
updateProjectMemberRole({ variables: { userID, roleCode, projectID } });
|
|
||||||
}}
|
|
||||||
onChangeProjectOwner={uid => {
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
onRemoveFromBoard={userID => {
|
|
||||||
deleteProjectMember({ variables: { userID, projectID } });
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
onSaveProjectName={projectName => {
|
|
||||||
updateProjectName({ variables: { projectID, name: projectName } });
|
|
||||||
}}
|
|
||||||
onInviteUser={$target => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<UserManagementPopup
|
|
||||||
onAddProjectMember={userID => {
|
|
||||||
createProjectMember({ variables: { userID, projectID } });
|
|
||||||
}}
|
|
||||||
users={data.users}
|
|
||||||
projectMembers={data.findProject.members}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
popupContent={<ProjectPopup history={history} name={data.findProject.name} projectID={projectID} />}
|
|
||||||
menuType={[{ name: 'Board', link: location.pathname }]}
|
|
||||||
currentTab={0}
|
|
||||||
projectMembers={data.findProject.members}
|
|
||||||
projectID={projectID}
|
|
||||||
teamID={data.findProject.team.id}
|
|
||||||
name={data.findProject.name}
|
|
||||||
/>
|
|
||||||
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />
|
|
||||||
<Route
|
|
||||||
path={`${match.path}/board`}
|
|
||||||
render={() => (
|
|
||||||
<Board
|
|
||||||
cardLabelVariant={value === 'small' ? 'small' : 'large'}
|
|
||||||
onCardLabelClick={() => {
|
|
||||||
const variant = value === 'small' ? 'large' : 'small';
|
|
||||||
setValue(() => variant);
|
|
||||||
}}
|
|
||||||
projectID={projectID}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path={`${match.path}/board/c/:taskID`}
|
|
||||||
render={(routeProps: RouteComponentProps<TaskRouteProps>) => (
|
|
||||||
<Details
|
|
||||||
refreshCache={() => {}}
|
|
||||||
availableMembers={data.findProject.members}
|
|
||||||
projectURL={`${match.url}/board`}
|
|
||||||
taskID={routeProps.match.params.taskID}
|
|
||||||
onTaskNameChange={(updatedTask, newName) => {
|
|
||||||
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
|
|
||||||
}}
|
|
||||||
onTaskDescriptionChange={(updatedTask, newDescription) => {
|
|
||||||
updateTaskDescription({ variables: { taskID: updatedTask.id, description: newDescription } });
|
|
||||||
}}
|
|
||||||
onDeleteTask={deletedTask => {
|
|
||||||
deleteTask({ variables: { taskID: deletedTask.id } });
|
|
||||||
}}
|
|
||||||
onOpenAddLabelPopup={(task, $targetRef) => {
|
|
||||||
taskLabelsRef.current = task.labels;
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<LabelManagerEditor
|
|
||||||
onLabelToggle={labelID => {
|
|
||||||
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
|
|
||||||
}}
|
|
||||||
labelColors={data.labelColors}
|
|
||||||
labels={labelsRef}
|
|
||||||
taskLabels={taskLabelsRef}
|
|
||||||
projectID={projectID}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <div>Error</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Project;
|
|
@ -1,422 +0,0 @@
|
|||||||
import React, { useState, useContext, useEffect } from 'react';
|
|
||||||
import styled from 'styled-components/macro';
|
|
||||||
import GlobalTopNavbar from 'App/TopNavbar';
|
|
||||||
import Empty from 'shared/undraw/Empty';
|
|
||||||
import {
|
|
||||||
useCreateTeamMutation,
|
|
||||||
useGetProjectsQuery,
|
|
||||||
useCreateProjectMutation,
|
|
||||||
GetProjectsDocument,
|
|
||||||
GetProjectsQuery,
|
|
||||||
} from 'shared/generated/graphql';
|
|
||||||
|
|
||||||
import ProjectGridItem, { AddProjectItem } from 'shared/components/ProjectGridItem';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import NewProject from 'shared/components/NewProject';
|
|
||||||
import UserContext, { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
|
|
||||||
import Button from 'shared/components/Button';
|
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import Input from 'shared/components/Input';
|
|
||||||
import updateApolloCache from 'shared/utils/cache';
|
|
||||||
import produce from 'immer';
|
|
||||||
const EmptyStateContent = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justy-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const EmptyStateTitle = styled.h3`
|
|
||||||
color: #fff;
|
|
||||||
font-size: 18px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const EmptyStatePrompt = styled.span`
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
font-size: 16px;
|
|
||||||
margin-top: 8px;
|
|
||||||
`;
|
|
||||||
const EmptyState = styled(Empty)`
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto;
|
|
||||||
`;
|
|
||||||
const CreateTeamButton = styled(Button)`
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
type CreateTeamData = { teamName: string };
|
|
||||||
type CreateTeamFormProps = {
|
|
||||||
onCreateTeam: (teamName: string) => void;
|
|
||||||
};
|
|
||||||
const CreateTeamFormContainer = styled.form``;
|
|
||||||
|
|
||||||
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
|
|
||||||
const { register, handleSubmit, errors } = useForm<CreateTeamData>();
|
|
||||||
const createTeam = (data: CreateTeamData) => {
|
|
||||||
onCreateTeam(data.teamName);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<CreateTeamFormContainer onSubmit={handleSubmit(createTeam)}>
|
|
||||||
<Input
|
|
||||||
width="100%"
|
|
||||||
label="Team name"
|
|
||||||
id="teamName"
|
|
||||||
name="teamName"
|
|
||||||
variant="alternate"
|
|
||||||
ref={register({ required: 'Team name is required' })}
|
|
||||||
/>
|
|
||||||
<CreateTeamButton type="submit">Create</CreateTeamButton>
|
|
||||||
</CreateTeamFormContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProjectAddTile = styled.div`
|
|
||||||
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
|
||||||
background-size: cover;
|
|
||||||
background-position: 50%;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 20px;
|
|
||||||
padding: 8px;
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
border-radius: 3px;
|
|
||||||
display: block;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectTile = styled(Link)<{ color: string }>`
|
|
||||||
background-color: ${props => props.color};
|
|
||||||
background-size: cover;
|
|
||||||
background-position: 50%;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 20px;
|
|
||||||
padding: 8px;
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
border-radius: 3px;
|
|
||||||
display: block;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectTileFade = styled.div`
|
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectListItem = styled.li`
|
|
||||||
width: 23.5%;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 2% 2% 0;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover ${ProjectTileFade} {
|
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectList = styled.ul`
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
& ${ProjectListItem}:nth-of-type(4n) {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectTileDetails = styled.div`
|
|
||||||
display: flex;
|
|
||||||
height: 80px;
|
|
||||||
position: relative;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectAddTileDetails = styled.div`
|
|
||||||
display: flex;
|
|
||||||
height: 80px;
|
|
||||||
position: relative;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectTileName = styled.div<{ centered?: boolean }>`
|
|
||||||
flex: 0 0 auto;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
display: inline-block;
|
|
||||||
overflow: hidden;
|
|
||||||
max-height: 40px;
|
|
||||||
width: 100%;
|
|
||||||
word-wrap: break-word;
|
|
||||||
${props => props.centered && 'text-align: center;'}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectSectionTitleWrapper = styled.div`
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 32px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
padding: 8px 0;
|
|
||||||
position: relative;
|
|
||||||
margin-top: 16px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SectionActions = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SectionAction = styled(Button)`
|
|
||||||
padding: 6px 12px;
|
|
||||||
`;
|
|
||||||
const SectionActionLink = styled(Link)`
|
|
||||||
margin-right: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectSectionTitle = styled.h3`
|
|
||||||
font-size: 16px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectsContainer = styled.div`
|
|
||||||
margin: 40px 16px 0;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 825px;
|
|
||||||
min-width: 288px;
|
|
||||||
`;
|
|
||||||
const ProjectGrid = styled.div`
|
|
||||||
max-width: 780px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 240px 240px 240px;
|
|
||||||
gap: 20px 10px;
|
|
||||||
`;
|
|
||||||
const AddTeamButton = styled(Button)`
|
|
||||||
padding: 6px 12px;
|
|
||||||
position: absolute;
|
|
||||||
top: 6px;
|
|
||||||
right: 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const CreateFirstTeam = styled(Button)`
|
|
||||||
margin-top: 8px;
|
|
||||||
`;
|
|
||||||
type ShowNewProject = {
|
|
||||||
open: boolean;
|
|
||||||
initialTeamID: null | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProjectLink = styled(Link)``;
|
|
||||||
|
|
||||||
const Projects = () => {
|
|
||||||
const { showPopup, hidePopup } = usePopup();
|
|
||||||
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' });
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = 'Taskcafé';
|
|
||||||
}, []);
|
|
||||||
const [createProject] = useCreateProjectMutation({
|
|
||||||
update: (client, newProject) => {
|
|
||||||
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.projects.push({ ...newProject.data.createProject });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [showNewProject, setShowNewProject] = useState<ShowNewProject>({ open: false, initialTeamID: null });
|
|
||||||
const { user, setUser } = useCurrentUser();
|
|
||||||
const [createTeam] = useCreateTeamMutation({
|
|
||||||
update: (client, createData) => {
|
|
||||||
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.teams.push({ ...createData.data.createTeam });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span>loading</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
|
|
||||||
if (data && user) {
|
|
||||||
const { projects, teams, organizations } = data;
|
|
||||||
const organizationID = organizations[0].id ?? null;
|
|
||||||
const projectTeams = teams
|
|
||||||
.sort((a, b) => {
|
|
||||||
const textA = a.name.toUpperCase();
|
|
||||||
const textB = b.name.toUpperCase();
|
|
||||||
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
|
||||||
})
|
|
||||||
.map(team => {
|
|
||||||
return {
|
|
||||||
id: team.id,
|
|
||||||
name: team.name,
|
|
||||||
projects: projects
|
|
||||||
.filter(project => project.team.id === team.id)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const textA = a.name.toUpperCase();
|
|
||||||
const textB = b.name.toUpperCase();
|
|
||||||
return textA < textB ? -1 : textA > textB ? 1 : 0;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<GlobalTopNavbar onSaveProjectName={() => {}} projectID={null} name={null} />
|
|
||||||
<Wrapper>
|
|
||||||
<ProjectsContainer>
|
|
||||||
{user.roles.org === 'admin' && (
|
|
||||||
<AddTeamButton
|
|
||||||
variant="outline"
|
|
||||||
onClick={$target => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<Popup
|
|
||||||
title="Create team"
|
|
||||||
tab={0}
|
|
||||||
onClose={() => {
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CreateTeamForm
|
|
||||||
onCreateTeam={teamName => {
|
|
||||||
if (organizationID) {
|
|
||||||
createTeam({ variables: { name: teamName, organizationID } });
|
|
||||||
hidePopup();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Team
|
|
||||||
</AddTeamButton>
|
|
||||||
)}
|
|
||||||
{projectTeams.length === 0 && (
|
|
||||||
<EmptyStateContent>
|
|
||||||
<EmptyState width={425} height={425} />
|
|
||||||
<EmptyStateTitle>No teams exist</EmptyStateTitle>
|
|
||||||
<EmptyStatePrompt>Create a new team to get started</EmptyStatePrompt>
|
|
||||||
<CreateFirstTeam
|
|
||||||
variant="outline"
|
|
||||||
onClick={$target => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<Popup
|
|
||||||
title="Create team"
|
|
||||||
tab={0}
|
|
||||||
onClose={() => {
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CreateTeamForm
|
|
||||||
onCreateTeam={teamName => {
|
|
||||||
if (organizationID) {
|
|
||||||
createTeam({ variables: { name: teamName, organizationID } });
|
|
||||||
hidePopup();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create new team
|
|
||||||
</CreateFirstTeam>
|
|
||||||
</EmptyStateContent>
|
|
||||||
)}
|
|
||||||
{projectTeams.map(team => {
|
|
||||||
return (
|
|
||||||
<div key={team.id}>
|
|
||||||
<ProjectSectionTitleWrapper>
|
|
||||||
<ProjectSectionTitle>{team.name}</ProjectSectionTitle>
|
|
||||||
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
|
|
||||||
<SectionActions>
|
|
||||||
<SectionActionLink to={`/teams/${team.id}`}>
|
|
||||||
<SectionAction variant="outline">Projects</SectionAction>
|
|
||||||
</SectionActionLink>
|
|
||||||
<SectionActionLink to={`/teams/${team.id}/members`}>
|
|
||||||
<SectionAction variant="outline">Members</SectionAction>
|
|
||||||
</SectionActionLink>
|
|
||||||
<SectionActionLink to={`/teams/${team.id}/settings`}>
|
|
||||||
<SectionAction variant="outline">Settings</SectionAction>
|
|
||||||
</SectionActionLink>
|
|
||||||
</SectionActions>
|
|
||||||
)}
|
|
||||||
</ProjectSectionTitleWrapper>
|
|
||||||
<ProjectList>
|
|
||||||
{team.projects.map((project, idx) => (
|
|
||||||
<ProjectListItem key={project.id}>
|
|
||||||
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
|
|
||||||
<ProjectTileFade />
|
|
||||||
<ProjectTileDetails>
|
|
||||||
<ProjectTileName>{project.name}</ProjectTileName>
|
|
||||||
</ProjectTileDetails>
|
|
||||||
</ProjectTile>
|
|
||||||
</ProjectListItem>
|
|
||||||
))}
|
|
||||||
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
|
|
||||||
<ProjectListItem>
|
|
||||||
<ProjectAddTile
|
|
||||||
onClick={() => {
|
|
||||||
setShowNewProject({ open: true, initialTeamID: team.id });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ProjectTileFade />
|
|
||||||
<ProjectAddTileDetails>
|
|
||||||
<ProjectTileName centered>Create new project</ProjectTileName>
|
|
||||||
</ProjectAddTileDetails>
|
|
||||||
</ProjectAddTile>
|
|
||||||
</ProjectListItem>
|
|
||||||
)}
|
|
||||||
</ProjectList>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{showNewProject.open && (
|
|
||||||
<NewProject
|
|
||||||
initialTeamID={showNewProject.initialTeamID}
|
|
||||||
onCreateProject={(name, teamID) => {
|
|
||||||
if (user) {
|
|
||||||
createProject({ variables: { teamID, name, userID: user.id } });
|
|
||||||
setShowNewProject({ open: false, initialTeamID: null });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClose={() => {
|
|
||||||
setShowNewProject({ open: false, initialTeamID: null });
|
|
||||||
}}
|
|
||||||
teams={teams}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ProjectsContainer>
|
|
||||||
</Wrapper>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <div>Error!</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Projects;
|
|
@ -1,560 +0,0 @@
|
|||||||
import React, { useState, useContext } from 'react';
|
|
||||||
import Input from 'shared/components/Input';
|
|
||||||
import updateApolloCache from 'shared/utils/cache';
|
|
||||||
import produce from 'immer';
|
|
||||||
import Button from 'shared/components/Button';
|
|
||||||
import UserContext, { useCurrentUser, PermissionLevel, PermissionObjectType } from 'App/context';
|
|
||||||
import Select from 'shared/components/Select';
|
|
||||||
import {
|
|
||||||
useGetTeamQuery,
|
|
||||||
RoleCode,
|
|
||||||
useCreateTeamMemberMutation,
|
|
||||||
useDeleteTeamMemberMutation,
|
|
||||||
useUpdateTeamMemberRoleMutation,
|
|
||||||
GetTeamQuery,
|
|
||||||
GetTeamDocument,
|
|
||||||
MeDocument,
|
|
||||||
MeQuery,
|
|
||||||
} from 'shared/generated/graphql';
|
|
||||||
import { UserPlus, Checkmark } from 'shared/icons';
|
|
||||||
import styled, { css } from 'styled-components/macro';
|
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
|
||||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
|
||||||
import Member from 'shared/components/Member';
|
|
||||||
import ControlledInput from 'shared/components/ControlledInput';
|
|
||||||
|
|
||||||
const MemberListWrapper = styled.div`
|
|
||||||
flex: 1 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SearchInput = styled(ControlledInput)`
|
|
||||||
margin: 0 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const UserMember = styled(Member)`
|
|
||||||
padding: 4px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
background: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
|
||||||
}
|
|
||||||
border-radius: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TeamMemberList = styled.div`
|
|
||||||
margin: 8px 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type UserManagementPopupProps = {
|
|
||||||
users: Array<User>;
|
|
||||||
teamMembers: Array<TaskUser>;
|
|
||||||
onAddTeamMember: (userID: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, teamMembers, onAddTeamMember }) => {
|
|
||||||
return (
|
|
||||||
<Popup tab={0} title="Invite a user">
|
|
||||||
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
|
|
||||||
<TeamMemberList>
|
|
||||||
{users
|
|
||||||
.filter(u => u.id !== teamMembers.find(p => p.id === u.id)?.id)
|
|
||||||
.map(user => (
|
|
||||||
<UserMember
|
|
||||||
key={user.id}
|
|
||||||
onCardMemberClick={() => onAddTeamMember(user.id)}
|
|
||||||
showName
|
|
||||||
member={user}
|
|
||||||
taskID=""
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TeamMemberList>
|
|
||||||
</Popup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RoleCheckmark = styled(Checkmark)`
|
|
||||||
padding-left: 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const permissions = [
|
|
||||||
{
|
|
||||||
code: 'owner',
|
|
||||||
name: 'Owner',
|
|
||||||
description:
|
|
||||||
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team. Can delete the team and all team projects.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'admin',
|
|
||||||
name: 'Admin',
|
|
||||||
description:
|
|
||||||
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team.',
|
|
||||||
},
|
|
||||||
|
|
||||||
{ code: 'member', name: 'Member', description: 'Can view, create, and edit team projects, but not change settings.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const RoleName = styled.div`
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
`;
|
|
||||||
export const RoleDescription = styled.div`
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const MiniProfileActions = styled.ul`
|
|
||||||
list-style-type: none;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const MiniProfileActionWrapper = styled.li``;
|
|
||||||
|
|
||||||
export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
|
|
||||||
color: #c2c6dc;
|
|
||||||
display: block;
|
|
||||||
font-weight: 400;
|
|
||||||
padding: 6px 12px;
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
${props =>
|
|
||||||
props.disabled
|
|
||||||
? css`
|
|
||||||
user-select: none;
|
|
||||||
pointer-events: none;
|
|
||||||
color: rgba(${props.theme.colors.text.primary}, 0.4);
|
|
||||||
`
|
|
||||||
: css`
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
background: rgb(115, 103, 240);
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
export const Content = styled.div`
|
|
||||||
padding: 0 12px 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const CurrentPermission = styled.span`
|
|
||||||
margin-left: 4px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary}, 0.4);
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Separator = styled.div`
|
|
||||||
height: 1px;
|
|
||||||
border-top: 1px solid #414561;
|
|
||||||
margin: 0.25rem !important;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const WarningText = styled.span`
|
|
||||||
display: flex;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
|
|
||||||
padding: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DeleteDescription = styled.div`
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const RemoveMemberButton = styled(Button)`
|
|
||||||
margin-top: 16px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
type TeamRoleManagerPopupProps = {
|
|
||||||
currentUserID: string;
|
|
||||||
subject: User;
|
|
||||||
members: Array<User>;
|
|
||||||
warning?: string | null;
|
|
||||||
canChangeRole: boolean;
|
|
||||||
onChangeRole: (roleCode: RoleCode) => void;
|
|
||||||
onRemoveFromTeam?: (newOwnerID: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
|
||||||
members,
|
|
||||||
warning,
|
|
||||||
subject,
|
|
||||||
currentUserID,
|
|
||||||
canChangeRole,
|
|
||||||
onRemoveFromTeam,
|
|
||||||
onChangeRole,
|
|
||||||
}) => {
|
|
||||||
const { hidePopup, setTab } = usePopup();
|
|
||||||
const [orphanedProjectOwner, setOrphanedProjectOwner] = useState<{ label: string; value: string } | null>(null);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Popup title={null} tab={0}>
|
|
||||||
<MiniProfileActions>
|
|
||||||
<MiniProfileActionWrapper>
|
|
||||||
{subject.role && (
|
|
||||||
<MiniProfileActionItem
|
|
||||||
onClick={() => {
|
|
||||||
setTab(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Change permissions...
|
|
||||||
<CurrentPermission>{`(${subject.role.name})`}</CurrentPermission>
|
|
||||||
</MiniProfileActionItem>
|
|
||||||
)}
|
|
||||||
{onRemoveFromTeam && (
|
|
||||||
<MiniProfileActionItem
|
|
||||||
onClick={() => {
|
|
||||||
setTab(2);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentUserID === subject.id ? 'Leave team...' : 'Remove from team...'}
|
|
||||||
</MiniProfileActionItem>
|
|
||||||
)}
|
|
||||||
</MiniProfileActionWrapper>
|
|
||||||
</MiniProfileActions>
|
|
||||||
{warning && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<WarningText>{warning}</WarningText>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Popup>
|
|
||||||
<Popup title="Change Permissions" onClose={() => hidePopup()} tab={1}>
|
|
||||||
<MiniProfileActions>
|
|
||||||
<MiniProfileActionWrapper>
|
|
||||||
{permissions
|
|
||||||
.filter(p => (subject.role && subject.role.code === 'owner') || p.code !== 'owner')
|
|
||||||
.map(perm => (
|
|
||||||
<MiniProfileActionItem
|
|
||||||
disabled={subject.role && perm.code !== subject.role.code && !canChangeRole}
|
|
||||||
key={perm.code}
|
|
||||||
onClick={() => {
|
|
||||||
if (onChangeRole && subject.role && perm.code !== subject.role.code) {
|
|
||||||
switch (perm.code) {
|
|
||||||
case 'owner':
|
|
||||||
onChangeRole(RoleCode.Owner);
|
|
||||||
break;
|
|
||||||
case 'admin':
|
|
||||||
onChangeRole(RoleCode.Admin);
|
|
||||||
break;
|
|
||||||
case 'member':
|
|
||||||
onChangeRole(RoleCode.Member);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
hidePopup();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RoleName>
|
|
||||||
{perm.name}
|
|
||||||
{subject.role && perm.code === subject.role.code && <RoleCheckmark width={12} height={12} />}
|
|
||||||
</RoleName>
|
|
||||||
<RoleDescription>{perm.description}</RoleDescription>
|
|
||||||
</MiniProfileActionItem>
|
|
||||||
))}
|
|
||||||
</MiniProfileActionWrapper>
|
|
||||||
{subject.role && subject.role.code === 'owner' && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<WarningText>You can't change roles because there must be an owner.</WarningText>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</MiniProfileActions>
|
|
||||||
</Popup>
|
|
||||||
<Popup title="Remove from Team?" onClose={() => hidePopup()} tab={2}>
|
|
||||||
<Content>
|
|
||||||
<DeleteDescription>
|
|
||||||
The member will be removed from all team project tasks. They will receive a notification.
|
|
||||||
</DeleteDescription>
|
|
||||||
{subject.owned && subject.owned.projects.length !== 0 && (
|
|
||||||
<>
|
|
||||||
<DeleteDescription>
|
|
||||||
{`The member is the owner of ${subject.owned.projects.length} project${
|
|
||||||
subject.owned.projects.length > 1 ? 's' : ''
|
|
||||||
}. You can give the projects a new owner but it is not needed`}
|
|
||||||
</DeleteDescription>
|
|
||||||
<Select
|
|
||||||
label="New projects owner"
|
|
||||||
value={orphanedProjectOwner}
|
|
||||||
onChange={value => setOrphanedProjectOwner(value)}
|
|
||||||
options={members.filter(m => m.id !== subject.id).map(m => ({ label: m.fullName, value: m.id }))}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<RemoveMemberButton
|
|
||||||
color="danger"
|
|
||||||
onClick={() => {
|
|
||||||
if (onRemoveFromTeam) {
|
|
||||||
onRemoveFromTeam(orphanedProjectOwner ? orphanedProjectOwner.value : null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove Member
|
|
||||||
</RemoveMemberButton>
|
|
||||||
</Content>
|
|
||||||
</Popup>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MemberItemOptions = styled.div``;
|
|
||||||
|
|
||||||
const MemberItemOption = styled(Button)`
|
|
||||||
padding: 7px 9px;
|
|
||||||
margin: 4px 0 2px 8px;
|
|
||||||
float: left;
|
|
||||||
min-width: 95px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberList = styled.div`
|
|
||||||
border-top: 1px solid rgba(${props => props.theme.colors.border});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberListItem = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-bottom: 1px solid rgba(${props => props.theme.colors.border});
|
|
||||||
min-height: 40px;
|
|
||||||
padding: 12px 0 12px 40px;
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberListItemDetails = styled.div`
|
|
||||||
float: left;
|
|
||||||
flex: 1 0 auto;
|
|
||||||
padding-left: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const InviteIcon = styled(UserPlus)`
|
|
||||||
padding-right: 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberProfile = styled(TaskAssignee)`
|
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
left: 0;
|
|
||||||
margin: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberItemName = styled.p`
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberItemUsername = styled.p`
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberListHeader = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
`;
|
|
||||||
const ListTitle = styled.h3`
|
|
||||||
font-size: 18px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary});
|
|
||||||
margin-bottom: 12px;
|
|
||||||
`;
|
|
||||||
const ListDesc = styled.span`
|
|
||||||
font-size: 16px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
`;
|
|
||||||
const FilterSearch = styled(Input)`
|
|
||||||
margin: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ListActions = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const InviteMemberButton = styled(Button)`
|
|
||||||
padding: 6px 12px;
|
|
||||||
`;
|
|
||||||
const FilterTab = styled.div`
|
|
||||||
max-width: 240px;
|
|
||||||
flex: 0 0 240px;
|
|
||||||
margin: 0;
|
|
||||||
padding-right: 32px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FilterTabItems = styled.ul``;
|
|
||||||
const FilterTabItem = styled.li`
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: block;
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 6px 8px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
&:hover {
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(${props => props.theme.colors.primary});
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary});
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FilterTabTitle = styled.h2`
|
|
||||||
color: #5e6c84;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
line-height: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 8px;
|
|
||||||
margin: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberContainer = styled.div`
|
|
||||||
margin-top: 45px;
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type MembersProps = {
|
|
||||||
teamID: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Members: React.FC<MembersProps> = ({ teamID }) => {
|
|
||||||
const { showPopup, hidePopup } = usePopup();
|
|
||||||
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
|
|
||||||
const { user, setUserRoles } = useCurrentUser();
|
|
||||||
const warning =
|
|
||||||
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
|
||||||
const [createTeamMember] = useCreateTeamMemberMutation({
|
|
||||||
update: (client, response) => {
|
|
||||||
updateApolloCache<GetTeamQuery>(
|
|
||||||
client,
|
|
||||||
GetTeamDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.findTeam.members.push({
|
|
||||||
...response.data.createTeamMember.teamMember,
|
|
||||||
member: { __typename: 'MemberList', projects: [], teams: [] },
|
|
||||||
owned: { __typename: 'OwnedList', projects: [], teams: [] },
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
{ teamID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [updateTeamMemberRole] = useUpdateTeamMemberRoleMutation({
|
|
||||||
onCompleted: r => {
|
|
||||||
if (user) {
|
|
||||||
setUserRoles(
|
|
||||||
produce(user.roles, draftRoles => {
|
|
||||||
draftRoles.teams.set(r.updateTeamMemberRole.teamID, r.updateTeamMemberRole.member.role.code);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [deleteTeamMember] = useDeleteTeamMemberMutation({
|
|
||||||
update: (client, response) => {
|
|
||||||
updateApolloCache<GetTeamQuery>(
|
|
||||||
client,
|
|
||||||
GetTeamDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.findTeam.members = cache.findTeam.members.filter(
|
|
||||||
member => member.id !== response.data.deleteTeamMember.userID,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
{ teamID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (loading) {
|
|
||||||
return <span>loading</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data && user) {
|
|
||||||
return (
|
|
||||||
<MemberContainer>
|
|
||||||
<FilterTab>
|
|
||||||
<FilterTabTitle>MEMBERS OF TEAM PROJECTS</FilterTabTitle>
|
|
||||||
<FilterTabItems>
|
|
||||||
<FilterTabItem>{`Team Members (${data.findTeam.members.length})`}</FilterTabItem>
|
|
||||||
<FilterTabItem>Observers</FilterTabItem>
|
|
||||||
</FilterTabItems>
|
|
||||||
</FilterTab>
|
|
||||||
<MemberListWrapper>
|
|
||||||
<MemberListHeader>
|
|
||||||
<ListTitle>{`Team Members (${data.findTeam.members.length})`}</ListTitle>
|
|
||||||
<ListDesc>
|
|
||||||
Team members can view and join all Team Visible boards and create new boards in the team.
|
|
||||||
</ListDesc>
|
|
||||||
<ListActions>
|
|
||||||
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
|
|
||||||
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, data.findTeam.id) && (
|
|
||||||
<InviteMemberButton
|
|
||||||
onClick={$target => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<UserManagementPopup
|
|
||||||
users={data.users}
|
|
||||||
teamMembers={data.findTeam.members}
|
|
||||||
onAddTeamMember={userID => {
|
|
||||||
createTeamMember({ variables: { userID, teamID } });
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InviteIcon width={16} height={16} />
|
|
||||||
Invite Team Members
|
|
||||||
</InviteMemberButton>
|
|
||||||
)}
|
|
||||||
</ListActions>
|
|
||||||
</MemberListHeader>
|
|
||||||
<MemberList>
|
|
||||||
{data.findTeam.members.map(member => (
|
|
||||||
<MemberListItem>
|
|
||||||
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} />
|
|
||||||
<MemberListItemDetails>
|
|
||||||
<MemberItemName>{member.fullName}</MemberItemName>
|
|
||||||
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
|
|
||||||
</MemberListItemDetails>
|
|
||||||
<MemberItemOptions>
|
|
||||||
<MemberItemOption variant="flat">On 2 projects</MemberItemOption>
|
|
||||||
<MemberItemOption
|
|
||||||
variant="outline"
|
|
||||||
onClick={$target => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<TeamRoleManagerPopup
|
|
||||||
currentUserID={user.id ?? ''}
|
|
||||||
subject={member}
|
|
||||||
members={data.findTeam.members}
|
|
||||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
|
||||||
canChangeRole={user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)}
|
|
||||||
onChangeRole={roleCode => {
|
|
||||||
updateTeamMemberRole({ variables: { userID: member.id, teamID, roleCode } });
|
|
||||||
}}
|
|
||||||
onRemoveFromTeam={
|
|
||||||
member.role && member.role.code === 'owner'
|
|
||||||
? undefined
|
|
||||||
: newOwnerID => {
|
|
||||||
deleteTeamMember({ variables: { teamID, newOwnerID, userID: member.id } });
|
|
||||||
hidePopup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Manage
|
|
||||||
</MemberItemOption>
|
|
||||||
</MemberItemOptions>
|
|
||||||
</MemberListItem>
|
|
||||||
))}
|
|
||||||
</MemberList>
|
|
||||||
</MemberListWrapper>
|
|
||||||
</MemberContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div>error</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Members;
|
|
@ -1,194 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import styled, { css } from 'styled-components/macro';
|
|
||||||
import {
|
|
||||||
useGetTeamQuery,
|
|
||||||
useDeleteTeamMutation,
|
|
||||||
GetProjectsDocument,
|
|
||||||
GetProjectsQuery,
|
|
||||||
} from 'shared/generated/graphql';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import Input from 'shared/components/Input';
|
|
||||||
|
|
||||||
const FilterSearch = styled(Input)`
|
|
||||||
margin: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectsContainer = styled.div`
|
|
||||||
margin-top: 45px;
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FilterTab = styled.div`
|
|
||||||
max-width: 240px;
|
|
||||||
flex: 0 0 240px;
|
|
||||||
margin: 0;
|
|
||||||
padding-right: 32px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FilterTabItems = styled.ul``;
|
|
||||||
const FilterTabItem = styled.li`
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 3px;
|
|
||||||
display: block;
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 6px 8px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
&:hover {
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(${props => props.theme.colors.primary});
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary});
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FilterTabTitle = styled.h2`
|
|
||||||
color: #5e6c84;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
line-height: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 8px;
|
|
||||||
margin: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectAddTile = styled.div`
|
|
||||||
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
|
||||||
background-size: cover;
|
|
||||||
background-position: 50%;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 20px;
|
|
||||||
padding: 8px;
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
border-radius: 3px;
|
|
||||||
display: block;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectTile = styled(Link)<{ color: string }>`
|
|
||||||
background-color: ${props => props.color};
|
|
||||||
background-size: cover;
|
|
||||||
background-position: 50%;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 20px;
|
|
||||||
padding: 8px;
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
border-radius: 3px;
|
|
||||||
display: block;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectTileFade = styled.div`
|
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectListItem = styled.li`
|
|
||||||
width: 23.5%;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 2% 2% 0;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover ${ProjectTileFade} {
|
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectList = styled.ul`
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding-top: 16px;
|
|
||||||
|
|
||||||
& ${ProjectListItem}:nth-of-type(4n) {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectTileDetails = styled.div`
|
|
||||||
display: flex;
|
|
||||||
height: 80px;
|
|
||||||
position: relative;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectAddTileDetails = styled.div`
|
|
||||||
display: flex;
|
|
||||||
height: 80px;
|
|
||||||
position: relative;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectTileName = styled.div<{ centered?: boolean }>`
|
|
||||||
flex: 0 0 auto;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
display: inline-block;
|
|
||||||
overflow: hidden;
|
|
||||||
max-height: 40px;
|
|
||||||
width: 100%;
|
|
||||||
word-wrap: break-word;
|
|
||||||
${props => props.centered && 'text-align: center;'}
|
|
||||||
`;
|
|
||||||
const ProjectListWrapper = styled.div`
|
|
||||||
flex: 1 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
|
|
||||||
|
|
||||||
type TeamProjectsProps = {
|
|
||||||
teamID: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
|
|
||||||
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
|
|
||||||
if (loading) {
|
|
||||||
return <span>loading</span>;
|
|
||||||
}
|
|
||||||
if (data) {
|
|
||||||
return (
|
|
||||||
<ProjectsContainer>
|
|
||||||
<FilterTab>
|
|
||||||
<FilterSearch placeholder="Search for projects..." width="100%" variant="alternate" />
|
|
||||||
<FilterTabTitle>SORT</FilterTabTitle>
|
|
||||||
<FilterTabItems>
|
|
||||||
<FilterTabItem>Most Recently Active</FilterTabItem>
|
|
||||||
<FilterTabItem>Number of Members</FilterTabItem>
|
|
||||||
<FilterTabItem>Number of Stars</FilterTabItem>
|
|
||||||
<FilterTabItem>Alphabetical</FilterTabItem>
|
|
||||||
</FilterTabItems>
|
|
||||||
</FilterTab>
|
|
||||||
<ProjectListWrapper>
|
|
||||||
<ProjectList>
|
|
||||||
{data.projects.map((project, idx) => (
|
|
||||||
<ProjectListItem key={project.id}>
|
|
||||||
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
|
|
||||||
<ProjectTileFade />
|
|
||||||
<ProjectTileDetails>
|
|
||||||
<ProjectTileName>{project.name}</ProjectTileName>
|
|
||||||
</ProjectTileDetails>
|
|
||||||
</ProjectTile>
|
|
||||||
</ProjectListItem>
|
|
||||||
))}
|
|
||||||
</ProjectList>
|
|
||||||
</ProjectListWrapper>
|
|
||||||
</ProjectsContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span>error</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TeamProjects;
|
|
@ -1,7 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const TeamSettings = () => {
|
|
||||||
return <h1>HI!</h1>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TeamSettings;
|
|
@ -1,142 +0,0 @@
|
|||||||
import React, { useState, useContext, useEffect } from 'react';
|
|
||||||
import styled, { css } from 'styled-components/macro';
|
|
||||||
import { MENU_TYPES } from 'shared/components/TopNavbar';
|
|
||||||
import GlobalTopNavbar from 'App/TopNavbar';
|
|
||||||
import updateApolloCache from 'shared/utils/cache';
|
|
||||||
import { Route, Switch, useRouteMatch, Redirect } from 'react-router';
|
|
||||||
import Members from './Members';
|
|
||||||
import Projects from './Projects';
|
|
||||||
|
|
||||||
import {
|
|
||||||
useGetTeamQuery,
|
|
||||||
useDeleteTeamMutation,
|
|
||||||
GetProjectsDocument,
|
|
||||||
GetProjectsQuery,
|
|
||||||
} from 'shared/generated/graphql';
|
|
||||||
import { useParams, useHistory, useLocation } from 'react-router';
|
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
|
||||||
import { History } from 'history';
|
|
||||||
import produce from 'immer';
|
|
||||||
import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
|
|
||||||
import UserContext, { PermissionObjectType, PermissionLevel, useCurrentUser } from 'App/context';
|
|
||||||
|
|
||||||
const OuterWrapper = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
max-width: 1400px;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type TeamPopupProps = {
|
|
||||||
history: History<History.PoorMansUnknown>;
|
|
||||||
name: string;
|
|
||||||
teamID: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamPopup: React.FC<TeamPopupProps> = ({ history, name, teamID }) => {
|
|
||||||
const { hidePopup, setTab } = usePopup();
|
|
||||||
const [deleteTeam] = useDeleteTeamMutation({
|
|
||||||
update: (client, deleteData) => {
|
|
||||||
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data.deleteTeam.team.id);
|
|
||||||
draftCache.projects = cache.projects.filter(
|
|
||||||
(project: any) => project.team.id !== deleteData.data.deleteTeam.team.id,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Popup title={null} tab={0}>
|
|
||||||
<TeamSettings
|
|
||||||
onDeleteTeam={() => {
|
|
||||||
setTab(1, 340);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
<Popup title={`Delete the "${name}" team?`} tab={1} onClose={() => hidePopup()}>
|
|
||||||
<DeleteConfirm
|
|
||||||
description={DELETE_INFO.DELETE_TEAMS.description}
|
|
||||||
deletedItems={DELETE_INFO.DELETE_TEAMS.deletedItems}
|
|
||||||
onConfirmDelete={() => {
|
|
||||||
if (teamID) {
|
|
||||||
deleteTeam({ variables: { teamID } });
|
|
||||||
hidePopup();
|
|
||||||
history.push('/projects');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type TeamsRouteProps = {
|
|
||||||
teamID: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Teams = () => {
|
|
||||||
const { teamID } = useParams<TeamsRouteProps>();
|
|
||||||
const history = useHistory();
|
|
||||||
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
|
|
||||||
const { user } = useCurrentUser();
|
|
||||||
const [currentTab, setCurrentTab] = useState(0);
|
|
||||||
const match = useRouteMatch();
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = 'Teams | Taskcafé';
|
|
||||||
}, []);
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span>loading</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (data && user) {
|
|
||||||
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
|
|
||||||
return <Redirect to="/" />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<GlobalTopNavbar
|
|
||||||
menuType={[
|
|
||||||
{ name: 'Projects', link: `${match.url}` },
|
|
||||||
{ name: 'Members', link: `${match.url}/members` },
|
|
||||||
]}
|
|
||||||
currentTab={currentTab}
|
|
||||||
onSetTab={tab => {
|
|
||||||
setCurrentTab(tab);
|
|
||||||
}}
|
|
||||||
popupContent={<TeamPopup history={history} name={data.findTeam.name} teamID={teamID} />}
|
|
||||||
onSaveProjectName={() => {}}
|
|
||||||
projectID={null}
|
|
||||||
name={data.findTeam.name}
|
|
||||||
/>
|
|
||||||
<OuterWrapper>
|
|
||||||
<Wrapper>
|
|
||||||
<Switch>
|
|
||||||
<Route exact path={match.path}>
|
|
||||||
<Projects teamID={teamID} />
|
|
||||||
</Route>
|
|
||||||
<Route path={`${match.path}/members`}>
|
|
||||||
<Members teamID={teamID} />
|
|
||||||
</Route>
|
|
||||||
</Switch>
|
|
||||||
</Wrapper>
|
|
||||||
</OuterWrapper>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <div>Error!</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Teams;
|
|
@ -1,26 +1,25 @@
|
|||||||
import { createGlobalStyle } from 'styled-components';
|
import { createGlobalStyle } from 'styled-components';
|
||||||
|
|
||||||
import { color, font, mixin } from 'shared/utils/styles';
|
import { font, mixin } from 'shared/utils/styles';
|
||||||
|
|
||||||
export default createGlobalStyle`
|
export default createGlobalStyle`
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
min-width: 768px;
|
|
||||||
background: #262c49;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: ${color.textDarkest};
|
color: ${(props) => props.theme.colors.text.primary};
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
${font.size(16)}
|
font-size: 14px;
|
||||||
${font.regular}
|
${font.regular}
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
background: ${(props) => props.theme.colors.bg.secondary};
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@ -83,15 +82,9 @@ export default createGlobalStyle`
|
|||||||
select::-ms-expand {
|
select::-ms-expand {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
select option {
|
|
||||||
color: ${color.textDarkest};
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
p {
|
||||||
line-height: 1.4285;
|
line-height: 1.4285;
|
||||||
a {
|
|
||||||
${mixin.link()}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
@ -125,5 +118,7 @@ export default createGlobalStyle`
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
${mixin.placeholderColor(color.textLight)}
|
.picker-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
`;
|
`;
|
113
frontend/src/app/ThemeStyles.ts
Normal file
113
frontend/src/app/ThemeStyles.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { DefaultTheme } from 'styled-components';
|
||||||
|
import Color from 'color';
|
||||||
|
|
||||||
|
export const darkTheme: DefaultTheme = {
|
||||||
|
borderRadius: {
|
||||||
|
primary: '3x',
|
||||||
|
alternate: '6px',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
multiColors: ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'],
|
||||||
|
primary: 'rgb(115, 103, 240)',
|
||||||
|
secondary: 'rgb(216, 93, 216)',
|
||||||
|
alternate: 'rgb(65, 69, 97)',
|
||||||
|
success: 'rgb(40, 199, 111)',
|
||||||
|
danger: 'rgb(234, 84, 85)',
|
||||||
|
warning: 'rgb(255, 159, 67)',
|
||||||
|
dark: 'rgb(30, 30, 30)',
|
||||||
|
form: {
|
||||||
|
textfield: {
|
||||||
|
background: 'rgb(38, 44, 73)',
|
||||||
|
text: 'rgb(255, 255, 255)',
|
||||||
|
label: 'rgb(194, 198, 220)',
|
||||||
|
placeholder: 'rgb(64, 70, 86)',
|
||||||
|
error: 'rgb(234, 84, 85)',
|
||||||
|
borderColor: 'rgb(65, 69, 97)',
|
||||||
|
secondaryLabel: 'rgb(115, 103, 240)',
|
||||||
|
hover: {
|
||||||
|
text: 'rgb(255, 255, 255)',
|
||||||
|
background: 'none',
|
||||||
|
borderColor: 'rgb(115, 103, 240)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
primary: 'rgb(194, 198, 220)',
|
||||||
|
secondary: 'rgb(255, 255, 255)',
|
||||||
|
alternate: 'rgb(0, 0, 0)',
|
||||||
|
danger: 'rgb(234, 84, 85)',
|
||||||
|
colored: 'rgb(115, 103, 240)',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: 'rgb(194, 198, 220)',
|
||||||
|
secondary: 'rgb(255, 255, 255)',
|
||||||
|
alternate: 'rgb(0, 0, 0)',
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
primary: 'rgb(65, 69, 97)',
|
||||||
|
secondary: 'rgb(115, 103, 240)',
|
||||||
|
},
|
||||||
|
bg: {
|
||||||
|
primary: 'rgb(16, 22, 58)',
|
||||||
|
secondary: 'rgb(38, 44, 73)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lightTheme: DefaultTheme = {
|
||||||
|
borderRadius: {
|
||||||
|
primary: '3x',
|
||||||
|
alternate: '6px',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
multiColors: ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'],
|
||||||
|
primary: 'rgb(115, 103, 240)',
|
||||||
|
secondary: 'rgb(216, 93, 216)',
|
||||||
|
alternate: 'rgb(65, 69, 97)',
|
||||||
|
success: 'rgb(40, 199, 111)',
|
||||||
|
danger: 'rgb(234, 84, 85)',
|
||||||
|
warning: 'rgb(255, 159, 67)',
|
||||||
|
dark: 'rgb(30, 30, 30)',
|
||||||
|
form: {
|
||||||
|
textfield: {
|
||||||
|
background: 'rgb(250, 251, 252)',
|
||||||
|
error: 'rgb(234, 84, 85)',
|
||||||
|
text: 'rgb(23, 43, 77)',
|
||||||
|
label: 'rgb(94, 108, 132)',
|
||||||
|
secondaryLabel: 'rgb(115, 103, 240)',
|
||||||
|
borderColor: 'rgb( 233, 225, 230)',
|
||||||
|
placeholder: 'rgb(64, 70, 86)',
|
||||||
|
hover: {
|
||||||
|
text: 'rgb(23, 43, 77)',
|
||||||
|
background: 'rgb(255, 255, 255)',
|
||||||
|
borderColor: 'rgb(115, 103, 240)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
primary: 'rgb(194, 198, 220)',
|
||||||
|
secondary: 'rgb(0, 0, 0)',
|
||||||
|
alternate: 'rgb(0, 0, 0)',
|
||||||
|
colored: 'rgb(115, 103, 240)',
|
||||||
|
danger: 'rgb(234, 84, 85)',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: 'rgb(194, 198, 220)',
|
||||||
|
secondary: 'rgb(255, 255, 255)',
|
||||||
|
alternate: 'rgb(0, 0, 0)',
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
primary: 'rgb(223, 225, 230)',
|
||||||
|
secondary: 'rgb(115, 103, 240)',
|
||||||
|
},
|
||||||
|
bg: {
|
||||||
|
primary: 'rgb(0, 22, 58)',
|
||||||
|
secondary: 'rgb(250, 251, 252)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkTheme,
|
||||||
|
lightTheme,
|
||||||
|
};
|
4
frontend/src/app/font.css
Normal file
4
frontend/src/app/font.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Open Sans';
|
||||||
|
src: url(/assets/fonts/OpenSans-Regular.ttf) format('truetype');
|
||||||
|
}
|
22
frontend/src/app/index.tsx
Normal file
22
frontend/src/app/index.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import Login from 'pages/Login';
|
||||||
|
|
||||||
|
import NormalizeStyles from './NormalizeStyles';
|
||||||
|
import BaseStyles from './BaseStyles';
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
@ -1,148 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import axios from 'axios';
|
|
||||||
import createAuthRefreshInterceptor from 'axios-auth-refresh';
|
|
||||||
import { ApolloProvider } from '@apollo/react-hooks';
|
|
||||||
import { ApolloClient } from 'apollo-client';
|
|
||||||
import { HttpLink } from 'apollo-link-http';
|
|
||||||
import { onError } from 'apollo-link-error';
|
|
||||||
import { enableMapSet } from 'immer';
|
|
||||||
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
|
|
||||||
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
|
|
||||||
import cache from './App/cache';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
|
import App from 'app';
|
||||||
enableMapSet();
|
|
||||||
|
|
||||||
let forward$;
|
|
||||||
let isRefreshing = false;
|
|
||||||
let pendingRequests: any = [];
|
|
||||||
|
|
||||||
const refreshAuthLogic = (failedRequest: any) =>
|
|
||||||
axios.post('/auth/refresh_token', {}, { withCredentials: true }).then(tokenRefreshResponse => {
|
|
||||||
setAccessToken(tokenRefreshResponse.data.accessToken);
|
|
||||||
failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`;
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
createAuthRefreshInterceptor(axios, refreshAuthLogic);
|
|
||||||
|
|
||||||
const resolvePendingRequests = () => {
|
|
||||||
pendingRequests.map((callback: any) => callback());
|
|
||||||
pendingRequests = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolvePromise = (resolve: () => void) => {
|
|
||||||
pendingRequests.push(() => resolve());
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetPendingRequests = () => {
|
|
||||||
pendingRequests = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const setRefreshing = (newVal: boolean) => {
|
|
||||||
isRefreshing = newVal;
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
|
||||||
if (graphQLErrors) {
|
|
||||||
for (const err of graphQLErrors) {
|
|
||||||
if (err.extensions && err.extensions.code) {
|
|
||||||
switch (err.extensions.code) {
|
|
||||||
case 'UNAUTHENTICATED':
|
|
||||||
if (!isRefreshing) {
|
|
||||||
setRefreshing(true);
|
|
||||||
forward$ = fromPromise(
|
|
||||||
getNewToken()
|
|
||||||
.then((response: any) => {
|
|
||||||
setAccessToken(response.accessToken);
|
|
||||||
resolvePendingRequests();
|
|
||||||
return response.accessToken;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
resetPendingRequests();
|
|
||||||
// TODO
|
|
||||||
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
|
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setRefreshing(false);
|
|
||||||
}),
|
|
||||||
).filter(value => Boolean(value));
|
|
||||||
} else {
|
|
||||||
forward$ = fromPromise(new Promise(resolvePromise));
|
|
||||||
}
|
|
||||||
return forward$.flatMap(() => forward(operation));
|
|
||||||
default:
|
|
||||||
// pass
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (networkError) {
|
|
||||||
console.log(`[Network error]: ${networkError}`);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestLink = new ApolloLink(
|
|
||||||
(operation, forward) =>
|
|
||||||
new Observable((observer: any) => {
|
|
||||||
let handle: any;
|
|
||||||
Promise.resolve(operation)
|
|
||||||
.then((op: any) => {
|
|
||||||
const accessToken = getAccessToken();
|
|
||||||
if (accessToken) {
|
|
||||||
op.setContext({
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
handle = forward(operation).subscribe({
|
|
||||||
next: observer.next.bind(observer),
|
|
||||||
error: observer.error.bind(observer),
|
|
||||||
complete: observer.complete.bind(observer),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(observer.error.bind(observer));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (handle) {
|
|
||||||
handle.unsubscribe();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = new ApolloClient({
|
|
||||||
link: ApolloLink.from([
|
|
||||||
onError(({ graphQLErrors, networkError }) => {
|
|
||||||
if (graphQLErrors) {
|
|
||||||
graphQLErrors.forEach(({ message, locations, path }) =>
|
|
||||||
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (networkError) {
|
|
||||||
console.log(`[Network error]: ${networkError}`);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
errorLink,
|
|
||||||
requestLink,
|
|
||||||
new HttpLink({
|
|
||||||
uri: '/graphql',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
cache,
|
|
||||||
});
|
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<ApolloProvider client={client}>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</ApolloProvider>,
|
</React.StrictMode>,
|
||||||
document.getElementById('root'),
|
document.getElementById('root'),
|
||||||
);
|
);
|
||||||
|
65
frontend/src/pages/Login/form/LoginForm.tsx
Normal file
65
frontend/src/pages/Login/form/LoginForm.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FieldError, useForm, UseFormSetError } from 'react-hook-form';
|
||||||
|
import Form from 'shared/components/form';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import DefaultFormButton from 'shared/components/form/FormButton';
|
||||||
|
import DefaultFormTextField from 'shared/components/form/FormTextField';
|
||||||
|
import DefaultFormPasswordField from 'shared/components/form/FormPasswordField';
|
||||||
|
|
||||||
|
const FormTextField = styled(DefaultFormTextField)`
|
||||||
|
margin-top: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FormPasswordField = styled(DefaultFormPasswordField)`
|
||||||
|
margin-top: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FormButton = styled(DefaultFormButton)`
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type LoginFormData = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnLoginFn = (data: LoginFormData, setError: UseFormSetError<LoginFormData>) => void;
|
||||||
|
|
||||||
|
type LoginFormProps = {
|
||||||
|
onLogin: OnLoginFn;
|
||||||
|
onForgotPassword: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginForm: React.FC<LoginFormProps> = ({ onLogin, onForgotPassword, isLoading }) => {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
setError,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginFormData>();
|
||||||
|
const onSubmit = (data: LoginFormData) => {
|
||||||
|
onLogin(data, setError);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<FormTextField
|
||||||
|
error={errors.username?.message}
|
||||||
|
label="Username"
|
||||||
|
{...register('username', { required: 'Username is required' })}
|
||||||
|
/>
|
||||||
|
<FormPasswordField
|
||||||
|
label="Password"
|
||||||
|
error={errors.password?.message}
|
||||||
|
secondaryLabel={{ label: 'Forgot Password?', onClick: onForgotPassword }}
|
||||||
|
{...register('password', { required: 'Password is required' })}
|
||||||
|
/>
|
||||||
|
<FormButton disabled={isLoading} width="100%" onClick={handleSubmit(onSubmit)}>
|
||||||
|
Login
|
||||||
|
</FormButton>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginForm;
|
26
frontend/src/pages/Login/index.tsx
Normal file
26
frontend/src/pages/Login/index.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import LoginPage from './page/LoginPage';
|
||||||
|
|
||||||
|
const Login: React.FC = () => {
|
||||||
|
const [settings, setSettings] = useState<null | { allowRegistration: string }>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/public_settings')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.isInstalled) {
|
||||||
|
navigate('/register');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<LoginPage
|
||||||
|
onLogin={(data, setError) => {
|
||||||
|
console.log(data);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
29
frontend/src/pages/Login/page/LoginPage.stories.tsx
Normal file
29
frontend/src/pages/Login/page/LoginPage.stories.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import LoginPage from './LoginPage';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Pages/LoginPage',
|
||||||
|
component: LoginPage,
|
||||||
|
argTypes: {
|
||||||
|
onLogin: {
|
||||||
|
action: 'on login',
|
||||||
|
},
|
||||||
|
onRegister: {
|
||||||
|
options: ['None', 'Standard'],
|
||||||
|
defaultValue: 'Standard',
|
||||||
|
mapping: {
|
||||||
|
Standard: () => action('on register'),
|
||||||
|
None: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ComponentMeta<typeof LoginPage>;
|
||||||
|
|
||||||
|
const Template: ComponentStory<typeof LoginPage> = ({ children, ...args }) => <LoginPage {...args} />;
|
||||||
|
|
||||||
|
export const Primary = Template.bind({});
|
||||||
|
|
||||||
|
Primary.args = { isLoading: true };
|
51
frontend/src/pages/Login/page/LoginPage.tsx
Normal file
51
frontend/src/pages/Login/page/LoginPage.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import InfoCircle from 'shared/components/icons/solid/InfoCircle';
|
||||||
|
import AuthPageLayout from 'shared/components/layout/AuthPageLayout';
|
||||||
|
import LoginForm, { OnLoginFn } from 'pages/Login/form/LoginForm';
|
||||||
|
import * as S from './Styles';
|
||||||
|
|
||||||
|
type LoginPageProps = {
|
||||||
|
isLoading: boolean;
|
||||||
|
onLogin: OnLoginFn;
|
||||||
|
onRegister?: () => void;
|
||||||
|
alert?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginPage: React.FC<LoginPageProps> = ({ isLoading, alert, onRegister }) => {
|
||||||
|
return (
|
||||||
|
<AuthPageLayout>
|
||||||
|
<S.Welcome>Welcome to Taskcafe</S.Welcome>
|
||||||
|
{alert && (
|
||||||
|
<S.Alert>
|
||||||
|
<S.AlertContent>{alert}</S.AlertContent>
|
||||||
|
<S.AlertIcon>
|
||||||
|
<InfoCircle stroke="danger" fill="danger" />
|
||||||
|
</S.AlertIcon>
|
||||||
|
</S.Alert>
|
||||||
|
)}
|
||||||
|
<LoginForm
|
||||||
|
isLoading={isLoading}
|
||||||
|
onLogin={() => {
|
||||||
|
// TODO
|
||||||
|
}}
|
||||||
|
onForgotPassword={() => {
|
||||||
|
// TODO
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!isLoading && (
|
||||||
|
<>
|
||||||
|
{onRegister && (
|
||||||
|
<S.Register>
|
||||||
|
New to Taskcafe? <S.RegisterLink to="/register">Create an account</S.RegisterLink>
|
||||||
|
</S.Register>
|
||||||
|
)}
|
||||||
|
<S.Divider>
|
||||||
|
<S.DividerText>or</S.DividerText>
|
||||||
|
</S.Divider>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AuthPageLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
72
frontend/src/pages/Login/page/Styles.ts
Normal file
72
frontend/src/pages/Login/page/Styles.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { mixin } from 'shared/utils/styles';
|
||||||
|
|
||||||
|
export const Register = styled.div`
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: break-spaces;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RegisterLink = styled(Link)`
|
||||||
|
color: ${(props) => props.theme.colors.primary};
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export const Divider = styled.div`
|
||||||
|
margin: 21px 0;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Alert = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background-color: ${(props) => mixin.rgba(props.theme.colors.danger, 0.12)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const AlertIcon = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const AlertContent = styled.div`
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${(props) => props.theme.colors.danger};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DividerText = styled.div`
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 14px;
|
||||||
|
&:before {
|
||||||
|
right: 100%;
|
||||||
|
border-top: 1px solid ${(props) => props.theme.colors.border.primary};
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 9999px;
|
||||||
|
}
|
||||||
|
&:after {
|
||||||
|
left: 100%;
|
||||||
|
border-top: 1px solid ${(props) => props.theme.colors.border.primary};
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 9999px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Welcome = styled.h2`
|
||||||
|
font-size: 24px;
|
||||||
|
color: ${(props) => props.theme.colors.text.primary};
|
||||||
|
margin-bottom: 16px;
|
||||||
|
`;
|
@ -1,5 +1 @@
|
|||||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
import '@testing-library/jest-dom';
|
||||||
// allows you to do things like:
|
|
||||||
// expect(element).toHaveTextContent(/react/i)
|
|
||||||
// learn more: https://github.com/testing-library/jest-dom
|
|
||||||
import '@testing-library/jest-dom/extend-expect';
|
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
import AddList from '.';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: AddList,
|
|
||||||
title: 'AddList',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'gray', value: '#262c49', default: true },
|
|
||||||
{ name: 'white', value: '#ffffff' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
return <AddList onSave={action('on save')} />;
|
|
||||||
};
|
|
@ -1,112 +0,0 @@
|
|||||||
import styled, { css } from 'styled-components';
|
|
||||||
import TextareaAutosize from 'react-autosize-textarea/lib';
|
|
||||||
import { mixin } from 'shared/utils/styles';
|
|
||||||
import Button from 'shared/components/Button';
|
|
||||||
|
|
||||||
export const Container = styled.div`
|
|
||||||
width: 272px;
|
|
||||||
margin: 0 4px;
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
white-space: nowrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Wrapper = styled.div<{ editorOpen: boolean }>`
|
|
||||||
display: inline-block;
|
|
||||||
background-color: hsla(0, 0%, 100%, 0.24);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 3px;
|
|
||||||
height: auto;
|
|
||||||
min-height: 32px;
|
|
||||||
padding: 4px;
|
|
||||||
transition: background 85ms ease-in, opacity 40ms ease-in, border-color 85ms ease-in;
|
|
||||||
width: 272px;
|
|
||||||
margin: 0 4px;
|
|
||||||
margin-right: 8px;
|
|
||||||
|
|
||||||
${props =>
|
|
||||||
!props.editorOpen &&
|
|
||||||
css`
|
|
||||||
&:hover {
|
|
||||||
background-color: hsla(0, 0%, 100%, 0.32);
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
|
|
||||||
${props =>
|
|
||||||
props.editorOpen &&
|
|
||||||
css`
|
|
||||||
background-color: #10163a;
|
|
||||||
border-radius: 3px;
|
|
||||||
height: auto;
|
|
||||||
min-height: 32px;
|
|
||||||
padding: 8px;
|
|
||||||
transition: background 85ms ease-in, opacity 40ms ease-in, border-color 85ms ease-in;
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const AddListButton = styled(Button)`
|
|
||||||
padding: 6px 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Placeholder = styled.span`
|
|
||||||
color: #c2c6dc;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 8px;
|
|
||||||
transition: color 85ms ease-in;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const AddIconWrapper = styled.div`
|
|
||||||
color: #fff;
|
|
||||||
margin-right: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListNameEditorWrapper = styled.div`
|
|
||||||
display: flex;
|
|
||||||
`;
|
|
||||||
export const ListNameEditor = styled(TextareaAutosize)`
|
|
||||||
background-color: ${props => mixin.lighten('#262c49', 0.05)};
|
|
||||||
border: none;
|
|
||||||
box-shadow: inset 0 0 0 2px #0079bf;
|
|
||||||
transition: margin 85ms ease-in, background 85ms ease-in;
|
|
||||||
line-height: 20px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
resize: none;
|
|
||||||
height: 54px;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
box-shadow: none;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
max-height: 162px;
|
|
||||||
min-height: 54px;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
|
||||||
|
|
||||||
color: #c2c6dc;
|
|
||||||
l &:focus {
|
|
||||||
background-color: ${props => mixin.lighten('#262c49', 0.05)};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListAddControls = styled.div`
|
|
||||||
height: 32px;
|
|
||||||
transition: margin 85ms ease-in, height 85ms ease-in;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 4px 0 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const CancelAdd = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 32px;
|
|
||||||
width: 32px;
|
|
||||||
cursor: pointer;
|
|
||||||
`;
|
|
@ -1,111 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import { Plus, Cross } from 'shared/icons';
|
|
||||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
|
||||||
import Button from 'shared/components/Button';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Wrapper,
|
|
||||||
Placeholder,
|
|
||||||
AddIconWrapper,
|
|
||||||
AddListButton,
|
|
||||||
ListNameEditor,
|
|
||||||
ListAddControls,
|
|
||||||
CancelAdd,
|
|
||||||
ListNameEditorWrapper,
|
|
||||||
} from './Styles';
|
|
||||||
|
|
||||||
type NameEditorProps = {
|
|
||||||
onSave: (listName: string) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
|
|
||||||
const $editorRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const [listName, setListName] = useState('');
|
|
||||||
useEffect(() => {
|
|
||||||
if ($editorRef && $editorRef.current) {
|
|
||||||
$editorRef.current.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
onSave(listName);
|
|
||||||
setListName('');
|
|
||||||
if ($editorRef && $editorRef.current) {
|
|
||||||
$editorRef.current.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ListNameEditorWrapper>
|
|
||||||
<ListNameEditor
|
|
||||||
ref={$editorRef}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
value={listName}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setListName(e.currentTarget.value)}
|
|
||||||
placeholder="Enter a title for this list..."
|
|
||||||
/>
|
|
||||||
</ListNameEditorWrapper>
|
|
||||||
<ListAddControls>
|
|
||||||
<AddListButton
|
|
||||||
variant="relief"
|
|
||||||
onClick={() => {
|
|
||||||
onSave(listName);
|
|
||||||
setListName('');
|
|
||||||
if ($editorRef && $editorRef.current) {
|
|
||||||
$editorRef.current.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</AddListButton>
|
|
||||||
<CancelAdd onClick={() => onCancel()}>
|
|
||||||
<Cross width={16} height={16} />
|
|
||||||
</CancelAdd>
|
|
||||||
</ListAddControls>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type AddListProps = {
|
|
||||||
onSave: (listName: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AddList: React.FC<AddListProps> = ({ onSave }) => {
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
|
||||||
const $wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
const onOutsideClick = () => {
|
|
||||||
setEditorOpen(false);
|
|
||||||
};
|
|
||||||
useOnOutsideClick($wrapperRef, editorOpen, onOutsideClick, null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Wrapper
|
|
||||||
ref={$wrapperRef}
|
|
||||||
editorOpen={editorOpen}
|
|
||||||
onClick={() => {
|
|
||||||
if (!editorOpen) {
|
|
||||||
setEditorOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{editorOpen ? (
|
|
||||||
<NameEditor onCancel={() => setEditorOpen(false)} onSave={onSave} />
|
|
||||||
) : (
|
|
||||||
<Placeholder>
|
|
||||||
<AddIconWrapper>
|
|
||||||
<Plus size={12} color="#c2c6dc" />
|
|
||||||
</AddIconWrapper>
|
|
||||||
Add another list
|
|
||||||
</Placeholder>
|
|
||||||
)}
|
|
||||||
</Wrapper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddList;
|
|
@ -1,59 +0,0 @@
|
|||||||
import React, { useRef } from 'react';
|
|
||||||
import Admin from '.';
|
|
||||||
import { theme } from 'App/ThemeStyles';
|
|
||||||
import NormalizeStyles from 'App/NormalizeStyles';
|
|
||||||
import BaseStyles from 'App/BaseStyles';
|
|
||||||
import { ThemeProvider } from 'styled-components';
|
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: Admin,
|
|
||||||
title: 'Admin',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
|
||||||
{ name: 'white', value: '#ffffff' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<NormalizeStyles />
|
|
||||||
<BaseStyles />
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<Admin
|
|
||||||
onInviteUser={action('invite user')}
|
|
||||||
canInviteUser
|
|
||||||
initialTab={1}
|
|
||||||
onUpdateUserPassword={action('update user password')}
|
|
||||||
onDeleteUser={action('delete user')}
|
|
||||||
users={[
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
username: 'jordanthedev',
|
|
||||||
email: 'jordan@jordanthedev.com',
|
|
||||||
role: { code: 'admin', name: 'Admin' },
|
|
||||||
fullName: 'Jordan Knott',
|
|
||||||
profileIcon: {
|
|
||||||
bgColor: '#fff',
|
|
||||||
initials: 'JK',
|
|
||||||
url: null,
|
|
||||||
},
|
|
||||||
owned: {
|
|
||||||
teams: [{ id: '1', name: 'Team' }],
|
|
||||||
projects: [{ id: '2', name: 'Project' }],
|
|
||||||
},
|
|
||||||
member: {
|
|
||||||
teams: [],
|
|
||||||
projects: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onAddUser={action('add user')}
|
|
||||||
/>
|
|
||||||
</ThemeProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,735 +0,0 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
|
||||||
import { UserPlus, Checkmark } from 'shared/icons';
|
|
||||||
import styled, { css } from 'styled-components';
|
|
||||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
|
||||||
import Select from 'shared/components/Select';
|
|
||||||
import { User, Plus, Lock, Pencil, Trash } from 'shared/icons';
|
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
|
||||||
import { RoleCode, useUpdateUserRoleMutation } from 'shared/generated/graphql';
|
|
||||||
import Input from 'shared/components/Input';
|
|
||||||
import Member from 'shared/components/Member';
|
|
||||||
|
|
||||||
import Button from 'shared/components/Button';
|
|
||||||
|
|
||||||
export const RoleCheckmark = styled(Checkmark)`
|
|
||||||
padding-left: 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const permissions = [
|
|
||||||
{
|
|
||||||
code: 'owner',
|
|
||||||
name: 'Owner',
|
|
||||||
description:
|
|
||||||
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team. Can delete the team and all team projects.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'admin',
|
|
||||||
name: 'Admin',
|
|
||||||
description:
|
|
||||||
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team.',
|
|
||||||
},
|
|
||||||
|
|
||||||
{ code: 'member', name: 'Member', description: 'Can view, create, and edit team projects, but not change settings.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const RoleName = styled.div`
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
`;
|
|
||||||
export const RoleDescription = styled.div`
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const MiniProfileActions = styled.ul`
|
|
||||||
list-style-type: none;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const MiniProfileActionWrapper = styled.li``;
|
|
||||||
|
|
||||||
export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
|
|
||||||
color: #c2c6dc;
|
|
||||||
display: block;
|
|
||||||
font-weight: 400;
|
|
||||||
padding: 6px 12px;
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
${props =>
|
|
||||||
props.disabled
|
|
||||||
? css`
|
|
||||||
user-select: none;
|
|
||||||
pointer-events: none;
|
|
||||||
color: rgba(${props.theme.colors.text.primary}, 0.4);
|
|
||||||
`
|
|
||||||
: css`
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
background: rgb(115, 103, 240);
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
export const Content = styled.div`
|
|
||||||
padding: 0 12px 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const CurrentPermission = styled.span`
|
|
||||||
margin-left: 4px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary}, 0.4);
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Separator = styled.div`
|
|
||||||
height: 1px;
|
|
||||||
border-top: 1px solid #414561;
|
|
||||||
margin: 0.25rem !important;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const WarningText = styled.span`
|
|
||||||
display: flex;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
|
|
||||||
padding: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DeleteDescription = styled.div`
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const RemoveMemberButton = styled(Button)`
|
|
||||||
margin-top: 16px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
type TeamRoleManagerPopupProps = {
|
|
||||||
user: User;
|
|
||||||
users: Array<User>;
|
|
||||||
warning?: string | null;
|
|
||||||
canChangeRole: boolean;
|
|
||||||
onChangeRole: (roleCode: RoleCode) => void;
|
|
||||||
updateUserPassword?: (user: TaskUser, password: string) => void;
|
|
||||||
onDeleteUser?: (userID: string, newOwnerID: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
|
||||||
warning,
|
|
||||||
user,
|
|
||||||
users,
|
|
||||||
canChangeRole,
|
|
||||||
onDeleteUser,
|
|
||||||
updateUserPassword,
|
|
||||||
onChangeRole,
|
|
||||||
}) => {
|
|
||||||
const { hidePopup, setTab } = usePopup();
|
|
||||||
const [userPass, setUserPass] = useState({ pass: '', passConfirm: '' });
|
|
||||||
const [deleteUser, setDeleteUser] = useState<{ label: string; value: string } | null>(null);
|
|
||||||
const hasOwned = user.owned.projects.length !== 0 || user.owned.teams.length !== 0;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Popup title={null} tab={0}>
|
|
||||||
<MiniProfileActions>
|
|
||||||
<MiniProfileActionWrapper>
|
|
||||||
{user.role && (
|
|
||||||
<MiniProfileActionItem
|
|
||||||
onClick={() => {
|
|
||||||
setTab(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Change permissions...
|
|
||||||
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
|
|
||||||
</MiniProfileActionItem>
|
|
||||||
)}
|
|
||||||
<MiniProfileActionItem
|
|
||||||
disabled
|
|
||||||
onClick={() => {
|
|
||||||
setTab(3);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset password...
|
|
||||||
</MiniProfileActionItem>
|
|
||||||
<MiniProfileActionItem onClick={() => setTab(2)}>Remove from organzation...</MiniProfileActionItem>
|
|
||||||
</MiniProfileActionWrapper>
|
|
||||||
</MiniProfileActions>
|
|
||||||
{warning && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<WarningText>{warning}</WarningText>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Popup>
|
|
||||||
<Popup title="Change Permissions" onClose={() => hidePopup()} tab={1}>
|
|
||||||
<MiniProfileActions>
|
|
||||||
<MiniProfileActionWrapper>
|
|
||||||
{permissions
|
|
||||||
.filter(p => (user.role && user.role.code === 'owner') || p.code !== 'owner')
|
|
||||||
.map(perm => (
|
|
||||||
<MiniProfileActionItem
|
|
||||||
disabled={user.role && perm.code !== user.role.code && !canChangeRole}
|
|
||||||
key={perm.code}
|
|
||||||
onClick={() => {
|
|
||||||
if (onChangeRole && user.role && perm.code !== user.role.code) {
|
|
||||||
switch (perm.code) {
|
|
||||||
case 'owner':
|
|
||||||
onChangeRole(RoleCode.Owner);
|
|
||||||
break;
|
|
||||||
case 'admin':
|
|
||||||
onChangeRole(RoleCode.Admin);
|
|
||||||
break;
|
|
||||||
case 'member':
|
|
||||||
onChangeRole(RoleCode.Member);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
hidePopup();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RoleName>
|
|
||||||
{perm.name}
|
|
||||||
{user.role && perm.code === user.role.code && <RoleCheckmark width={12} height={12} />}
|
|
||||||
</RoleName>
|
|
||||||
<RoleDescription>{perm.description}</RoleDescription>
|
|
||||||
</MiniProfileActionItem>
|
|
||||||
))}
|
|
||||||
</MiniProfileActionWrapper>
|
|
||||||
{user.role && user.role.code === 'owner' && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<WarningText>You can't change roles because there must be an owner.</WarningText>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</MiniProfileActions>
|
|
||||||
</Popup>
|
|
||||||
<Popup title="Remove from Organization?" onClose={() => hidePopup()} tab={2}>
|
|
||||||
<Content>
|
|
||||||
<DeleteDescription>
|
|
||||||
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
|
|
||||||
</DeleteDescription>
|
|
||||||
{hasOwned && (
|
|
||||||
<>
|
|
||||||
<DeleteDescription>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} teams.`}</DeleteDescription>
|
|
||||||
<DeleteDescription>
|
|
||||||
Choose a new user to take over ownership of this user's teams & projects.
|
|
||||||
</DeleteDescription>
|
|
||||||
<UserSelect
|
|
||||||
onChange={v => setDeleteUser(v)}
|
|
||||||
value={deleteUser}
|
|
||||||
options={users.map(u => ({ label: u.fullName, value: u.id }))}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<UserPassConfirmButton
|
|
||||||
disabled={!(!hasOwned || (hasOwned && deleteUser))}
|
|
||||||
onClick={() => {
|
|
||||||
if (onDeleteUser) {
|
|
||||||
if (!hasOwned || (hasOwned && deleteUser)) {
|
|
||||||
onDeleteUser(user.id, deleteUser ? deleteUser.value : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
color="danger"
|
|
||||||
>
|
|
||||||
Delete user
|
|
||||||
</UserPassConfirmButton>
|
|
||||||
</Content>
|
|
||||||
</Popup>
|
|
||||||
<Popup title="Really remove from Team?" onClose={() => hidePopup()} tab={4}>
|
|
||||||
<Content>
|
|
||||||
<DeleteDescription>
|
|
||||||
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
|
|
||||||
</DeleteDescription>
|
|
||||||
<DeleteDescription>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} teams.`}</DeleteDescription>
|
|
||||||
<UserSelect onChange={() => {}} value={null} options={users.map(u => ({ label: u.fullName, value: u.id }))} />
|
|
||||||
<UserPassConfirmButton
|
|
||||||
onClick={() => {
|
|
||||||
// onDeleteUser();
|
|
||||||
}}
|
|
||||||
color="danger"
|
|
||||||
>
|
|
||||||
Delete user
|
|
||||||
</UserPassConfirmButton>
|
|
||||||
</Content>
|
|
||||||
</Popup>
|
|
||||||
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
|
|
||||||
<Content>
|
|
||||||
<DeleteDescription>
|
|
||||||
You can either set the user's new password directly or send the user an email allowing them to reset their
|
|
||||||
own password.
|
|
||||||
</DeleteDescription>
|
|
||||||
<UserPassBar>
|
|
||||||
<UserPassButton onClick={() => setTab(4)} color="warning">
|
|
||||||
Set password...
|
|
||||||
</UserPassButton>
|
|
||||||
<UserPassButton color="warning" variant="outline">
|
|
||||||
Send reset link
|
|
||||||
</UserPassButton>
|
|
||||||
</UserPassBar>
|
|
||||||
</Content>
|
|
||||||
</Popup>
|
|
||||||
<Popup title="Reset password" onClose={() => hidePopup()} tab={4}>
|
|
||||||
<Content>
|
|
||||||
<NewUserPassInput defaultValue={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
|
|
||||||
<NewUserPassInput
|
|
||||||
defaultValue={userPass.passConfirm}
|
|
||||||
width="100%"
|
|
||||||
variant="alternate"
|
|
||||||
placeholder="New password (confirm)"
|
|
||||||
/>
|
|
||||||
<UserPassConfirmButton
|
|
||||||
disabled={userPass.pass === '' || userPass.passConfirm === ''}
|
|
||||||
onClick={() => {
|
|
||||||
if (userPass.pass === userPass.passConfirm && updateUserPassword) {
|
|
||||||
updateUserPassword(user, userPass.pass);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
color="danger"
|
|
||||||
>
|
|
||||||
Set password
|
|
||||||
</UserPassConfirmButton>
|
|
||||||
</Content>
|
|
||||||
</Popup>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const UserSelect = styled(Select)`
|
|
||||||
margin: 8px 0;
|
|
||||||
padding: 8px 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const NewUserPassInput = styled(Input)`
|
|
||||||
margin: 8px 0;
|
|
||||||
`;
|
|
||||||
const InviteMemberButton = styled(Button)`
|
|
||||||
padding: 7px 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const UserPassBar = styled.div`
|
|
||||||
display: flex;
|
|
||||||
padding-top: 8px;
|
|
||||||
`;
|
|
||||||
const UserPassConfirmButton = styled(Button)`
|
|
||||||
width: 100%;
|
|
||||||
padding: 7px 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const UserPassButton = styled(Button)`
|
|
||||||
width: 50%;
|
|
||||||
padding: 7px 12px;
|
|
||||||
& ~ & {
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberItemOptions = styled.div``;
|
|
||||||
|
|
||||||
const MemberItemOption = styled(Button)`
|
|
||||||
padding: 7px 9px;
|
|
||||||
margin: 4px 0 4px 8px;
|
|
||||||
float: left;
|
|
||||||
min-width: 95px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberList = styled.div`
|
|
||||||
border-top: 1px solid rgba(${props => props.theme.colors.border});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberListItem = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-bottom: 1px solid rgba(${props => props.theme.colors.border});
|
|
||||||
min-height: 40px;
|
|
||||||
padding: 12px 0 12px 40px;
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberListItemDetails = styled.div`
|
|
||||||
float: left;
|
|
||||||
flex: 1 0 auto;
|
|
||||||
padding-left: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const InviteIcon = styled(UserPlus)`
|
|
||||||
padding-right: 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberProfile = styled(TaskAssignee)`
|
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
left: 0;
|
|
||||||
margin: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberItemName = styled.p`
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberItemUsername = styled.p`
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberListHeader = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
`;
|
|
||||||
const ListTitle = styled.h3`
|
|
||||||
font-size: 18px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary});
|
|
||||||
margin-bottom: 12px;
|
|
||||||
`;
|
|
||||||
const ListDesc = styled.span`
|
|
||||||
font-size: 16px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
`;
|
|
||||||
const FilterSearch = styled(Input)`
|
|
||||||
margin: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ListActions = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MemberListWrapper = styled.div`
|
|
||||||
flex: 1 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Root = styled.div`
|
|
||||||
.ag-theme-material {
|
|
||||||
--ag-foreground-color: #c2c6dc;
|
|
||||||
--ag-secondary-foreground-color: #c2c6dc;
|
|
||||||
--ag-background-color: transparent;
|
|
||||||
--ag-header-background-color: transparent;
|
|
||||||
--ag-header-foreground-color: #c2c6dc;
|
|
||||||
--ag-border-color: #414561;
|
|
||||||
|
|
||||||
--ag-row-hover-color: #262c49;
|
|
||||||
--ag-header-cell-hover-background-color: #262c49;
|
|
||||||
--ag-checkbox-unchecked-color: #c2c6dc;
|
|
||||||
--ag-checkbox-indeterminate-color: rgba(115, 103, 240);
|
|
||||||
--ag-selected-row-background-color: #262c49;
|
|
||||||
--ag-material-primary-color: rgba(115, 103, 240);
|
|
||||||
--ag-material-accent-color: rgba(115, 103, 240);
|
|
||||||
}
|
|
||||||
.ag-theme-material ::-webkit-scrollbar {
|
|
||||||
width: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ag-theme-material ::-webkit-scrollbar-track {
|
|
||||||
background: #262c49;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ag-theme-material ::-webkit-scrollbar-thumb {
|
|
||||||
background: #7367f0;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
.ag-header-cell-text {
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Header = styled.div`
|
|
||||||
border-bottom: 1px solid #e2e2e2;
|
|
||||||
flex-direction: row;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
white-space: nowrap;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: transparent;
|
|
||||||
border-bottom-color: #414561;
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
height: 112px;
|
|
||||||
min-height: 112px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const EditUserIcon = styled(Pencil)``;
|
|
||||||
|
|
||||||
const LockUserIcon = styled(Lock)``;
|
|
||||||
|
|
||||||
const DeleteUserIcon = styled(Trash)``;
|
|
||||||
|
|
||||||
type ActionButtonProps = {
|
|
||||||
onClick: ($target: React.RefObject<HTMLElement>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ActionButtonWrapper = styled.div`
|
|
||||||
margin-right: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ActionButton: React.FC<ActionButtonProps> = ({ onClick, children }) => {
|
|
||||||
const $wrapper = useRef<HTMLDivElement>(null);
|
|
||||||
return (
|
|
||||||
<ActionButtonWrapper onClick={() => onClick($wrapper)} ref={$wrapper}>
|
|
||||||
{children}
|
|
||||||
</ActionButtonWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ActionButtons = (params: any) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ActionButton onClick={() => {}}>
|
|
||||||
<EditUserIcon width={16} height={16} />
|
|
||||||
</ActionButton>
|
|
||||||
<ActionButton onClick={$target => params.onDeleteUser($target, params.value)}>
|
|
||||||
<DeleteUserIcon width={16} height={16} />
|
|
||||||
</ActionButton>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
|
||||||
background: #eff2f7;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Container = styled.div`
|
|
||||||
padding: 2.2rem;
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1400px;
|
|
||||||
position: relative;
|
|
||||||
margin: 0 auto;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TabNav = styled.div`
|
|
||||||
float: left;
|
|
||||||
width: 220px;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TabNavContent = styled.ul`
|
|
||||||
display: block;
|
|
||||||
width: auto;
|
|
||||||
border-bottom: 0 !important;
|
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.05);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TabNavItem = styled.li`
|
|
||||||
padding: 0.35rem 0.3rem;
|
|
||||||
height: 48px;
|
|
||||||
display: block;
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
const TabNavItemButton = styled.button<{ active: boolean }>`
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
padding-top: 10px !important;
|
|
||||||
padding-bottom: 10px !important;
|
|
||||||
padding-left: 12px !important;
|
|
||||||
padding-right: 8px !important;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
color: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')};
|
|
||||||
&:hover {
|
|
||||||
color: rgba(115, 103, 240);
|
|
||||||
}
|
|
||||||
&:hover svg {
|
|
||||||
fill: rgba(115, 103, 240);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TabNavItemSpan = styled.span`
|
|
||||||
text-align: left;
|
|
||||||
padding-left: 9px;
|
|
||||||
font-size: 14px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TabNavLine = styled.span<{ top: number }>`
|
|
||||||
left: auto;
|
|
||||||
right: 0;
|
|
||||||
width: 2px;
|
|
||||||
height: 48px;
|
|
||||||
transform: scaleX(1);
|
|
||||||
top: ${props => props.top}px;
|
|
||||||
|
|
||||||
background: linear-gradient(30deg, rgba(115, 103, 240), rgba(115, 103, 240));
|
|
||||||
box-shadow: 0 0 8px 0 rgba(115, 103, 240);
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TabContentWrapper = styled.div`
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
margin-left: 1rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TabContent = styled.div`
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
padding: 0;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background-color: #10163a;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const items = [{ name: 'Members' }];
|
|
||||||
|
|
||||||
type NavItemProps = {
|
|
||||||
active: boolean;
|
|
||||||
name: string;
|
|
||||||
tab: number;
|
|
||||||
onClick: (tab: number, top: number) => void;
|
|
||||||
};
|
|
||||||
const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
|
|
||||||
const $item = useRef<HTMLLIElement>(null);
|
|
||||||
return (
|
|
||||||
<TabNavItem
|
|
||||||
key={name}
|
|
||||||
ref={$item}
|
|
||||||
onClick={() => {
|
|
||||||
if ($item && $item.current) {
|
|
||||||
const pos = $item.current.getBoundingClientRect();
|
|
||||||
onClick(tab, pos.top);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TabNavItemButton active={active}>
|
|
||||||
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
|
|
||||||
<TabNavItemSpan>{name}</TabNavItemSpan>
|
|
||||||
</TabNavItemButton>
|
|
||||||
</TabNavItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type AdminProps = {
|
|
||||||
initialTab: number;
|
|
||||||
onAddUser: ($target: React.RefObject<HTMLElement>) => void;
|
|
||||||
onDeleteUser: (userID: string, newOwnerID: string | null) => void;
|
|
||||||
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
|
|
||||||
users: Array<User>;
|
|
||||||
canInviteUser: boolean;
|
|
||||||
onUpdateUserPassword: (user: TaskUser, password: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Admin: React.FC<AdminProps> = ({
|
|
||||||
initialTab,
|
|
||||||
onAddUser,
|
|
||||||
onUpdateUserPassword,
|
|
||||||
canInviteUser,
|
|
||||||
onDeleteUser,
|
|
||||||
onInviteUser,
|
|
||||||
users,
|
|
||||||
}) => {
|
|
||||||
const warning =
|
|
||||||
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
|
||||||
const [currentTop, setTop] = useState(initialTab * 48);
|
|
||||||
const [currentTab, setTab] = useState(initialTab);
|
|
||||||
const { showPopup, hidePopup } = usePopup();
|
|
||||||
const $tabNav = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const [updateUserRole] = useUpdateUserRoleMutation();
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<TabNav ref={$tabNav}>
|
|
||||||
<TabNavContent>
|
|
||||||
{items.map((item, idx) => (
|
|
||||||
<NavItem
|
|
||||||
onClick={(tab, top) => {
|
|
||||||
if ($tabNav && $tabNav.current) {
|
|
||||||
const pos = $tabNav.current.getBoundingClientRect();
|
|
||||||
setTab(tab);
|
|
||||||
setTop(top - pos.top);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
name={item.name}
|
|
||||||
tab={idx}
|
|
||||||
active={idx === currentTab}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<TabNavLine top={currentTop} />
|
|
||||||
</TabNavContent>
|
|
||||||
</TabNav>
|
|
||||||
<TabContentWrapper>
|
|
||||||
<TabContent>
|
|
||||||
<MemberListWrapper>
|
|
||||||
<MemberListHeader>
|
|
||||||
<ListTitle>{`Members (${users.length})`}</ListTitle>
|
|
||||||
<ListDesc>
|
|
||||||
Organization admins can create / manage / delete all projects & teams. Members only have access to teams
|
|
||||||
or projects they have been added to.
|
|
||||||
</ListDesc>
|
|
||||||
<ListActions>
|
|
||||||
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
|
|
||||||
{canInviteUser && (
|
|
||||||
<InviteMemberButton
|
|
||||||
onClick={$target => {
|
|
||||||
onAddUser($target);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InviteIcon width={16} height={16} />
|
|
||||||
New Member
|
|
||||||
</InviteMemberButton>
|
|
||||||
)}
|
|
||||||
</ListActions>
|
|
||||||
</MemberListHeader>
|
|
||||||
<MemberList>
|
|
||||||
{users.map(member => {
|
|
||||||
const projectTotal = member.owned.projects.length + member.member.projects.length;
|
|
||||||
return (
|
|
||||||
<MemberListItem>
|
|
||||||
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} />
|
|
||||||
<MemberListItemDetails>
|
|
||||||
<MemberItemName>{member.fullName}</MemberItemName>
|
|
||||||
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
|
|
||||||
</MemberListItemDetails>
|
|
||||||
<MemberItemOptions>
|
|
||||||
<MemberItemOption variant="flat">{`On ${projectTotal} projects`}</MemberItemOption>
|
|
||||||
<MemberItemOption
|
|
||||||
variant="outline"
|
|
||||||
onClick={$target => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<TeamRoleManagerPopup
|
|
||||||
user={member}
|
|
||||||
users={users}
|
|
||||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
|
||||||
updateUserPassword={(user, password) => {
|
|
||||||
onUpdateUserPassword(user, password);
|
|
||||||
}}
|
|
||||||
canChangeRole={(member.role && member.role.code !== 'owner') ?? false}
|
|
||||||
onChangeRole={roleCode => {
|
|
||||||
updateUserRole({ variables: { userID: member.id, roleCode } });
|
|
||||||
}}
|
|
||||||
onDeleteUser={onDeleteUser}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Manage
|
|
||||||
</MemberItemOption>
|
|
||||||
</MemberItemOptions>
|
|
||||||
</MemberListItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MemberList>
|
|
||||||
</MemberListWrapper>
|
|
||||||
</TabContent>
|
|
||||||
</TabContentWrapper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Admin;
|
|
@ -1,138 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
import BaseStyles from 'App/BaseStyles';
|
|
||||||
import NormalizeStyles from 'App/NormalizeStyles';
|
|
||||||
import { theme } from 'App/ThemeStyles';
|
|
||||||
import styled, { ThemeProvider } from 'styled-components';
|
|
||||||
import Button from '.';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: Button,
|
|
||||||
title: 'Button',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
|
||||||
{ name: 'white', value: '#ffffff' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ButtonRow = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-items: center;
|
|
||||||
margin: 25px;
|
|
||||||
width: 100%;
|
|
||||||
& > button {
|
|
||||||
margin-right: 1.5rem;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BaseStyles />
|
|
||||||
<NormalizeStyles />
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<ButtonRow>
|
|
||||||
<Button>Primary</Button>
|
|
||||||
<Button color="success">Success</Button>
|
|
||||||
<Button color="danger">Danger</Button>
|
|
||||||
<Button color="warning">Warning</Button>
|
|
||||||
<Button color="dark">Dark</Button>
|
|
||||||
<Button disabled>Disabled</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
<ButtonRow>
|
|
||||||
<Button variant="outline">Primary</Button>
|
|
||||||
<Button variant="outline" color="success">
|
|
||||||
Success
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" color="danger">
|
|
||||||
Danger
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" color="warning">
|
|
||||||
Warning
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" color="dark">
|
|
||||||
Dark
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" disabled>
|
|
||||||
Disabled
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
<ButtonRow>
|
|
||||||
<Button variant="flat">Primary</Button>
|
|
||||||
<Button variant="flat" color="success">
|
|
||||||
Success
|
|
||||||
</Button>
|
|
||||||
<Button variant="flat" color="danger">
|
|
||||||
Danger
|
|
||||||
</Button>
|
|
||||||
<Button variant="flat" color="warning">
|
|
||||||
Warning
|
|
||||||
</Button>
|
|
||||||
<Button variant="flat" color="dark">
|
|
||||||
Dark
|
|
||||||
</Button>
|
|
||||||
<Button variant="flat" disabled>
|
|
||||||
Disabled
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
<ButtonRow>
|
|
||||||
<Button variant="lineDown">Primary</Button>
|
|
||||||
<Button variant="lineDown" color="success">
|
|
||||||
Success
|
|
||||||
</Button>
|
|
||||||
<Button variant="lineDown" color="danger">
|
|
||||||
Danger
|
|
||||||
</Button>
|
|
||||||
<Button variant="lineDown" color="warning">
|
|
||||||
Warning
|
|
||||||
</Button>
|
|
||||||
<Button variant="lineDown" color="dark">
|
|
||||||
Dark
|
|
||||||
</Button>
|
|
||||||
<Button variant="lineDown" disabled>
|
|
||||||
Disabled
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
<ButtonRow>
|
|
||||||
<Button variant="gradient">Primary</Button>
|
|
||||||
<Button variant="gradient" color="success">
|
|
||||||
Success
|
|
||||||
</Button>
|
|
||||||
<Button variant="gradient" color="danger">
|
|
||||||
Danger
|
|
||||||
</Button>
|
|
||||||
<Button variant="gradient" color="warning">
|
|
||||||
Warning
|
|
||||||
</Button>
|
|
||||||
<Button variant="gradient" color="dark">
|
|
||||||
Dark
|
|
||||||
</Button>
|
|
||||||
<Button variant="gradient" disabled>
|
|
||||||
Disabled
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
<ButtonRow>
|
|
||||||
<Button variant="relief">Primary</Button>
|
|
||||||
<Button variant="relief" color="success">
|
|
||||||
Success
|
|
||||||
</Button>
|
|
||||||
<Button variant="relief" color="danger">
|
|
||||||
Danger
|
|
||||||
</Button>
|
|
||||||
<Button variant="relief" color="warning">
|
|
||||||
Warning
|
|
||||||
</Button>
|
|
||||||
<Button variant="relief" color="dark">
|
|
||||||
Dark
|
|
||||||
</Button>
|
|
||||||
<Button variant="relief" disabled>
|
|
||||||
Disabled
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
</ThemeProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,211 +0,0 @@
|
|||||||
import React, { useRef } from 'react';
|
|
||||||
import styled, { css } from 'styled-components/macro';
|
|
||||||
|
|
||||||
const Text = styled.span<{ fontSize: string; justifyTextContent: string }>`
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: ${props => props.justifyTextContent};
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-size: ${props => props.fontSize};
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Base = styled.button<{ color: string; disabled: boolean }>`
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
position: relative;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.75rem 2rem;
|
|
||||||
border-radius: ${props => props.theme.borderRadius.alternate};
|
|
||||||
|
|
||||||
${props =>
|
|
||||||
props.disabled &&
|
|
||||||
css`
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
pointer-events: none;
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Filled = styled(Base)`
|
|
||||||
background: rgba(${props => props.theme.colors[props.color]});
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 8px 25px -8px rgba(${props => props.theme.colors[props.color]});
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const Outline = styled(Base)`
|
|
||||||
border: 1px solid rgba(${props => props.theme.colors[props.color]});
|
|
||||||
background: transparent;
|
|
||||||
& ${Text} {
|
|
||||||
color: rgba(${props => props.theme.colors[props.color]});
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(${props => props.theme.colors[props.color]}, 0.08);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Flat = styled(Base)`
|
|
||||||
background: transparent;
|
|
||||||
&:hover {
|
|
||||||
background: rgba(${props => props.theme.colors[props.color]}, 0.2);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const LineX = styled.span<{ color: string }>`
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
position: absolute;
|
|
||||||
height: 2px;
|
|
||||||
width: 0;
|
|
||||||
top: auto;
|
|
||||||
bottom: -2px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%);
|
|
||||||
background: rgba(${props => props.theme.colors[props.color]}, 1);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const LineDown = styled(Base)`
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 0;
|
|
||||||
border-width: 0;
|
|
||||||
border-style: solid;
|
|
||||||
border-bottom-width: 2px;
|
|
||||||
border-color: rgba(${props => props.theme.colors[props.color]}, 0.2);
|
|
||||||
|
|
||||||
&:hover ${LineX} {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
&:hover ${Text} {
|
|
||||||
transform: translateY(2px);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Gradient = styled(Base)`
|
|
||||||
background: linear-gradient(
|
|
||||||
30deg,
|
|
||||||
rgba(${props => props.theme.colors[props.color]}, 1),
|
|
||||||
rgba(${props => props.theme.colors[props.color]}, 0.5)
|
|
||||||
);
|
|
||||||
text-shadow: 1px 2px 4px rgba(0, 0, 0, 0.3);
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Relief = styled(Base)`
|
|
||||||
background: rgba(${props => props.theme.colors[props.color]}, 1);
|
|
||||||
-webkit-box-shadow: 0 -3px 0 0 rgba(0, 0, 0, 0.2) inset;
|
|
||||||
box-shadow: inset 0 -3px 0 0 rgba(0, 0, 0, 0.2);
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(3px);
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
type ButtonProps = {
|
|
||||||
fontSize?: string;
|
|
||||||
variant?: 'filled' | 'outline' | 'flat' | 'lineDown' | 'gradient' | 'relief';
|
|
||||||
color?: 'primary' | 'danger' | 'success' | 'warning' | 'dark';
|
|
||||||
disabled?: boolean;
|
|
||||||
type?: 'button' | 'submit';
|
|
||||||
className?: string;
|
|
||||||
onClick?: ($target: React.RefObject<HTMLButtonElement>) => void;
|
|
||||||
justifyTextContent?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Button: React.FC<ButtonProps> = ({
|
|
||||||
disabled = false,
|
|
||||||
fontSize = '14px',
|
|
||||||
color = 'primary',
|
|
||||||
variant = 'filled',
|
|
||||||
type = 'button',
|
|
||||||
justifyTextContent = 'center',
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const $button = useRef<HTMLButtonElement>(null);
|
|
||||||
const handleClick = () => {
|
|
||||||
if (onClick) {
|
|
||||||
onClick($button);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
switch (variant) {
|
|
||||||
case 'filled':
|
|
||||||
return (
|
|
||||||
<Filled ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}>
|
|
||||||
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</Filled>
|
|
||||||
);
|
|
||||||
case 'outline':
|
|
||||||
return (
|
|
||||||
<Outline
|
|
||||||
ref={$button}
|
|
||||||
type={type}
|
|
||||||
onClick={handleClick}
|
|
||||||
className={className}
|
|
||||||
disabled={disabled}
|
|
||||||
color={color}
|
|
||||||
>
|
|
||||||
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</Outline>
|
|
||||||
);
|
|
||||||
case 'flat':
|
|
||||||
return (
|
|
||||||
<Flat ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}>
|
|
||||||
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</Flat>
|
|
||||||
);
|
|
||||||
case 'lineDown':
|
|
||||||
return (
|
|
||||||
<LineDown
|
|
||||||
ref={$button}
|
|
||||||
type={type}
|
|
||||||
onClick={handleClick}
|
|
||||||
className={className}
|
|
||||||
disabled={disabled}
|
|
||||||
color={color}
|
|
||||||
>
|
|
||||||
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
<LineX color={color} />
|
|
||||||
</LineDown>
|
|
||||||
);
|
|
||||||
case 'gradient':
|
|
||||||
return (
|
|
||||||
<Gradient
|
|
||||||
ref={$button}
|
|
||||||
type={type}
|
|
||||||
onClick={handleClick}
|
|
||||||
className={className}
|
|
||||||
disabled={disabled}
|
|
||||||
color={color}
|
|
||||||
>
|
|
||||||
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</Gradient>
|
|
||||||
);
|
|
||||||
case 'relief':
|
|
||||||
return (
|
|
||||||
<Relief ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}>
|
|
||||||
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</Relief>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
throw new Error('not a valid variant');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Button;
|
|
@ -1,174 +0,0 @@
|
|||||||
import React, { useRef } from 'react';
|
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
import LabelColors from 'shared/constants/labelColors';
|
|
||||||
import Card from '.';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: Card,
|
|
||||||
title: 'Card',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
|
||||||
{ name: 'white', value: '#ffffff' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelData: Array<ProjectLabel> = [
|
|
||||||
{
|
|
||||||
id: 'development',
|
|
||||||
name: 'Development',
|
|
||||||
createdDate: new Date().toString(),
|
|
||||||
labelColor: {
|
|
||||||
id: '1',
|
|
||||||
colorHex: LabelColors.BLUE,
|
|
||||||
name: 'blue',
|
|
||||||
position: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
const $ref = useRef<HTMLDivElement>(null);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
taskID="1"
|
|
||||||
taskGroupID="1"
|
|
||||||
description=""
|
|
||||||
ref={$ref}
|
|
||||||
title="Hello, world"
|
|
||||||
onClick={action('on click')}
|
|
||||||
onContextMenu={action('on context click')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Labels = () => {
|
|
||||||
const $ref = useRef<HTMLDivElement>(null);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
taskID="1"
|
|
||||||
taskGroupID="1"
|
|
||||||
description=""
|
|
||||||
ref={$ref}
|
|
||||||
title="Hello, world"
|
|
||||||
labels={labelData}
|
|
||||||
onClick={action('on click')}
|
|
||||||
onContextMenu={action('on context click')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Badges = () => {
|
|
||||||
const $ref = useRef<HTMLDivElement>(null);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
taskID="1"
|
|
||||||
taskGroupID="1"
|
|
||||||
description="hello!"
|
|
||||||
ref={$ref}
|
|
||||||
title="Hello, world"
|
|
||||||
onClick={action('on click')}
|
|
||||||
onContextMenu={action('on context click')}
|
|
||||||
watched
|
|
||||||
checklists={{ complete: 1, total: 4 }}
|
|
||||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PastDue = () => {
|
|
||||||
const $ref = useRef<HTMLDivElement>(null);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
taskID="1"
|
|
||||||
taskGroupID="1"
|
|
||||||
description="hello!"
|
|
||||||
ref={$ref}
|
|
||||||
title="Hello, world"
|
|
||||||
onClick={action('on click')}
|
|
||||||
onContextMenu={action('on context click')}
|
|
||||||
watched
|
|
||||||
checklists={{ complete: 1, total: 4 }}
|
|
||||||
dueDate={{ isPastDue: true, formattedDate: 'Oct 26, 2020' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Everything = () => {
|
|
||||||
const $ref = useRef<HTMLDivElement>(null);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
taskID="1"
|
|
||||||
taskGroupID="1"
|
|
||||||
description="hello!"
|
|
||||||
ref={$ref}
|
|
||||||
title="Hello, world"
|
|
||||||
onClick={action('on click')}
|
|
||||||
onContextMenu={action('on context click')}
|
|
||||||
watched
|
|
||||||
members={[
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
fullName: 'Jordan Knott',
|
|
||||||
profileIcon: {
|
|
||||||
bgColor: '#0079bf',
|
|
||||||
initials: 'JK',
|
|
||||||
url: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
labels={labelData}
|
|
||||||
checklists={{ complete: 1, total: 4 }}
|
|
||||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Members = () => {
|
|
||||||
const $ref = useRef<HTMLDivElement>(null);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
description={null}
|
|
||||||
taskID="1"
|
|
||||||
taskGroupID="1"
|
|
||||||
ref={$ref}
|
|
||||||
title="Hello, world"
|
|
||||||
onClick={action('on click')}
|
|
||||||
onContextMenu={action('on context click')}
|
|
||||||
members={[
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
fullName: 'Jordan Knott',
|
|
||||||
profileIcon: {
|
|
||||||
bgColor: '#0079bf',
|
|
||||||
initials: 'JK',
|
|
||||||
url: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
labels={[]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Editable = () => {
|
|
||||||
const $ref = useRef<HTMLDivElement>(null);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
taskID="1"
|
|
||||||
taskGroupID="1"
|
|
||||||
description="hello!"
|
|
||||||
ref={$ref}
|
|
||||||
title="Hello, world"
|
|
||||||
onClick={action('on click')}
|
|
||||||
onContextMenu={action('on context click')}
|
|
||||||
watched
|
|
||||||
labels={labelData}
|
|
||||||
checklists={{ complete: 1, total: 4 }}
|
|
||||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
|
||||||
editable
|
|
||||||
onEditCard={action('edit card')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,253 +0,0 @@
|
|||||||
import styled, { css, keyframes } from 'styled-components';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { mixin } from 'shared/utils/styles';
|
|
||||||
import TextareaAutosize from 'react-autosize-textarea';
|
|
||||||
import { CheckCircle, CheckSquareOutline } from 'shared/icons';
|
|
||||||
import { RefObject } from 'react';
|
|
||||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
|
||||||
|
|
||||||
export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
|
|
||||||
box-shadow: 0 0 0 2px rgba(${props => props.theme.colors.bg.secondary}),
|
|
||||||
inset 0 0 0 1px rgba(${props => props.theme.colors.bg.secondary}, 0.07);
|
|
||||||
z-index: ${props => props.zIndex};
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'normal' }>`
|
|
||||||
${props =>
|
|
||||||
props.color === 'success' &&
|
|
||||||
css`
|
|
||||||
fill: rgba(${props.theme.colors.success});
|
|
||||||
stroke: rgba(${props.theme.colors.success});
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
export const ClockIcon = styled(FontAwesomeIcon)``;
|
|
||||||
|
|
||||||
export const EditorTextarea = styled(TextareaAutosize)`
|
|
||||||
overflow: hidden;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
resize: none;
|
|
||||||
height: 90px;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
max-height: 162px;
|
|
||||||
min-height: 54px;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 18px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
&:focus {
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListCardBadges = styled.div`
|
|
||||||
float: left;
|
|
||||||
display: flex;
|
|
||||||
max-width: 100%;
|
|
||||||
margin-left: -2px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListCardBadge = styled.div`
|
|
||||||
color: #5e6c84;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin: 0 6px 4px 0;
|
|
||||||
font-size: 12px;
|
|
||||||
max-width: 100%;
|
|
||||||
min-height: 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
padding: 2px;
|
|
||||||
text-decoration: none;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
vertical-align: top;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DescriptionBadge = styled(ListCardBadge)`
|
|
||||||
padding-right: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>`
|
|
||||||
font-size: 12px;
|
|
||||||
${props =>
|
|
||||||
props.isPastDue &&
|
|
||||||
css`
|
|
||||||
padding-left: 4px;
|
|
||||||
background-color: #ec9488;
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #fff;
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListCardBadgeText = styled.span<{ color?: 'success' | 'normal' }>`
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 0 4px 0 6px;
|
|
||||||
vertical-align: top;
|
|
||||||
white-space: nowrap;
|
|
||||||
${props => props.color === 'success' && `color: rgba(${props.theme.colors.success});`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>`
|
|
||||||
max-width: 256px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
${mixin.boxShadowCard}
|
|
||||||
cursor: pointer !important;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
background-color: ${props =>
|
|
||||||
props.isActive && !props.editable ? mixin.darken('#262c49', 0.1) : `rgba(${props.theme.colors.bg.secondary})`};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListCardInnerContainer = styled.div`
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListCardDetails = styled.div<{ complete: boolean }>`
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 6px 8px 2px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
${props => props.complete && 'opacity: 0.6;'}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const labelVariantExpandAnimation = keyframes`
|
|
||||||
0% {min-width: 40px; width: 40px; height: 8px;}
|
|
||||||
50% {min-width: 56px; width: auto; height: 8px;}
|
|
||||||
100% {min-width: 56px; width: auto; height: 16px;}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const labelTextVariantExpandAnimation = keyframes`
|
|
||||||
0% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
|
||||||
75% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
|
||||||
100% {transform: scale(1); visibility: visible; pointer-events: all;}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const labelVariantShrinkAnimation = keyframes`
|
|
||||||
0% {min-width: 56px; width: auto; height: 16px;}
|
|
||||||
50% {min-width: 56px; width: auto; height: 8px;}
|
|
||||||
100% {min-width: 40px; width: 40px; height: 8px;}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const labelTextVariantShrinkAnimation = keyframes`
|
|
||||||
0% {transform: scale(1); visibility: visible; pointer-events: all;}
|
|
||||||
75% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
|
||||||
100% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
|
||||||
`;
|
|
||||||
export const ListCardLabelText = styled.span`
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 16px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
|
|
||||||
${props =>
|
|
||||||
props.variant === 'small'
|
|
||||||
? css`
|
|
||||||
height: 8px;
|
|
||||||
min-width: 40px;
|
|
||||||
width: 40px;
|
|
||||||
& ${ListCardLabelText} {
|
|
||||||
transform: scale(0);
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
: css`
|
|
||||||
height: 16px;
|
|
||||||
min-width: 56px;
|
|
||||||
width: auto;
|
|
||||||
`}
|
|
||||||
|
|
||||||
padding: 0 8px;
|
|
||||||
max-width: 198px;
|
|
||||||
float: left;
|
|
||||||
margin: 0 4px 4px 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
background-color: ${props => props.color};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>`
|
|
||||||
overflow: auto;
|
|
||||||
position: relative;
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
${props =>
|
|
||||||
props.toggleLabels &&
|
|
||||||
props.toggleDirection === 'expand' &&
|
|
||||||
css`
|
|
||||||
& ${ListCardLabel} {
|
|
||||||
animation: ${labelVariantExpandAnimation} 0.45s ease-out;
|
|
||||||
}
|
|
||||||
& ${ListCardLabelText} {
|
|
||||||
animation: ${labelTextVariantExpandAnimation} 0.45s ease-out;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
${props =>
|
|
||||||
props.toggleLabels &&
|
|
||||||
props.toggleDirection === 'shrink' &&
|
|
||||||
css`
|
|
||||||
& ${ListCardLabel} {
|
|
||||||
animation: ${labelVariantShrinkAnimation} 0.45s ease-out;
|
|
||||||
}
|
|
||||||
& ${ListCardLabelText} {
|
|
||||||
animation: ${labelTextVariantShrinkAnimation} 0.45s ease-out;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
export const ListCardOperation = styled.span`
|
|
||||||
display: flex;
|
|
||||||
align-content: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 3px;
|
|
||||||
opacity: 0.8;
|
|
||||||
padding: 6px;
|
|
||||||
position: absolute;
|
|
||||||
right: 2px;
|
|
||||||
top: 2px;
|
|
||||||
z-index: 100;
|
|
||||||
&:hover {
|
|
||||||
background-color: ${props => mixin.darken('#262c49', 0.25)};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const CardTitle = styled.span`
|
|
||||||
clear: both;
|
|
||||||
margin: 0 0 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-decoration: none;
|
|
||||||
word-wrap: break-word;
|
|
||||||
line-height: 18px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const CardMembers = styled.div`
|
|
||||||
float: right;
|
|
||||||
margin: 0 -2px 4px 0;
|
|
||||||
display: flex;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const CompleteIcon = styled(CheckCircle)`
|
|
||||||
fill: rgba(${props => props.theme.colors.success});
|
|
||||||
margin-right: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const EditorContent = styled.div`
|
|
||||||
display: flex;
|
|
||||||
`;
|
|
@ -1,270 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons';
|
|
||||||
import {
|
|
||||||
EditorTextarea,
|
|
||||||
CardMember,
|
|
||||||
EditorContent,
|
|
||||||
ChecklistIcon,
|
|
||||||
CompleteIcon,
|
|
||||||
DescriptionBadge,
|
|
||||||
DueDateCardBadge,
|
|
||||||
ListCardBadges,
|
|
||||||
ListCardBadge,
|
|
||||||
ListCardBadgeText,
|
|
||||||
ListCardContainer,
|
|
||||||
ListCardInnerContainer,
|
|
||||||
ListCardDetails,
|
|
||||||
ClockIcon,
|
|
||||||
ListCardLabels,
|
|
||||||
ListCardLabel,
|
|
||||||
ListCardLabelText,
|
|
||||||
ListCardOperation,
|
|
||||||
CardTitle,
|
|
||||||
CardMembers,
|
|
||||||
} from './Styles';
|
|
||||||
import { CheckSquare } from 'shared/icons';
|
|
||||||
|
|
||||||
type DueDate = {
|
|
||||||
isPastDue: boolean;
|
|
||||||
formattedDate: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Checklist = {
|
|
||||||
complete: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
title: string;
|
|
||||||
taskID: string;
|
|
||||||
taskGroupID: string;
|
|
||||||
complete?: boolean;
|
|
||||||
position?: string | number;
|
|
||||||
onContextMenu?: ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => void;
|
|
||||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
|
||||||
description?: null | string;
|
|
||||||
dueDate?: DueDate;
|
|
||||||
checklists?: Checklist | null;
|
|
||||||
labels?: Array<ProjectLabel>;
|
|
||||||
watched?: boolean;
|
|
||||||
wrapperProps?: any;
|
|
||||||
members?: Array<TaskUser> | null;
|
|
||||||
onCardLabelClick?: () => void;
|
|
||||||
onCardMemberClick?: OnCardMemberClick;
|
|
||||||
editable?: boolean;
|
|
||||||
setToggleLabels?: (toggle: false) => void;
|
|
||||||
onEditCard?: (taskGroupID: string, taskID: string, cardName: string) => void;
|
|
||||||
onCardTitleChange?: (name: string) => void;
|
|
||||||
labelVariant?: CardLabelVariant;
|
|
||||||
toggleLabels?: boolean;
|
|
||||||
toggleDirection?: 'shrink' | 'expand';
|
|
||||||
};
|
|
||||||
|
|
||||||
const Card = React.forwardRef(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
wrapperProps,
|
|
||||||
onContextMenu,
|
|
||||||
taskID,
|
|
||||||
taskGroupID,
|
|
||||||
complete,
|
|
||||||
toggleLabels = false,
|
|
||||||
toggleDirection = 'shrink',
|
|
||||||
setToggleLabels,
|
|
||||||
onClick,
|
|
||||||
labels,
|
|
||||||
title,
|
|
||||||
dueDate,
|
|
||||||
description,
|
|
||||||
checklists,
|
|
||||||
position,
|
|
||||||
watched,
|
|
||||||
members,
|
|
||||||
labelVariant,
|
|
||||||
onCardMemberClick,
|
|
||||||
editable,
|
|
||||||
onCardLabelClick,
|
|
||||||
onEditCard,
|
|
||||||
onCardTitleChange,
|
|
||||||
}: Props,
|
|
||||||
$cardRef: any,
|
|
||||||
) => {
|
|
||||||
const [currentCardTitle, setCardTitle] = useState(title);
|
|
||||||
const $editorRef: any = useRef();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCardTitle(title);
|
|
||||||
}, [title]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if ($editorRef && $editorRef.current) {
|
|
||||||
$editorRef.current.focus();
|
|
||||||
$editorRef.current.select();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: any) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (onEditCard) {
|
|
||||||
onEditCard(taskGroupID, taskID, currentCardTitle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const [isActive, setActive] = useState(false);
|
|
||||||
const $innerCardRef: any = useRef(null);
|
|
||||||
const onOpenComposer = () => {
|
|
||||||
if (onContextMenu) {
|
|
||||||
onContextMenu($innerCardRef, taskID, taskGroupID);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onTaskContext = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenComposer();
|
|
||||||
};
|
|
||||||
const onOperationClick = (e: React.MouseEvent<HTMLOrSVGElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenComposer();
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<ListCardContainer
|
|
||||||
onMouseEnter={() => setActive(true)}
|
|
||||||
onMouseLeave={() => setActive(false)}
|
|
||||||
ref={$cardRef}
|
|
||||||
onClick={e => {
|
|
||||||
if (onClick) {
|
|
||||||
onClick(e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onContextMenu={onTaskContext}
|
|
||||||
isActive={isActive}
|
|
||||||
editable={editable}
|
|
||||||
{...wrapperProps}
|
|
||||||
>
|
|
||||||
<ListCardInnerContainer ref={$innerCardRef}>
|
|
||||||
{isActive && !editable && (
|
|
||||||
<ListCardOperation
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onContextMenu) {
|
|
||||||
onContextMenu($innerCardRef, taskID, taskGroupID);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
|
|
||||||
</ListCardOperation>
|
|
||||||
)}
|
|
||||||
<ListCardDetails complete={complete ?? false}>
|
|
||||||
<ListCardLabels
|
|
||||||
toggleLabels={toggleLabels}
|
|
||||||
toggleDirection={toggleDirection}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onCardLabelClick) {
|
|
||||||
onCardLabelClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{labels &&
|
|
||||||
labels
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.labelColor.position - b.labelColor.position)
|
|
||||||
.map(label => (
|
|
||||||
<ListCardLabel
|
|
||||||
onAnimationEnd={() => {
|
|
||||||
if (setToggleLabels) {
|
|
||||||
setToggleLabels(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
variant={labelVariant ?? 'large'}
|
|
||||||
color={label.labelColor.colorHex}
|
|
||||||
key={label.id}
|
|
||||||
>
|
|
||||||
<ListCardLabelText>{label.name}</ListCardLabelText>
|
|
||||||
</ListCardLabel>
|
|
||||||
))}
|
|
||||||
</ListCardLabels>
|
|
||||||
{editable ? (
|
|
||||||
<EditorContent>
|
|
||||||
{complete && <CompleteIcon width={16} height={16} />}
|
|
||||||
<EditorTextarea
|
|
||||||
onChange={e => {
|
|
||||||
setCardTitle(e.currentTarget.value);
|
|
||||||
if (onCardTitleChange) {
|
|
||||||
onCardTitleChange(e.currentTarget.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
value={currentCardTitle}
|
|
||||||
ref={$editorRef}
|
|
||||||
/>
|
|
||||||
</EditorContent>
|
|
||||||
) : (
|
|
||||||
<CardTitle>
|
|
||||||
{complete && <CompleteIcon width={16} height={16} />}
|
|
||||||
{`${title}${position ? ' - ' + position : ''}`}
|
|
||||||
</CardTitle>
|
|
||||||
)}
|
|
||||||
<ListCardBadges>
|
|
||||||
{watched && (
|
|
||||||
<ListCardBadge>
|
|
||||||
<FontAwesomeIcon color="#6b778c" icon={faEye} size="xs" />
|
|
||||||
</ListCardBadge>
|
|
||||||
)}
|
|
||||||
{dueDate && (
|
|
||||||
<DueDateCardBadge isPastDue={dueDate.isPastDue}>
|
|
||||||
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} icon={faClock} size="xs" />
|
|
||||||
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
|
|
||||||
</DueDateCardBadge>
|
|
||||||
)}
|
|
||||||
{description && (
|
|
||||||
<DescriptionBadge>
|
|
||||||
<FontAwesomeIcon color="#6b778c" icon={faList} size="xs" />
|
|
||||||
</DescriptionBadge>
|
|
||||||
)}
|
|
||||||
{checklists && (
|
|
||||||
<ListCardBadge>
|
|
||||||
<ChecklistIcon
|
|
||||||
color={checklists.complete === checklists.total ? 'success' : 'normal'}
|
|
||||||
width={8}
|
|
||||||
height={8}
|
|
||||||
/>
|
|
||||||
<ListCardBadgeText
|
|
||||||
color={checklists.complete === checklists.total ? 'success' : 'normal'}
|
|
||||||
>{`${checklists.complete}/${checklists.total}`}</ListCardBadgeText>
|
|
||||||
</ListCardBadge>
|
|
||||||
)}
|
|
||||||
</ListCardBadges>
|
|
||||||
<CardMembers>
|
|
||||||
{members &&
|
|
||||||
members.map((member, idx) => (
|
|
||||||
<CardMember
|
|
||||||
key={member.id}
|
|
||||||
size={28}
|
|
||||||
zIndex={members.length - idx}
|
|
||||||
member={member}
|
|
||||||
onMemberProfile={$target => {
|
|
||||||
if (onCardMemberClick) {
|
|
||||||
onCardMemberClick($target, taskID, member.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</CardMembers>
|
|
||||||
</ListCardDetails>
|
|
||||||
</ListCardInnerContainer>
|
|
||||||
</ListCardContainer>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Card.displayName = 'Card';
|
|
||||||
|
|
||||||
export default Card;
|
|
@ -1,18 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
import CardComposer from '.';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: CardComposer,
|
|
||||||
title: 'CardComposer',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
|
||||||
{ name: 'white', value: '#ffffff' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
return <CardComposer isOpen onClose={action('on close')} onCreateCard={action('on create card')} />;
|
|
||||||
};
|
|
@ -1,34 +0,0 @@
|
|||||||
import styled from 'styled-components';
|
|
||||||
import Button from 'shared/components/Button';
|
|
||||||
import TextareaAutosize from 'react-autosize-textarea';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { mixin } from 'shared/utils/styles';
|
|
||||||
|
|
||||||
export const CancelIcon = styled(FontAwesomeIcon)`
|
|
||||||
opacity: 0.8;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.25em;
|
|
||||||
padding-left: 5px;
|
|
||||||
`;
|
|
||||||
export const CardComposerWrapper = styled.div<{ isOpen: boolean }>`
|
|
||||||
padding-bottom: 8px;
|
|
||||||
display: ${props => (props.isOpen ? 'flex' : 'none')};
|
|
||||||
flex-direction: column;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ComposerControls = styled.div``;
|
|
||||||
|
|
||||||
export const ComposerControlsSaveSection = styled.div`
|
|
||||||
display: flex;
|
|
||||||
float: left;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
export const ComposerControlsActionsSection = styled.div`
|
|
||||||
float: right;
|
|
||||||
`;
|
|
||||||
export const AddCardButton = styled(Button)`
|
|
||||||
margin-right: 4px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 3px;
|
|
||||||
`;
|
|
@ -1,72 +0,0 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
|
|
||||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CardComposerWrapper,
|
|
||||||
CancelIcon,
|
|
||||||
AddCardButton,
|
|
||||||
ComposerControls,
|
|
||||||
ComposerControlsSaveSection,
|
|
||||||
ComposerControlsActionsSection,
|
|
||||||
} from './Styles';
|
|
||||||
import Card from '../Card';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean;
|
|
||||||
onCreateCard: (cardName: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
|
|
||||||
const [cardName, setCardName] = useState('');
|
|
||||||
const $cardRef = useRef<HTMLDivElement>(null);
|
|
||||||
useOnOutsideClick($cardRef, true, onClose, null);
|
|
||||||
useOnEscapeKeyDown(isOpen, onClose);
|
|
||||||
return (
|
|
||||||
<CardComposerWrapper isOpen={isOpen}>
|
|
||||||
<Card
|
|
||||||
title={cardName}
|
|
||||||
ref={$cardRef}
|
|
||||||
taskID=""
|
|
||||||
taskGroupID=""
|
|
||||||
editable
|
|
||||||
onEditCard={(_taskGroupID, _taskID, name) => {
|
|
||||||
onCreateCard(name);
|
|
||||||
setCardName('');
|
|
||||||
}}
|
|
||||||
onCardTitleChange={name => {
|
|
||||||
setCardName(name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ComposerControls>
|
|
||||||
<ComposerControlsSaveSection>
|
|
||||||
<AddCardButton
|
|
||||||
variant="relief"
|
|
||||||
onClick={() => {
|
|
||||||
onCreateCard(cardName);
|
|
||||||
setCardName('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Card
|
|
||||||
</AddCardButton>
|
|
||||||
<CancelIcon onClick={onClose} icon={faTimes} color="#42526e" />
|
|
||||||
</ComposerControlsSaveSection>
|
|
||||||
<ComposerControlsActionsSection />
|
|
||||||
</ComposerControls>
|
|
||||||
</CardComposerWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CardComposer.propTypes = {
|
|
||||||
isOpen: PropTypes.bool,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
onCreateCard: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
CardComposer.defaultProps = {
|
|
||||||
isOpen: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CardComposer;
|
|
@ -1,154 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
import BaseStyles from 'App/BaseStyles';
|
|
||||||
import NormalizeStyles from 'App/NormalizeStyles';
|
|
||||||
import { theme } from 'App/ThemeStyles';
|
|
||||||
import produce from 'immer';
|
|
||||||
import styled, { ThemeProvider } from 'styled-components';
|
|
||||||
import Checklist, { ChecklistItem } from '.';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: Checklist,
|
|
||||||
title: 'Checklist',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
|
||||||
{ name: 'white', value: '#ffffff' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const Container = styled.div`
|
|
||||||
width: 552px;
|
|
||||||
margin: 25px;
|
|
||||||
border: 1px solid rgba(${props => props.theme.colors.bg.primary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const defaultItems = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
position: 1,
|
|
||||||
taskChecklistID: '1',
|
|
||||||
complete: false,
|
|
||||||
name: 'Tasks',
|
|
||||||
assigned: null,
|
|
||||||
dueDate: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
taskChecklistID: '1',
|
|
||||||
position: 2,
|
|
||||||
complete: false,
|
|
||||||
name: 'Projects',
|
|
||||||
assigned: null,
|
|
||||||
dueDate: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
position: 3,
|
|
||||||
taskChecklistID: '1',
|
|
||||||
complete: false,
|
|
||||||
name: 'Teams',
|
|
||||||
assigned: null,
|
|
||||||
dueDate: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
position: 4,
|
|
||||||
complete: false,
|
|
||||||
taskChecklistID: '1',
|
|
||||||
name: 'Organizations',
|
|
||||||
assigned: null,
|
|
||||||
dueDate: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
const [checklistName, setChecklistName] = useState('Checklist');
|
|
||||||
const [items, setItems] = useState(defaultItems);
|
|
||||||
const onToggleItem = (itemID: string, complete: boolean) => {
|
|
||||||
setItems(
|
|
||||||
produce(items, draftState => {
|
|
||||||
const idx = items.findIndex(item => item.id === itemID);
|
|
||||||
if (idx !== -1) {
|
|
||||||
draftState[idx] = {
|
|
||||||
...draftState[idx],
|
|
||||||
complete,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BaseStyles />
|
|
||||||
<NormalizeStyles />
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<Container>
|
|
||||||
<Checklist
|
|
||||||
wrapperProps={{}}
|
|
||||||
handleProps={{}}
|
|
||||||
name={checklistName}
|
|
||||||
checklistID="checklist-one"
|
|
||||||
items={items}
|
|
||||||
onDeleteChecklist={action('delete checklist')}
|
|
||||||
onChangeName={currentName => {
|
|
||||||
setChecklistName(currentName);
|
|
||||||
}}
|
|
||||||
onAddItem={itemName => {
|
|
||||||
let position = 1;
|
|
||||||
const lastItem = items[-1];
|
|
||||||
if (lastItem) {
|
|
||||||
position = lastItem.position * 2 + 1;
|
|
||||||
}
|
|
||||||
setItems([
|
|
||||||
...items,
|
|
||||||
{
|
|
||||||
id: `${Math.random()}`,
|
|
||||||
name: itemName,
|
|
||||||
complete: false,
|
|
||||||
assigned: null,
|
|
||||||
dueDate: null,
|
|
||||||
position,
|
|
||||||
taskChecklistID: '1',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}}
|
|
||||||
onDeleteItem={itemID => {
|
|
||||||
setItems(items.filter(item => item.id !== itemID));
|
|
||||||
}}
|
|
||||||
onChangeItemName={(itemID, currentName) => {
|
|
||||||
setItems(
|
|
||||||
produce(items, draftState => {
|
|
||||||
const idx = items.findIndex(item => item.id === itemID);
|
|
||||||
if (idx !== -1) {
|
|
||||||
draftState[idx] = {
|
|
||||||
...draftState[idx],
|
|
||||||
name: currentName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onToggleItem={onToggleItem}
|
|
||||||
>
|
|
||||||
{items.map((item, idx) => (
|
|
||||||
<ChecklistItem
|
|
||||||
key={item.id}
|
|
||||||
wrapperProps={{}}
|
|
||||||
handleProps={{}}
|
|
||||||
checklistID="id"
|
|
||||||
itemID={item.id}
|
|
||||||
name={item.name}
|
|
||||||
complete={item.complete}
|
|
||||||
onDeleteItem={() => {}}
|
|
||||||
onChangeName={() => {}}
|
|
||||||
onToggleItem={() => {}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Checklist>
|
|
||||||
</Container>
|
|
||||||
</ThemeProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,634 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import { CheckSquare, Trash, Square, CheckSquareOutline, Clock, Cross, AccountPlus } from 'shared/icons';
|
|
||||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
|
||||||
import {
|
|
||||||
isPositionChanged,
|
|
||||||
getSortedDraggables,
|
|
||||||
getNewDraggablePosition,
|
|
||||||
getAfterDropDraggableList,
|
|
||||||
} from 'shared/utils/draggables';
|
|
||||||
import Button from 'shared/components/Button';
|
|
||||||
import TextareaAutosize from 'react-autosize-textarea';
|
|
||||||
import Control from 'react-select/src/components/Control';
|
|
||||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
|
||||||
margin-bottom: 24px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const WindowTitle = styled.div`
|
|
||||||
padding: 8px 0;
|
|
||||||
position: relative;
|
|
||||||
margin: 0 0 4px 40px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const WindowTitleIcon = styled(CheckSquareOutline)`
|
|
||||||
top: 10px;
|
|
||||||
left: -40px;
|
|
||||||
position: absolute;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const WindowChecklistTitle = styled.div`
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-flow: row wrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const WindowTitleText = styled.h3`
|
|
||||||
cursor: pointer;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
margin: 6px 0;
|
|
||||||
display: inline-block;
|
|
||||||
width: auto;
|
|
||||||
min-height: 18px;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 20px;
|
|
||||||
min-width: 40px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const WindowOptions = styled.div`
|
|
||||||
margin: 0 2px 0 auto;
|
|
||||||
float: right;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DeleteButton = styled(Button)`
|
|
||||||
padding: 6px 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ChecklistProgress = styled.div`
|
|
||||||
margin-bottom: 6px;
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
const ChecklistProgressPercent = styled.span`
|
|
||||||
color: #5e6c84;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 10px;
|
|
||||||
position: absolute;
|
|
||||||
left: 5px;
|
|
||||||
top: -1px;
|
|
||||||
text-align: center;
|
|
||||||
width: 32px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ChecklistProgressBar = styled.div`
|
|
||||||
background: rgba(${props => props.theme.colors.bg.primary});
|
|
||||||
border-radius: 4px;
|
|
||||||
clear: both;
|
|
||||||
height: 8px;
|
|
||||||
margin: 0 0 0 40px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
const ChecklistProgressBarCurrent = styled.div<{ width: number }>`
|
|
||||||
width: ${props => props.width}%;
|
|
||||||
background: rgba(${props => (props.width === 100 ? props.theme.colors.success : props.theme.colors.primary)});
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
transition: width 0.14s ease-in, background 0.14s ease-in;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ChecklistItems = styled.div`
|
|
||||||
min-height: 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ChecklistItemUncheckedIcon = styled(Square)``;
|
|
||||||
|
|
||||||
const ChecklistIcon = styled.div`
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
margin: 10px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ChecklistItemCheckedIcon = styled(CheckSquare)`
|
|
||||||
fill: rgba(${props => props.theme.colors.primary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ChecklistItemDetails = styled.div`
|
|
||||||
word-break: break-word;
|
|
||||||
word-wrap: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
`;
|
|
||||||
const ChecklistItemRow = styled.div`
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ChecklistItemTextControls = styled.div`
|
|
||||||
padding: 6px 0;
|
|
||||||
width: 100%;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ChecklistItemText = styled.span<{ complete: boolean }>`
|
|
||||||
color: ${props => (props.complete ? '#5e6c84' : `rgba(${props.theme.colors.text.primary})`)};
|
|
||||||
${props => props.complete && 'text-decoration: line-through;'}
|
|
||||||
line-height: 20px;
|
|
||||||
font-size: 16px;
|
|
||||||
|
|
||||||
min-height: 20px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
align-self: center;
|
|
||||||
flex: 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ChecklistControls = styled.div`
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: row;
|
|
||||||
float: right;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ControlButton = styled.div`
|
|
||||||
opacity: 0;
|
|
||||||
margin-left: 4px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.8);
|
|
||||||
display: flex;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(${props => props.theme.colors.primary}, 1);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ChecklistNameEditorWrapper = styled.div`
|
|
||||||
display: block;
|
|
||||||
float: left;
|
|
||||||
padding-top: 6px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
z-index: 50;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
export const ChecklistNameEditor = styled(TextareaAutosize)`
|
|
||||||
overflow: hidden;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
resize: none;
|
|
||||||
height: 54px;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
max-height: 162px;
|
|
||||||
min-height: 54px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 20px;
|
|
||||||
border: 1px solid rgba(${props => props.theme.colors.primary});
|
|
||||||
border-radius: 3px;
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
|
|
||||||
border-color: rgba(${props => props.theme.colors.border});
|
|
||||||
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
|
||||||
&:focus {
|
|
||||||
border-color: rgba(${props => props.theme.colors.primary});
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AssignUserButton = styled(AccountPlus)`
|
|
||||||
fill: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ClockButton = styled(Clock)`
|
|
||||||
fill: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TrashButton = styled(Trash)`
|
|
||||||
fill: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ChecklistItemWrapper = styled.div<{ ref: any }>`
|
|
||||||
user-select: none;
|
|
||||||
clear: both;
|
|
||||||
padding-left: 40px;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 6px;
|
|
||||||
|
|
||||||
& ${ControlButton}:last-child {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4);
|
|
||||||
}
|
|
||||||
&:hover ${ControlButton} {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const EditControls = styled.div`
|
|
||||||
clear: both;
|
|
||||||
display: flex;
|
|
||||||
padding-bottom: 9px;
|
|
||||||
flex-direction: row;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SaveButton = styled(Button)`
|
|
||||||
margin-right: 4px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
`;
|
|
||||||
const CancelButton = styled.div`
|
|
||||||
cursor: pointer;
|
|
||||||
margin: 5px;
|
|
||||||
& svg {
|
|
||||||
fill: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
}
|
|
||||||
&:hover svg {
|
|
||||||
fill: rgba(${props => props.theme.colors.text.secondary});
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Spacer = styled.div`
|
|
||||||
flex: 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const EditableDeleteButton = styled.button`
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
margin: 0 2px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(${props => props.theme.colors.primary}, 0.8);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const NewItemButton = styled(Button)`
|
|
||||||
padding: 6px 8px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ChecklistNewItem = styled.div`
|
|
||||||
margin: 8px 0;
|
|
||||||
margin-left: 40px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type ChecklistItemProps = {
|
|
||||||
itemID: string;
|
|
||||||
checklistID: string;
|
|
||||||
complete: boolean;
|
|
||||||
name: string;
|
|
||||||
onChangeName: (itemID: string, currentName: string) => void;
|
|
||||||
wrapperProps: any;
|
|
||||||
handleProps: any;
|
|
||||||
onToggleItem: (itemID: string, complete: boolean) => void;
|
|
||||||
onDeleteItem: (checklistIDID: string, itemID: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChecklistItem = React.forwardRef(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
itemID,
|
|
||||||
checklistID,
|
|
||||||
complete,
|
|
||||||
name,
|
|
||||||
wrapperProps,
|
|
||||||
handleProps,
|
|
||||||
onChangeName,
|
|
||||||
onToggleItem,
|
|
||||||
onDeleteItem,
|
|
||||||
}: ChecklistItemProps,
|
|
||||||
$item,
|
|
||||||
) => {
|
|
||||||
const $editor = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const [editting, setEditting] = useState(false);
|
|
||||||
const [currentName, setCurrentName] = useState(name);
|
|
||||||
useEffect(() => {
|
|
||||||
if (editting && $editor && $editor.current) {
|
|
||||||
$editor.current.focus();
|
|
||||||
$editor.current.select();
|
|
||||||
}
|
|
||||||
}, [editting]);
|
|
||||||
// useOnOutsideClick($item, true, () => setEditting(false), null);
|
|
||||||
return (
|
|
||||||
<ChecklistItemWrapper ref={$item} {...wrapperProps} {...handleProps}>
|
|
||||||
<ChecklistIcon
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleItem(itemID, !complete);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{complete ? (
|
|
||||||
<ChecklistItemCheckedIcon width={20} height={20} />
|
|
||||||
) : (
|
|
||||||
<ChecklistItemUncheckedIcon width={20} height={20} />
|
|
||||||
)}
|
|
||||||
</ChecklistIcon>
|
|
||||||
{editting ? (
|
|
||||||
<>
|
|
||||||
<ChecklistNameEditorWrapper>
|
|
||||||
<ChecklistNameEditor
|
|
||||||
ref={$editor}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
onChangeName(itemID, currentName);
|
|
||||||
setEditting(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={e => {
|
|
||||||
setCurrentName(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
value={currentName}
|
|
||||||
/>
|
|
||||||
</ChecklistNameEditorWrapper>
|
|
||||||
<EditControls>
|
|
||||||
<SaveButton
|
|
||||||
onClick={() => {
|
|
||||||
onChangeName(itemID, currentName);
|
|
||||||
setEditting(false);
|
|
||||||
}}
|
|
||||||
variant="relief"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</SaveButton>
|
|
||||||
<CancelButton
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setEditting(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Cross width={20} height={20} />
|
|
||||||
</CancelButton>
|
|
||||||
<Spacer />
|
|
||||||
<EditableDeleteButton
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setEditting(false);
|
|
||||||
onDeleteItem(checklistID, itemID);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash width={16} height={16} />
|
|
||||||
</EditableDeleteButton>
|
|
||||||
</EditControls>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<ChecklistItemDetails
|
|
||||||
onClick={() => {
|
|
||||||
setEditting(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChecklistItemRow>
|
|
||||||
<ChecklistItemTextControls>
|
|
||||||
<ChecklistItemText complete={complete}>{name}</ChecklistItemText>
|
|
||||||
<ChecklistControls>
|
|
||||||
<ControlButton>
|
|
||||||
<AssignUserButton width={14} height={14} />
|
|
||||||
</ControlButton>
|
|
||||||
<ControlButton>
|
|
||||||
<ClockButton width={14} height={14} />
|
|
||||||
</ControlButton>
|
|
||||||
<ControlButton
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDeleteItem(checklistID, itemID);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TrashButton width={14} height={14} />
|
|
||||||
</ControlButton>
|
|
||||||
</ChecklistControls>
|
|
||||||
</ChecklistItemTextControls>
|
|
||||||
</ChecklistItemRow>
|
|
||||||
</ChecklistItemDetails>
|
|
||||||
)}
|
|
||||||
</ChecklistItemWrapper>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
type AddNewItemProps = {
|
|
||||||
onAddItem: (name: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AddNewItem: React.FC<AddNewItemProps> = ({ onAddItem }) => {
|
|
||||||
const $editor = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const $wrapper = useRef<HTMLDivElement>(null);
|
|
||||||
const [currentName, setCurrentName] = useState('');
|
|
||||||
const [editting, setEditting] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (editting && $editor && $editor.current) {
|
|
||||||
$editor.current.focus();
|
|
||||||
$editor.current.select();
|
|
||||||
}
|
|
||||||
}, [editting]);
|
|
||||||
useOnOutsideClick($wrapper, true, () => setEditting(false), null);
|
|
||||||
return (
|
|
||||||
<ChecklistNewItem ref={$wrapper}>
|
|
||||||
{editting ? (
|
|
||||||
<>
|
|
||||||
<ChecklistNameEditorWrapper>
|
|
||||||
<ChecklistNameEditor
|
|
||||||
ref={$editor}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
onAddItem(currentName);
|
|
||||||
setCurrentName('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={e => {
|
|
||||||
setCurrentName(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
value={currentName}
|
|
||||||
/>
|
|
||||||
</ChecklistNameEditorWrapper>
|
|
||||||
<EditControls>
|
|
||||||
<SaveButton
|
|
||||||
onClick={() => {
|
|
||||||
onAddItem(currentName);
|
|
||||||
setCurrentName('');
|
|
||||||
if (editting && $editor && $editor.current) {
|
|
||||||
$editor.current.focus();
|
|
||||||
$editor.current.select();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
variant="relief"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</SaveButton>
|
|
||||||
<CancelButton
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setEditting(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Cross width={20} height={20} />
|
|
||||||
</CancelButton>
|
|
||||||
</EditControls>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<NewItemButton onClick={() => setEditting(true)}>Add an item</NewItemButton>
|
|
||||||
)}
|
|
||||||
</ChecklistNewItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type ChecklistTitleEditorProps = {
|
|
||||||
name: string;
|
|
||||||
onChangeName: (item: string) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ChecklistTitleEditor = React.forwardRef(
|
|
||||||
({ name, onChangeName, onCancel }: ChecklistTitleEditorProps, $name: any) => {
|
|
||||||
const [currentName, setCurrentName] = useState(name);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ChecklistNameEditor
|
|
||||||
ref={$name}
|
|
||||||
value={currentName}
|
|
||||||
onChange={e => {
|
|
||||||
setCurrentName(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
onChangeName(currentName);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<EditControls>
|
|
||||||
<SaveButton
|
|
||||||
onClick={() => {
|
|
||||||
onChangeName(currentName);
|
|
||||||
}}
|
|
||||||
variant="relief"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</SaveButton>
|
|
||||||
<CancelButton
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onCancel();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Cross width={20} height={20} />
|
|
||||||
</CancelButton>
|
|
||||||
</EditControls>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
type ChecklistProps = {
|
|
||||||
checklistID: string;
|
|
||||||
onDeleteChecklist: ($target: React.RefObject<HTMLElement>, checklistID: string) => void;
|
|
||||||
name: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
onChangeName: (item: string) => void;
|
|
||||||
onToggleItem: (taskID: string, complete: boolean) => void;
|
|
||||||
onChangeItemName: (itemID: string, currentName: string) => void;
|
|
||||||
wrapperProps: any;
|
|
||||||
handleProps: any;
|
|
||||||
onDeleteItem: (checklistID: string, itemID: string) => void;
|
|
||||||
onAddItem: (itemName: string) => void;
|
|
||||||
items: Array<TaskChecklistItem>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Checklist = React.forwardRef(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
checklistID,
|
|
||||||
children,
|
|
||||||
onDeleteChecklist,
|
|
||||||
name,
|
|
||||||
items,
|
|
||||||
wrapperProps,
|
|
||||||
handleProps,
|
|
||||||
onToggleItem,
|
|
||||||
onAddItem,
|
|
||||||
onChangeItemName,
|
|
||||||
onChangeName,
|
|
||||||
onDeleteItem,
|
|
||||||
}: ChecklistProps,
|
|
||||||
$container,
|
|
||||||
) => {
|
|
||||||
const $name = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const complete = items.reduce((prev, item) => prev + (item.complete ? 1 : 0), 0);
|
|
||||||
const percent = items.length === 0 ? 0 : Math.floor((complete / items.length) * 100);
|
|
||||||
const [editting, setEditting] = useState(false);
|
|
||||||
// useOnOutsideClick($name, true, () => setEditting(false), null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (editting && $name && $name.current) {
|
|
||||||
$name.current.focus();
|
|
||||||
$name.current.select();
|
|
||||||
}
|
|
||||||
}, [editting]);
|
|
||||||
return (
|
|
||||||
<Wrapper ref={$container} {...wrapperProps}>
|
|
||||||
<WindowTitle>
|
|
||||||
<WindowTitleIcon width={24} height={24} />
|
|
||||||
{editting ? (
|
|
||||||
<ChecklistTitleEditor
|
|
||||||
ref={$name}
|
|
||||||
name={name}
|
|
||||||
onChangeName={currentName => {
|
|
||||||
onChangeName(currentName);
|
|
||||||
setEditting(false);
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
setEditting(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<WindowChecklistTitle {...handleProps}>
|
|
||||||
<WindowTitleText onClick={() => setEditting(true)}>{name}</WindowTitleText>
|
|
||||||
<WindowOptions>
|
|
||||||
<DeleteButton
|
|
||||||
onClick={$target => {
|
|
||||||
onDeleteChecklist($target, checklistID);
|
|
||||||
}}
|
|
||||||
color="danger"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</DeleteButton>
|
|
||||||
</WindowOptions>
|
|
||||||
</WindowChecklistTitle>
|
|
||||||
)}
|
|
||||||
</WindowTitle>
|
|
||||||
<ChecklistProgress>
|
|
||||||
<ChecklistProgressPercent>{`${percent}%`}</ChecklistProgressPercent>
|
|
||||||
<ChecklistProgressBar>
|
|
||||||
<ChecklistProgressBarCurrent width={percent} />
|
|
||||||
</ChecklistProgressBar>
|
|
||||||
</ChecklistProgress>
|
|
||||||
{children}
|
|
||||||
<AddNewItem onAddItem={onAddItem} />
|
|
||||||
</Wrapper>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
/*
|
|
||||||
<ChecklistItems>
|
|
||||||
{items
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.position - b.position)
|
|
||||||
.map((item, idx) => (
|
|
||||||
<ChecklistItem
|
|
||||||
index={idx}
|
|
||||||
key={item.id}
|
|
||||||
itemID={item.id}
|
|
||||||
name={item.name}
|
|
||||||
complete={item.complete}
|
|
||||||
onDeleteItem={onDeleteItem}
|
|
||||||
onChangeName={onChangeItemName}
|
|
||||||
onToggleItem={onToggleItem}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
</ChecklistItems>
|
|
||||||
*/
|
|
||||||
export default Checklist;
|
|
@ -1,43 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import BaseStyles from 'App/BaseStyles';
|
|
||||||
import NormalizeStyles from 'App/NormalizeStyles';
|
|
||||||
import { theme } from 'App/ThemeStyles';
|
|
||||||
import styled, { ThemeProvider } from 'styled-components';
|
|
||||||
|
|
||||||
import Input from '.';
|
|
||||||
import { User } from 'shared/icons';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: Input,
|
|
||||||
title: 'Input',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'white', value: '#ffffff', default: true },
|
|
||||||
{ name: 'gray', value: '#f8f8f8' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
|
||||||
background: rgba(${props => props.theme.colors.bg.primary});
|
|
||||||
padding: 45px;
|
|
||||||
margin: 25px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<NormalizeStyles />
|
|
||||||
<BaseStyles />
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<Wrapper>
|
|
||||||
<Input label="Label placeholder" />
|
|
||||||
<Input width="100%" placeholder="Placeholder" />
|
|
||||||
<Input icon={<User size={20} />} width="100%" placeholder="Placeholder" />
|
|
||||||
</Wrapper>
|
|
||||||
</ThemeProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,155 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import styled, { css } from 'styled-components/macro';
|
|
||||||
|
|
||||||
const InputWrapper = styled.div<{ width: string }>`
|
|
||||||
position: relative;
|
|
||||||
width: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
margin-bottom: 2.2rem;
|
|
||||||
margin-top: 24px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const InputLabel = styled.span<{ width: string }>`
|
|
||||||
width: ${props => props.width};
|
|
||||||
padding: 0.7rem !important;
|
|
||||||
color: #c2c6dc;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: text;
|
|
||||||
font-size: 12px;
|
|
||||||
user-select: none;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const InputInput = styled.input<{
|
|
||||||
hasValue: boolean;
|
|
||||||
hasIcon: boolean;
|
|
||||||
width: string;
|
|
||||||
focusBg: string;
|
|
||||||
borderColor: string;
|
|
||||||
}>`
|
|
||||||
width: ${props => props.width};
|
|
||||||
font-size: 14px;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
||||||
border-color: ${props => props.borderColor};
|
|
||||||
background: #262c49;
|
|
||||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
|
|
||||||
${props => (props.hasIcon ? 'padding: 0.7rem 1rem 0.7rem 3rem;' : 'padding: 0.7rem;')}
|
|
||||||
line-height: 16px;
|
|
||||||
color: #c2c6dc;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 5px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
&:focus {
|
|
||||||
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
|
|
||||||
border: 1px solid rgba(115, 103, 240);
|
|
||||||
background: ${props => props.focusBg};
|
|
||||||
}
|
|
||||||
&:focus ~ ${InputLabel} {
|
|
||||||
color: rgba(115, 103, 240);
|
|
||||||
transform: translate(-3px, -90%);
|
|
||||||
}
|
|
||||||
${props =>
|
|
||||||
props.hasValue &&
|
|
||||||
css`
|
|
||||||
& ~ ${InputLabel} {
|
|
||||||
color: rgba(115, 103, 240);
|
|
||||||
transform: translate(-3px, -90%);
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Icon = styled.div`
|
|
||||||
display: flex;
|
|
||||||
left: 16px;
|
|
||||||
position: absolute;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type ControlledInputProps = {
|
|
||||||
variant?: 'normal' | 'alternate';
|
|
||||||
label?: string;
|
|
||||||
width?: string;
|
|
||||||
floatingLabel?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
icon?: JSX.Element;
|
|
||||||
type?: string;
|
|
||||||
autocomplete?: boolean;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
className?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
value?: string;
|
|
||||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ControlledInput = ({
|
|
||||||
width = 'auto',
|
|
||||||
variant = 'normal',
|
|
||||||
type = 'text',
|
|
||||||
autocomplete,
|
|
||||||
autoFocus = false,
|
|
||||||
label,
|
|
||||||
placeholder,
|
|
||||||
icon,
|
|
||||||
name,
|
|
||||||
className,
|
|
||||||
onChange,
|
|
||||||
value,
|
|
||||||
onClick,
|
|
||||||
floatingLabel = false,
|
|
||||||
defaultValue,
|
|
||||||
id,
|
|
||||||
}: ControlledInputProps) => {
|
|
||||||
const $input = useRef<HTMLInputElement>(null);
|
|
||||||
const [hasValue, setHasValue] = useState(false);
|
|
||||||
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561';
|
|
||||||
const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)';
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoFocus && $input && $input.current) {
|
|
||||||
$input.current.focus();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<InputWrapper className={className} width={width}>
|
|
||||||
<InputInput
|
|
||||||
hasValue={hasValue}
|
|
||||||
onChange={e => {
|
|
||||||
if (onChange) {
|
|
||||||
setHasValue(e.currentTarget.value !== '' || floatingLabel);
|
|
||||||
onChange(e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={value}
|
|
||||||
id={id}
|
|
||||||
type={type}
|
|
||||||
name={name}
|
|
||||||
ref={$input}
|
|
||||||
onClick={onClick}
|
|
||||||
autoComplete={autocomplete ? 'on' : 'off'}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
hasIcon={typeof icon !== 'undefined'}
|
|
||||||
width={width}
|
|
||||||
placeholder={placeholder}
|
|
||||||
focusBg={focusBg}
|
|
||||||
borderColor={borderColor}
|
|
||||||
/>
|
|
||||||
{label && <InputLabel width={width}>{label}</InputLabel>}
|
|
||||||
<Icon>{icon && icon}</Icon>
|
|
||||||
</InputWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ControlledInput;
|
|
@ -1,64 +0,0 @@
|
|||||||
import React, { createRef, useState } from 'react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
import DropdownMenu from '.';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: DropdownMenu,
|
|
||||||
title: 'DropdownMenu',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'white', value: '#ffffff' },
|
|
||||||
{ name: 'gray', value: '#f8f8f8' },
|
|
||||||
{ name: 'darkBlue', value: '#262c49', default: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const Container = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
const Button = styled.div`
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 15px 20px;
|
|
||||||
color: #fff;
|
|
||||||
background: #000;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
const [menu, setMenu] = useState({
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
isOpen: false,
|
|
||||||
});
|
|
||||||
const $buttonRef: any = createRef();
|
|
||||||
const onClick = () => {
|
|
||||||
setMenu({
|
|
||||||
isOpen: !menu.isOpen,
|
|
||||||
left: $buttonRef.current.getBoundingClientRect().right,
|
|
||||||
top: $buttonRef.current.getBoundingClientRect().bottom,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Container>
|
|
||||||
<Button onClick={onClick} ref={$buttonRef}>
|
|
||||||
Click me
|
|
||||||
</Button>
|
|
||||||
</Container>
|
|
||||||
{menu.isOpen && (
|
|
||||||
<DropdownMenu
|
|
||||||
onAdminConsole={action('admin')}
|
|
||||||
onCloseDropdown={() => {
|
|
||||||
setMenu({ top: 0, left: 0, isOpen: false });
|
|
||||||
}}
|
|
||||||
onLogout={action('on logout')}
|
|
||||||
left={menu.left}
|
|
||||||
top={menu.top}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,74 +0,0 @@
|
|||||||
import styled from 'styled-components/macro';
|
|
||||||
|
|
||||||
export const Container = styled.div<{ left: number; top: number }>`
|
|
||||||
position: absolute;
|
|
||||||
left: ${props => props.left}px;
|
|
||||||
top: ${props => props.top}px;
|
|
||||||
position: absolute;
|
|
||||||
padding-top: 10px;
|
|
||||||
height: auto;
|
|
||||||
width: auto;
|
|
||||||
transform: translate(-100%);
|
|
||||||
transition: opacity 0.25s, transform 0.25s, width 0.3s ease;
|
|
||||||
z-index: 40000;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Wrapper = styled.div`
|
|
||||||
padding: 5px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
|
|
||||||
position: relative;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
color: #c2c6dc;
|
|
||||||
background: #262c49;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-color: #414561;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const WrapperDiamond = styled.div`
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
position: absolute;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
display: block;
|
|
||||||
transform: rotate(45deg) translate(-7px);
|
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
background: #262c49;
|
|
||||||
border-color: #414561;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ActionsList = styled.ul`
|
|
||||||
min-width: 9rem;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ActionItem = styled.li`
|
|
||||||
padding-left: 1rem;
|
|
||||||
padding-right: 1rem;
|
|
||||||
padding-top: 0.5rem !important;
|
|
||||||
padding-bottom: 0.5rem !important;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 14px;
|
|
||||||
&:hover {
|
|
||||||
background: rgb(115, 103, 240);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ActionTitle = styled.span`
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Separator = styled.div`
|
|
||||||
height: 1px;
|
|
||||||
border-top: 1px solid #414561;
|
|
||||||
margin: 0.25rem !important;
|
|
||||||
`;
|
|
@ -1,72 +0,0 @@
|
|||||||
import React, { useRef } from 'react';
|
|
||||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
|
||||||
import { Exit, User, Cog } from 'shared/icons';
|
|
||||||
import { Separator, Container, WrapperDiamond, Wrapper, ActionsList, ActionItem, ActionTitle } from './Styles';
|
|
||||||
|
|
||||||
type DropdownMenuProps = {
|
|
||||||
left: number;
|
|
||||||
top: number;
|
|
||||||
onLogout: () => void;
|
|
||||||
onCloseDropdown: () => void;
|
|
||||||
onAdminConsole: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DropdownMenu: React.FC<DropdownMenuProps> = ({ left, top, onLogout, onCloseDropdown, onAdminConsole }) => {
|
|
||||||
const $containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
useOnOutsideClick($containerRef, true, onCloseDropdown, null);
|
|
||||||
return (
|
|
||||||
<Container ref={$containerRef} left={left} top={top}>
|
|
||||||
<Wrapper>
|
|
||||||
<ActionItem onClick={onAdminConsole}>
|
|
||||||
<User size={16} color="#c2c6dc" />
|
|
||||||
<ActionTitle>Profile</ActionTitle>
|
|
||||||
</ActionItem>
|
|
||||||
<Separator />
|
|
||||||
<ActionsList>
|
|
||||||
<ActionItem onClick={onLogout}>
|
|
||||||
<Exit size={16} color="#c2c6dc" />
|
|
||||||
<ActionTitle>Logout</ActionTitle>
|
|
||||||
</ActionItem>
|
|
||||||
</ActionsList>
|
|
||||||
</Wrapper>
|
|
||||||
<WrapperDiamond />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProfileMenuProps = {
|
|
||||||
onProfile: () => void;
|
|
||||||
onLogout: () => void;
|
|
||||||
showAdminConsole: boolean;
|
|
||||||
onAdminConsole: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ showAdminConsole, onAdminConsole, onProfile, onLogout }) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showAdminConsole && (
|
|
||||||
<>
|
|
||||||
<ActionItem onClick={onAdminConsole}>
|
|
||||||
<Cog size={16} color="#c2c6dc" />
|
|
||||||
<ActionTitle>Admin Console</ActionTitle>
|
|
||||||
</ActionItem>
|
|
||||||
<Separator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<ActionItem onClick={onProfile}>
|
|
||||||
<User size={16} color="#c2c6dc" />
|
|
||||||
<ActionTitle>Profile</ActionTitle>
|
|
||||||
</ActionItem>
|
|
||||||
<ActionsList>
|
|
||||||
<ActionItem onClick={onLogout}>
|
|
||||||
<Exit size={16} color="#c2c6dc" />
|
|
||||||
<ActionTitle>Logout</ActionTitle>
|
|
||||||
</ActionItem>
|
|
||||||
</ActionsList>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { ProfileMenu };
|
|
||||||
|
|
||||||
export default DropdownMenu;
|
|
@ -1,74 +0,0 @@
|
|||||||
import React, { useRef } from 'react';
|
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
import BaseStyles from 'App/BaseStyles';
|
|
||||||
import NormalizeStyles from 'App/NormalizeStyles';
|
|
||||||
import { theme } from 'App/ThemeStyles';
|
|
||||||
import styled, { ThemeProvider } from 'styled-components';
|
|
||||||
import { Popup } from '../PopupMenu';
|
|
||||||
import DueDateManager from '.';
|
|
||||||
|
|
||||||
const PopupWrapper = styled.div`
|
|
||||||
width: 310px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: DueDateManager,
|
|
||||||
title: 'DueDateManager',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
|
||||||
{ name: 'white', value: '#ffffff' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<NormalizeStyles />
|
|
||||||
<BaseStyles />
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<PopupWrapper>
|
|
||||||
<Popup title={null} tab={0}>
|
|
||||||
<DueDateManager
|
|
||||||
task={{
|
|
||||||
id: '1',
|
|
||||||
taskGroup: { name: 'General', id: '1', position: 1 },
|
|
||||||
name: 'Hello, world',
|
|
||||||
position: 1,
|
|
||||||
labels: [
|
|
||||||
{
|
|
||||||
id: 'soft-skills',
|
|
||||||
assignedDate: new Date().toString(),
|
|
||||||
projectLabel: {
|
|
||||||
createdDate: new Date().toString(),
|
|
||||||
id: 'label-soft-skills',
|
|
||||||
name: 'Soft Skills',
|
|
||||||
labelColor: {
|
|
||||||
id: '1',
|
|
||||||
name: 'white',
|
|
||||||
colorHex: '#fff',
|
|
||||||
position: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
description: 'hello!',
|
|
||||||
assigned: [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
profileIcon: { url: null, initials: null, bgColor: null },
|
|
||||||
fullName: 'Jordan Knott',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
onCancel={action('cancel')}
|
|
||||||
onDueDateChange={action('due date change')}
|
|
||||||
onRemoveDueDate={action('remove due date')}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
</PopupWrapper>
|
|
||||||
</ThemeProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,133 +0,0 @@
|
|||||||
import styled from 'styled-components';
|
|
||||||
import Button from 'shared/components/Button';
|
|
||||||
import { mixin } from 'shared/utils/styles';
|
|
||||||
import Input from 'shared/components/Input';
|
|
||||||
|
|
||||||
export const Wrapper = styled.div`
|
|
||||||
display: flex
|
|
||||||
flex-direction: column;
|
|
||||||
& .react-datepicker {
|
|
||||||
background: #262c49;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
& .react-datepicker__triangle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
& .react-datepicker-popper {
|
|
||||||
z-index: 10000;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .react-datepicker-time__header {
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
}
|
|
||||||
& .react-datepicker__time-list-item {
|
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
|
||||||
}
|
|
||||||
& .react-datepicker__time-container .react-datepicker__time
|
|
||||||
.react-datepicker__time-box ul.react-datepicker__time-list
|
|
||||||
li.react-datepicker__time-list-item:hover {
|
|
||||||
color: rgba(${props => props.theme.colors.text.secondary});
|
|
||||||
background: rgba(${props => props.theme.colors.bg.secondary});
|
|
||||||
}
|
|
||||||
& .react-datepicker__time-container .react-datepicker__time {
|
|
||||||
background: rgba(${props => props.theme.colors.bg.primary});
|
|
||||||
}
|
|
||||||
& .react-datepicker--time-only {
|
|
||||||
background: rgba(${props => props.theme.colors.bg.primary});
|
|
||||||
border: 1px solid rgba(${props => props.theme.colors.border});
|
|
||||||
}
|
|
||||||
|
|
||||||
& .react-datepicker * {
|
|
||||||
box-sizing: content-box;
|
|
||||||
}
|
|
||||||
& .react-datepicker__day-name {
|
|
||||||
color: #c2c6dc;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 4px;
|
|
||||||
font-size: 12px40px
|
|
||||||
line-height: 40px;
|
|
||||||
}
|
|
||||||
& .react-datepicker__day-name:hover {
|
|
||||||
background: #10163a;
|
|
||||||
}
|
|
||||||
& .react-datepicker__month {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .react-datepicker__day,
|
|
||||||
& .react-datepicker__time-name {
|
|
||||||
color: #c2c6dc;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .react-datepicker__day--outside-month {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .react-datepicker__day:hover {
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #10163a;
|
|
||||||
}
|
|
||||||
& .react-datepicker__day--selected {
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(115, 103, 240);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
& .react-datepicker__day--selected:hover {
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(115, 103, 240);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
& .react-datepicker__header {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
& .react-datepicker__header--time {
|
|
||||||
border-bottom: 1px solid rgba(${props => props.theme.colors.border});
|
|
||||||
}
|
|
||||||
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DueDatePickerWrapper = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ConfirmAddDueDate = styled(Button)`
|
|
||||||
margin: 0 4px 0 0;
|
|
||||||
padding: 6px 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const RemoveDueDate = styled(Button)`
|
|
||||||
padding: 6px 12px;
|
|
||||||
margin: 0 0 0 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const CancelDueDate = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 32px;
|
|
||||||
width: 32px;
|
|
||||||
cursor: pointer;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const DueDateInput = styled(Input)`
|
|
||||||
margin-top: 15px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
padding-right: 10px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ActionWrapper = styled.div`
|
|
||||||
padding-top: 8px;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
`;
|
|
@ -1,276 +0,0 @@
|
|||||||
import React, { useState, useEffect, forwardRef } from 'react';
|
|
||||||
import moment from 'moment';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import DatePicker from 'react-datepicker';
|
|
||||||
import { Cross } from 'shared/icons';
|
|
||||||
import _ from 'lodash';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Wrapper,
|
|
||||||
ActionWrapper,
|
|
||||||
RemoveDueDate,
|
|
||||||
DueDateInput,
|
|
||||||
DueDatePickerWrapper,
|
|
||||||
ConfirmAddDueDate,
|
|
||||||
CancelDueDate,
|
|
||||||
} from './Styles';
|
|
||||||
|
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
|
||||||
import { getYear, getMonth } from 'date-fns';
|
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
|
||||||
|
|
||||||
type DueDateManagerProps = {
|
|
||||||
task: Task;
|
|
||||||
onDueDateChange: (task: Task, newDueDate: Date) => void;
|
|
||||||
onRemoveDueDate: (task: Task) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Form = styled.form`
|
|
||||||
padding-top: 25px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FormField = styled.div`
|
|
||||||
width: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
`;
|
|
||||||
const HeaderSelectLabel = styled.div`
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
z-index: 9999;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px 10px;
|
|
||||||
text-decoration: underline;
|
|
||||||
margin: 6px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 16px;
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
padding-left: 4px;
|
|
||||||
padding-right: 4px;
|
|
||||||
color: #c2c6dc;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(115, 103, 240);
|
|
||||||
color: #c2c6dc;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const HeaderSelect = styled.select`
|
|
||||||
text-decoration: underline;
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: center;
|
|
||||||
padding: 4px 6px;
|
|
||||||
background: none;
|
|
||||||
outline: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
appearance: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #262c49;
|
|
||||||
border: 1px solid rgba(115, 103, 240);
|
|
||||||
outline: none !important;
|
|
||||||
box-shadow: none;
|
|
||||||
color: #c2c6dc;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-ms-expand {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 9998;
|
|
||||||
margin: 0;
|
|
||||||
left: 0;
|
|
||||||
top: 5px;
|
|
||||||
opacity: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const HeaderButton = styled.button`
|
|
||||||
cursor: pointer;
|
|
||||||
color: #c2c6dc;
|
|
||||||
text-decoration: underline;
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: center;
|
|
||||||
padding: 6px 10px;
|
|
||||||
margin: 6px 0;
|
|
||||||
background: none;
|
|
||||||
outline: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
&:hover {
|
|
||||||
background: rgba(115, 103, 240);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const HeaderActions = styled.div`
|
|
||||||
position: relative;
|
|
||||||
text-align: center;
|
|
||||||
& > button:first-child {
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
& > button:last-child {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
|
|
||||||
const now = moment();
|
|
||||||
const [textStartDate, setTextStartDate] = useState(now.format('YYYY-MM-DD'));
|
|
||||||
const [startDate, setStartDate] = useState(new Date());
|
|
||||||
useEffect(() => {
|
|
||||||
setTextStartDate(moment(startDate).format('YYYY-MM-DD'));
|
|
||||||
}, [startDate]);
|
|
||||||
|
|
||||||
const [textEndTime, setTextEndTime] = useState(now.format('h:mm A'));
|
|
||||||
const [endTime, setEndTime] = useState(now.toDate());
|
|
||||||
useEffect(() => {
|
|
||||||
setTextEndTime(moment(endTime).format('h:mm A'));
|
|
||||||
}, [endTime]);
|
|
||||||
|
|
||||||
const years = _.range(2010, getYear(new Date()) + 10, 1);
|
|
||||||
const months = [
|
|
||||||
'January',
|
|
||||||
'February',
|
|
||||||
'March',
|
|
||||||
'April',
|
|
||||||
'May',
|
|
||||||
'June',
|
|
||||||
'July',
|
|
||||||
'August',
|
|
||||||
'September',
|
|
||||||
'October',
|
|
||||||
'November',
|
|
||||||
'December',
|
|
||||||
];
|
|
||||||
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
|
|
||||||
const saveDueDate = (data: any) => {
|
|
||||||
const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A');
|
|
||||||
if (newDate.isValid()) {
|
|
||||||
onDueDateChange(task, newDate.toDate());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => {
|
|
||||||
return (
|
|
||||||
<DueDateInput
|
|
||||||
id="endTime"
|
|
||||||
name="endTime"
|
|
||||||
ref={$ref}
|
|
||||||
width="100%"
|
|
||||||
variant="alternate"
|
|
||||||
label="Date"
|
|
||||||
onClick={onClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<Wrapper>
|
|
||||||
<Form onSubmit={handleSubmit(saveDueDate)}>
|
|
||||||
<FormField>
|
|
||||||
<DueDateInput
|
|
||||||
id="endDate"
|
|
||||||
name="endDate"
|
|
||||||
width="100%"
|
|
||||||
variant="alternate"
|
|
||||||
label="Date"
|
|
||||||
defaultValue={textStartDate}
|
|
||||||
ref={register({
|
|
||||||
required: 'End date is required.',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="endTime"
|
|
||||||
render={({ onChange, onBlur, value }) => (
|
|
||||||
<DatePicker
|
|
||||||
onChange={onChange}
|
|
||||||
selected={value}
|
|
||||||
onBlur={onBlur}
|
|
||||||
showTimeSelect
|
|
||||||
showTimeSelectOnly
|
|
||||||
timeIntervals={15}
|
|
||||||
timeCaption="Time"
|
|
||||||
dateFormat="h:mm aa"
|
|
||||||
customInput={<CustomTimeInput />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<DueDatePickerWrapper>
|
|
||||||
<DatePicker
|
|
||||||
useWeekdaysShort
|
|
||||||
renderCustomHeader={({
|
|
||||||
date,
|
|
||||||
changeYear,
|
|
||||||
changeMonth,
|
|
||||||
decreaseMonth,
|
|
||||||
increaseMonth,
|
|
||||||
prevMonthButtonDisabled,
|
|
||||||
nextMonthButtonDisabled,
|
|
||||||
}) => (
|
|
||||||
<HeaderActions>
|
|
||||||
<HeaderButton onClick={decreaseMonth} disabled={prevMonthButtonDisabled}>
|
|
||||||
Prev
|
|
||||||
</HeaderButton>
|
|
||||||
<HeaderSelectLabel>
|
|
||||||
{months[date.getMonth()]}
|
|
||||||
<HeaderSelect value={getYear(date)} onChange={({ target: { value } }) => changeYear(parseInt(value))}>
|
|
||||||
{years.map(option => (
|
|
||||||
<option key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</HeaderSelect>
|
|
||||||
</HeaderSelectLabel>
|
|
||||||
<HeaderSelectLabel>
|
|
||||||
{date.getFullYear()}
|
|
||||||
<HeaderSelect
|
|
||||||
value={months[getMonth(date)]}
|
|
||||||
onChange={({ target: { value } }) => changeMonth(months.indexOf(value))}
|
|
||||||
>
|
|
||||||
{months.map(option => (
|
|
||||||
<option key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</HeaderSelect>
|
|
||||||
</HeaderSelectLabel>
|
|
||||||
|
|
||||||
<HeaderButton onClick={increaseMonth} disabled={nextMonthButtonDisabled}>
|
|
||||||
Next
|
|
||||||
</HeaderButton>
|
|
||||||
</HeaderActions>
|
|
||||||
)}
|
|
||||||
selected={startDate}
|
|
||||||
inline
|
|
||||||
onChange={date => {
|
|
||||||
setStartDate(date ?? new Date());
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DueDatePickerWrapper>
|
|
||||||
<ActionWrapper>
|
|
||||||
<ConfirmAddDueDate type="submit" onClick={() => {}}>
|
|
||||||
Save
|
|
||||||
</ConfirmAddDueDate>
|
|
||||||
<RemoveDueDate
|
|
||||||
variant="outline"
|
|
||||||
color="danger"
|
|
||||||
onClick={() => {
|
|
||||||
onRemoveDueDate(task);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</RemoveDueDate>
|
|
||||||
</ActionWrapper>
|
|
||||||
</Form>
|
|
||||||
</Wrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DueDateManager;
|
|
@ -1,94 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import styled, { keyframes } from 'styled-components/macro';
|
|
||||||
import { mixin } from 'shared/utils/styles';
|
|
||||||
|
|
||||||
export const BoardContainer = styled.div`
|
|
||||||
position: relative;
|
|
||||||
overflow-y: auto;
|
|
||||||
outline: none;
|
|
||||||
flex-grow: 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const BoardWrapper = styled.div`
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
user-select: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
`;
|
|
||||||
export const Container = styled.div`
|
|
||||||
width: 272px;
|
|
||||||
margin: 0 4px;
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
white-space: nowrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const defaultBaseColor = '#10163a';
|
|
||||||
|
|
||||||
export const defaultHighlightColor = mixin.lighten('#10163a', 0.25);
|
|
||||||
|
|
||||||
export const skeletonKeyframes = keyframes`
|
|
||||||
0% {
|
|
||||||
background-position: -200px 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: calc(200px + 100%) 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Wrapper = styled.div`
|
|
||||||
// background-color: #ebecf0;
|
|
||||||
// background: rgb(244, 245, 247);
|
|
||||||
min-height: 120px;
|
|
||||||
opacity: 0.8;
|
|
||||||
background: #10163a;
|
|
||||||
color: #c2c6dc;
|
|
||||||
|
|
||||||
border-radius: 5px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 100%;
|
|
||||||
position: relative;
|
|
||||||
white-space: normal;
|
|
||||||
|
|
||||||
background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor});
|
|
||||||
background-size: 200px 100%;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
|
|
||||||
animation: ${skeletonKeyframes} 1.2s ease-in-out infinite;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const EmptyBoard: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<BoardContainer>
|
|
||||||
<BoardWrapper>
|
|
||||||
<Container>
|
|
||||||
<Wrapper />
|
|
||||||
</Container>
|
|
||||||
<Container>
|
|
||||||
<Wrapper />
|
|
||||||
</Container>
|
|
||||||
<Container>
|
|
||||||
<Wrapper />
|
|
||||||
</Container>
|
|
||||||
<Container>
|
|
||||||
<Wrapper />
|
|
||||||
</Container>
|
|
||||||
</BoardWrapper>
|
|
||||||
</BoardContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmptyBoard;
|
|
@ -1,43 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import BaseStyles from 'App/BaseStyles';
|
|
||||||
import NormalizeStyles from 'App/NormalizeStyles';
|
|
||||||
import { theme } from 'App/ThemeStyles';
|
|
||||||
import styled, { ThemeProvider } from 'styled-components';
|
|
||||||
|
|
||||||
import Input from '.';
|
|
||||||
import { User } from 'shared/icons';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: Input,
|
|
||||||
title: 'Input',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'white', value: '#ffffff', default: true },
|
|
||||||
{ name: 'gray', value: '#f8f8f8' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
|
||||||
background: rgba(${props => props.theme.colors.bg.primary});
|
|
||||||
padding: 45px;
|
|
||||||
margin: 25px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<NormalizeStyles />
|
|
||||||
<BaseStyles />
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<Wrapper>
|
|
||||||
<Input label="Label placeholder" />
|
|
||||||
<Input width="100%" placeholder="Placeholder" />
|
|
||||||
<Input icon={<User size={20} />} width="100%" placeholder="Placeholder" />
|
|
||||||
</Wrapper>
|
|
||||||
</ThemeProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,182 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import styled, { css } from 'styled-components/macro';
|
|
||||||
|
|
||||||
const InputWrapper = styled.div<{ width: string }>`
|
|
||||||
position: relative;
|
|
||||||
width: ${props => props.width};
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
margin-bottom: 2.2rem;
|
|
||||||
margin-top: 24px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const InputLabel = styled.span<{ width: string }>`
|
|
||||||
width: ${props => props.width};
|
|
||||||
padding: 0.7rem !important;
|
|
||||||
color: #c2c6dc;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: text;
|
|
||||||
font-size: 12px;
|
|
||||||
user-select: none;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const InputInput = styled.input<{
|
|
||||||
hasValue: boolean;
|
|
||||||
hasIcon: boolean;
|
|
||||||
width: string;
|
|
||||||
focusBg: string;
|
|
||||||
borderColor: string;
|
|
||||||
}>`
|
|
||||||
width: ${props => props.width};
|
|
||||||
font-size: 14px;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
||||||
border-color: ${props => props.borderColor};
|
|
||||||
background: #262c49;
|
|
||||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
|
|
||||||
${props => (props.hasIcon ? 'padding: 0.7rem 1rem 0.7rem 3rem;' : 'padding: 0.7rem;')}
|
|
||||||
line-height: 16px;
|
|
||||||
color: #c2c6dc;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 5px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
&:focus {
|
|
||||||
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
|
|
||||||
border: 1px solid rgba(115, 103, 240);
|
|
||||||
background: ${props => props.focusBg};
|
|
||||||
}
|
|
||||||
&:focus ~ ${InputLabel} {
|
|
||||||
color: rgba(115, 103, 240);
|
|
||||||
transform: translate(-3px, -90%);
|
|
||||||
}
|
|
||||||
${props =>
|
|
||||||
props.hasValue &&
|
|
||||||
css`
|
|
||||||
& ~ ${InputLabel} {
|
|
||||||
color: rgba(115, 103, 240);
|
|
||||||
transform: translate(-3px, -90%);
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Icon = styled.div`
|
|
||||||
display: flex;
|
|
||||||
left: 16px;
|
|
||||||
position: absolute;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type InputProps = {
|
|
||||||
variant?: 'normal' | 'alternate';
|
|
||||||
label?: string;
|
|
||||||
width?: string;
|
|
||||||
floatingLabel?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
icon?: JSX.Element;
|
|
||||||
type?: string;
|
|
||||||
autocomplete?: boolean;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
autoSelect?: boolean;
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
className?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function useCombinedRefs(...refs: any) {
|
|
||||||
const targetRef = React.useRef();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
refs.forEach((ref: any) => {
|
|
||||||
if (!ref) return;
|
|
||||||
|
|
||||||
if (typeof ref === 'function') {
|
|
||||||
ref(targetRef.current);
|
|
||||||
} else {
|
|
||||||
ref.current = targetRef.current;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [refs]);
|
|
||||||
|
|
||||||
return targetRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Input = React.forwardRef(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
width = 'auto',
|
|
||||||
variant = 'normal',
|
|
||||||
type = 'text',
|
|
||||||
autoFocus = false,
|
|
||||||
autoSelect = false,
|
|
||||||
autocomplete,
|
|
||||||
label,
|
|
||||||
placeholder,
|
|
||||||
icon,
|
|
||||||
name,
|
|
||||||
className,
|
|
||||||
onClick,
|
|
||||||
floatingLabel,
|
|
||||||
defaultValue,
|
|
||||||
id,
|
|
||||||
}: InputProps,
|
|
||||||
$ref: any,
|
|
||||||
) => {
|
|
||||||
const [hasValue, setHasValue] = useState(defaultValue !== '');
|
|
||||||
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561';
|
|
||||||
const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)';
|
|
||||||
|
|
||||||
// Merge forwarded ref and internal ref in order to be able to access the ref in the useEffect
|
|
||||||
// The forwarded ref is not accessible by itself, which is what the innerRef & combined ref is for
|
|
||||||
// TODO(jordanknott): This is super ugly, find a better approach?
|
|
||||||
const $innerRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
const combinedRef: any = useCombinedRefs($ref, $innerRef);
|
|
||||||
useEffect(() => {
|
|
||||||
if (combinedRef && combinedRef.current) {
|
|
||||||
if (autoFocus) {
|
|
||||||
combinedRef.current.focus();
|
|
||||||
}
|
|
||||||
if (autoSelect) {
|
|
||||||
combinedRef.current.select();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<InputWrapper className={className} width={width}>
|
|
||||||
<InputInput
|
|
||||||
onChange={e => {
|
|
||||||
setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false);
|
|
||||||
}}
|
|
||||||
hasValue={hasValue}
|
|
||||||
ref={combinedRef}
|
|
||||||
id={id}
|
|
||||||
type={type}
|
|
||||||
name={name}
|
|
||||||
onClick={onClick}
|
|
||||||
autoComplete={autocomplete ? 'on' : 'off'}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
hasIcon={typeof icon !== 'undefined'}
|
|
||||||
width={width}
|
|
||||||
placeholder={placeholder}
|
|
||||||
focusBg={focusBg}
|
|
||||||
borderColor={borderColor}
|
|
||||||
/>
|
|
||||||
{label && <InputLabel width={width}>{label}</InputLabel>}
|
|
||||||
<Icon>{icon && icon}</Icon>
|
|
||||||
</InputWrapper>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Input;
|
|
@ -1,145 +0,0 @@
|
|||||||
import React, { createRef } from 'react';
|
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
import Card from 'shared/components/Card';
|
|
||||||
import CardComposer from 'shared/components/CardComposer';
|
|
||||||
import LabelColors from 'shared/constants/labelColors';
|
|
||||||
import List, { ListCards } from '.';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: List,
|
|
||||||
title: 'List',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'white', value: '#ffffff', default: true },
|
|
||||||
{ name: 'gray', value: '#f8f8f8' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelData: Array<ProjectLabel> = [
|
|
||||||
{
|
|
||||||
id: 'development',
|
|
||||||
name: 'Development',
|
|
||||||
createdDate: new Date().toString(),
|
|
||||||
labelColor: {
|
|
||||||
id: '1',
|
|
||||||
colorHex: LabelColors.BLUE,
|
|
||||||
name: 'blue',
|
|
||||||
position: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const createCard = () => {
|
|
||||||
const $ref = createRef<HTMLDivElement>();
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
taskID="1"
|
|
||||||
taskGroupID="1"
|
|
||||||
description="hello!"
|
|
||||||
ref={$ref}
|
|
||||||
title="Hello, world"
|
|
||||||
onClick={action('on click')}
|
|
||||||
onContextMenu={action('on context click')}
|
|
||||||
watched
|
|
||||||
labels={labelData}
|
|
||||||
checklists={{ complete: 1, total: 4 }}
|
|
||||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
return (
|
|
||||||
<List
|
|
||||||
id=""
|
|
||||||
name="General"
|
|
||||||
isComposerOpen={false}
|
|
||||||
onSaveName={action('on save name')}
|
|
||||||
onOpenComposer={action('on open composer')}
|
|
||||||
onExtraMenuOpen={action('extra menu open')}
|
|
||||||
>
|
|
||||||
<ListCards>
|
|
||||||
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen={false} />
|
|
||||||
</ListCards>
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WithCardComposer = () => {
|
|
||||||
return (
|
|
||||||
<List
|
|
||||||
id="1"
|
|
||||||
name="General"
|
|
||||||
isComposerOpen
|
|
||||||
onSaveName={action('on save name')}
|
|
||||||
onOpenComposer={action('on open composer')}
|
|
||||||
onExtraMenuOpen={action('extra menu open')}
|
|
||||||
>
|
|
||||||
<ListCards>
|
|
||||||
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen />
|
|
||||||
</ListCards>
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WithCard = () => {
|
|
||||||
const $cardRef: any = createRef();
|
|
||||||
return (
|
|
||||||
<List
|
|
||||||
id="1"
|
|
||||||
name="General"
|
|
||||||
isComposerOpen={false}
|
|
||||||
onSaveName={action('on save name')}
|
|
||||||
onOpenComposer={action('on open composer')}
|
|
||||||
onExtraMenuOpen={action('extra menu open')}
|
|
||||||
>
|
|
||||||
<ListCards>
|
|
||||||
<Card
|
|
||||||
taskID="1"
|
|
||||||
taskGroupID="1"
|
|
||||||
description="hello!"
|
|
||||||
ref={$cardRef}
|
|
||||||
title="Hello, world"
|
|
||||||
onClick={action('on click')}
|
|
||||||
onContextMenu={action('on context click')}
|
|
||||||
watched
|
|
||||||
labels={labelData}
|
|
||||||
checklists={{ complete: 1, total: 4 }}
|
|
||||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
|
||||||
/>
|
|
||||||
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen={false} />
|
|
||||||
</ListCards>
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export const WithCardAndComposer = () => {
|
|
||||||
const $cardRef: any = createRef();
|
|
||||||
return (
|
|
||||||
<List
|
|
||||||
id="1"
|
|
||||||
name="General"
|
|
||||||
isComposerOpen
|
|
||||||
onSaveName={action('on save name')}
|
|
||||||
onOpenComposer={action('on open composer')}
|
|
||||||
onExtraMenuOpen={action('extra menu open')}
|
|
||||||
>
|
|
||||||
<ListCards>
|
|
||||||
<Card
|
|
||||||
taskID="1"
|
|
||||||
taskGroupID="1"
|
|
||||||
description="hello!"
|
|
||||||
ref={$cardRef}
|
|
||||||
title="Hello, world"
|
|
||||||
onClick={action('on click')}
|
|
||||||
onContextMenu={action('on context click')}
|
|
||||||
watched
|
|
||||||
labels={labelData}
|
|
||||||
checklists={{ complete: 1, total: 4 }}
|
|
||||||
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
|
||||||
/>
|
|
||||||
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen />
|
|
||||||
</ListCards>
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,126 +0,0 @@
|
|||||||
import styled, { css } from 'styled-components';
|
|
||||||
import TextareaAutosize from 'react-autosize-textarea';
|
|
||||||
import { mixin } from 'shared/utils/styles';
|
|
||||||
|
|
||||||
export const Container = styled.div`
|
|
||||||
width: 272px;
|
|
||||||
margin: 0 4px;
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
white-space: nowrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const AddCardContainer = styled.div`
|
|
||||||
min-height: 38px;
|
|
||||||
max-height: 38px;
|
|
||||||
display: ${props => (props.hidden ? 'none' : 'flex')};
|
|
||||||
justify-content: space-between;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const AddCardButton = styled.a`
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #c2c6dc;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
flex: 1 0 auto;
|
|
||||||
margin: 2px 8px 8px 8px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
user-select: none;
|
|
||||||
&:hover {
|
|
||||||
color: #c2c6dc;
|
|
||||||
text-decoration: none;
|
|
||||||
background: rgb(115, 103, 240);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
export const Wrapper = styled.div`
|
|
||||||
// background-color: #ebecf0;
|
|
||||||
// background: rgb(244, 245, 247);
|
|
||||||
background: #10163a;
|
|
||||||
color: #c2c6dc;
|
|
||||||
|
|
||||||
border-radius: 5px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 100%;
|
|
||||||
position: relative;
|
|
||||||
white-space: normal;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const HeaderEditTarget = styled.div<{ isHidden: boolean }>`
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: ${props => (props.isHidden ? 'none' : 'block')};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const HeaderName = styled(TextareaAutosize)`
|
|
||||||
font-size: 14px;
|
|
||||||
border: none;
|
|
||||||
resize: none;
|
|
||||||
overflow: hidden;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 3px;
|
|
||||||
box-shadow: none;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: -4px 0;
|
|
||||||
padding: 4px 8px;
|
|
||||||
|
|
||||||
letter-spacing: normal;
|
|
||||||
word-spacing: normal;
|
|
||||||
text-transform: none;
|
|
||||||
text-indent: 0px;
|
|
||||||
text-shadow: none;
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: start;
|
|
||||||
|
|
||||||
color: #c2c6dc;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Header = styled.div<{ isEditing: boolean }>`
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 10px 8px;
|
|
||||||
position: relative;
|
|
||||||
min-height: 20px;
|
|
||||||
padding-right: 36px;
|
|
||||||
|
|
||||||
${props =>
|
|
||||||
props.isEditing &&
|
|
||||||
css`
|
|
||||||
& ${HeaderName} {
|
|
||||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const AddCardButtonText = styled.span`
|
|
||||||
padding-left: 5px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListCards = styled.div`
|
|
||||||
margin: 0 4px;
|
|
||||||
padding: 0 4px;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 45px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListExtraMenuButtonWrapper = styled.div`
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
right: 4px;
|
|
||||||
top: 4px;
|
|
||||||
z-index: 1;
|
|
||||||
padding: 6px;
|
|
||||||
padding-bottom: 0;
|
|
||||||
`;
|
|
@ -1,125 +0,0 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
|
||||||
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
|
|
||||||
import { Plus, Ellipsis } from 'shared/icons';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Wrapper,
|
|
||||||
Header,
|
|
||||||
HeaderName,
|
|
||||||
HeaderEditTarget,
|
|
||||||
AddCardContainer,
|
|
||||||
AddCardButton,
|
|
||||||
AddCardButtonText,
|
|
||||||
ListCards,
|
|
||||||
ListExtraMenuButtonWrapper,
|
|
||||||
} from './Styles';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
onSaveName: (name: string) => void;
|
|
||||||
isComposerOpen: boolean;
|
|
||||||
onOpenComposer: (id: string) => void;
|
|
||||||
wrapperProps?: any;
|
|
||||||
headerProps?: any;
|
|
||||||
index?: number;
|
|
||||||
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const List = React.forwardRef(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
onSaveName,
|
|
||||||
isComposerOpen,
|
|
||||||
onOpenComposer,
|
|
||||||
children,
|
|
||||||
wrapperProps,
|
|
||||||
headerProps,
|
|
||||||
onExtraMenuOpen,
|
|
||||||
}: Props,
|
|
||||||
$wrapperRef: any,
|
|
||||||
) => {
|
|
||||||
const [listName, setListName] = useState(name);
|
|
||||||
const [isEditingTitle, setEditingTitle] = useState(false);
|
|
||||||
const $listNameRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const $extraActionsRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const onClick = () => {
|
|
||||||
setEditingTitle(true);
|
|
||||||
if ($listNameRef && $listNameRef.current) {
|
|
||||||
$listNameRef.current.select();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onBlur = () => {
|
|
||||||
setEditingTitle(false);
|
|
||||||
onSaveName(listName);
|
|
||||||
};
|
|
||||||
const onEscape = () => {
|
|
||||||
if ($listNameRef && $listNameRef.current) {
|
|
||||||
$listNameRef.current.blur();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onChange = (event: React.FormEvent<HTMLTextAreaElement>): void => {
|
|
||||||
setListName(event.currentTarget.value);
|
|
||||||
};
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if ($listNameRef && $listNameRef.current) {
|
|
||||||
$listNameRef.current.blur();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExtraMenuOpen = () => {
|
|
||||||
if ($extraActionsRef && $extraActionsRef.current) {
|
|
||||||
onExtraMenuOpen(id, $extraActionsRef);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
useOnEscapeKeyDown(isEditingTitle, onEscape);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container ref={$wrapperRef} {...wrapperProps}>
|
|
||||||
<Wrapper>
|
|
||||||
<Header {...headerProps} isEditing={isEditingTitle}>
|
|
||||||
<HeaderEditTarget onClick={onClick} isHidden={isEditingTitle} />
|
|
||||||
<HeaderName
|
|
||||||
ref={$listNameRef}
|
|
||||||
onBlur={onBlur}
|
|
||||||
onChange={onChange}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
spellCheck={false}
|
|
||||||
value={listName}
|
|
||||||
/>
|
|
||||||
<ListExtraMenuButtonWrapper ref={$extraActionsRef} onClick={handleExtraMenuOpen}>
|
|
||||||
<Ellipsis size={16} color="#c2c6dc" />
|
|
||||||
</ListExtraMenuButtonWrapper>
|
|
||||||
</Header>
|
|
||||||
{children && children}
|
|
||||||
<AddCardContainer hidden={isComposerOpen}>
|
|
||||||
<AddCardButton onClick={() => onOpenComposer(id)}>
|
|
||||||
<Plus size={12} color="#c2c6dc" />
|
|
||||||
<AddCardButtonText>Add another card</AddCardButtonText>
|
|
||||||
</AddCardButton>
|
|
||||||
</AddCardContainer>
|
|
||||||
</Wrapper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
List.defaultProps = {
|
|
||||||
children: null,
|
|
||||||
isComposerOpen: false,
|
|
||||||
wrapperProps: {},
|
|
||||||
headerProps: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
List.displayName = 'List';
|
|
||||||
export default List;
|
|
||||||
|
|
||||||
export { ListCards };
|
|
@ -1,18 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
import ListActions from '.';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: ListActions,
|
|
||||||
title: 'ListActions',
|
|
||||||
parameters: {
|
|
||||||
backgrounds: [
|
|
||||||
{ name: 'white', value: '#ffffff', default: true },
|
|
||||||
{ name: 'gray', value: '#f8f8f8' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
return <ListActions taskGroupID="1" onArchiveTaskGroup={action('on archive task group')} />;
|
|
||||||
};
|
|
@ -1,39 +0,0 @@
|
|||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
export const ListActionsWrapper = styled.ul`
|
|
||||||
list-style-type: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListActionItemWrapper = styled.li`
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
`;
|
|
||||||
export const ListActionItem = styled.span`
|
|
||||||
cursor: pointer;
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #c2c6dc;
|
|
||||||
font-weight: 400;
|
|
||||||
padding: 6px 12px;
|
|
||||||
position: relative;
|
|
||||||
margin: 0 -12px;
|
|
||||||
text-decoration: none;
|
|
||||||
&:hover {
|
|
||||||
background: rgb(115, 103, 240);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ListSeparator = styled.hr`
|
|
||||||
background-color: #414561;
|
|
||||||
border: 0;
|
|
||||||
height: 1px;
|
|
||||||
margin: 8px 0;
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const InnerContent = styled.div`
|
|
||||||
margin: 0 12px;
|
|
||||||
`;
|
|
@ -1,50 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { InnerContent, ListActionsWrapper, ListActionItemWrapper, ListActionItem, ListSeparator } from './Styles';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
taskGroupID: string;
|
|
||||||
|
|
||||||
onArchiveTaskGroup: (taskGroupID: string) => void;
|
|
||||||
};
|
|
||||||
const LabelManager: React.FC<Props> = ({ taskGroupID, onArchiveTaskGroup }) => {
|
|
||||||
return (
|
|
||||||
<InnerContent>
|
|
||||||
<ListActionsWrapper>
|
|
||||||
<ListActionItemWrapper>
|
|
||||||
<ListActionItem>Add card...</ListActionItem>
|
|
||||||
</ListActionItemWrapper>
|
|
||||||
<ListActionItemWrapper>
|
|
||||||
<ListActionItem>Copy List...</ListActionItem>
|
|
||||||
</ListActionItemWrapper>
|
|
||||||
<ListActionItemWrapper>
|
|
||||||
<ListActionItem>Move card...</ListActionItem>
|
|
||||||
</ListActionItemWrapper>
|
|
||||||
<ListActionItemWrapper>
|
|
||||||
<ListActionItem>Watch</ListActionItem>
|
|
||||||
</ListActionItemWrapper>
|
|
||||||
</ListActionsWrapper>
|
|
||||||
<ListSeparator />
|
|
||||||
<ListActionsWrapper>
|
|
||||||
<ListActionItemWrapper>
|
|
||||||
<ListActionItem>Sort By...</ListActionItem>
|
|
||||||
</ListActionItemWrapper>
|
|
||||||
</ListActionsWrapper>
|
|
||||||
<ListSeparator />
|
|
||||||
<ListActionsWrapper>
|
|
||||||
<ListActionItemWrapper>
|
|
||||||
<ListActionItem>Move All Cards in This List...</ListActionItem>
|
|
||||||
</ListActionItemWrapper>
|
|
||||||
<ListActionItemWrapper>
|
|
||||||
<ListActionItem>Archive All Cards in This List...</ListActionItem>
|
|
||||||
</ListActionItemWrapper>
|
|
||||||
</ListActionsWrapper>
|
|
||||||
<ListSeparator />
|
|
||||||
<ListActionsWrapper>
|
|
||||||
<ListActionItemWrapper onClick={() => onArchiveTaskGroup(taskGroupID)}>
|
|
||||||
<ListActionItem>Archive This List</ListActionItem>
|
|
||||||
</ListActionItemWrapper>
|
|
||||||
</ListActionsWrapper>
|
|
||||||
</InnerContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default LabelManager;
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user