2 Commits

191 changed files with 2547 additions and 15499 deletions

3
.github/FUNDING.yml vendored
View File

@ -1,3 +0,0 @@
# These are supported funding model platforms
custom: ['https://paypal.me/jordanthedev', 'https://www.buymeacoffee.com/jordanknott']

View File

@ -1,30 +0,0 @@
---
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
-->

View File

@ -1,30 +0,0 @@
---
name: Feature request
about: Create a feature request to help improve Taskcafe
title: ""
labels: ""
assignees: ""
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
<!--
Be aware, not all feature requests will get accepted.
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
-->

View File

@ -1,19 +0,0 @@
* **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**:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -7,16 +7,16 @@
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
height="350"
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-ydpi="96"
inkscape:export-xdpi="96"
inkscape:export-ydpi="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"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)">
<metadata
id="metadata957">
<rdf:RDF>
@ -25,92 +25,81 @@
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs955">
<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"
effect="bspline"
id="path-effect2633"
effect="bspline" />
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"
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-effect2614"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
effect="bspline" />
<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-effect2500"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
effect="bspline" />
<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-effect2491"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
effect="bspline" />
<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-effect1706"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
effect="bspline" />
<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-effect1697"
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
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-effect917"
effect="bspline" />
<inkscape:path-effect
effect="bspline"
id="path-effect950"
id="path-effect917"
is_visible="true"
lpeversion="1"
weight="33.333333"
@ -128,60 +117,117 @@
weight="33.333333"
lpeversion="1"
is_visible="true"
id="path-effect969"
id="path-effect950"
effect="bspline" />
<inkscape:path-effect
effect="bspline"
id="path-effect969"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
</defs>
<sodipodi:namedview
inkscape:current-layer="g2585"
inkscape:window-maximized="1"
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"
inkscape:document-rotation="0"
pagecolor="#ffffff"
inkscape:document-rotation="0" />
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:current-layer="g2585" />
<g
transform="matrix(7.6408653,0,0,7.6408653,40.593299,-53.251254)"
id="g974">
id="g974"
transform="matrix(7.6408653,0,0,7.6408653,40.593299,-53.251254)">
<g
transform="matrix(0.21947908,0,0,0.21947908,-1.6240521,6.1742219)"
id="g1721">
id="g1721"
transform="matrix(0.21947908,0,0,0.21947908,-1.6240521,6.1742219)">
<g
id="g2454"
transform="translate(0,-133.87521)">
transform="translate(0,-133.87521)"
id="g2454">
<g
id="g2554"
transform="translate(-35.365658,-44.89936)">
transform="translate(-35.365658,-44.89936)"
id="g2554">
<g
id="g2433"
transform="translate(0,107.63572)">
transform="translate(0,107.63572)"
id="g2433">
<g
id="g2439"
transform="translate(0.82337254,1.4684449)">
transform="translate(0.82337254,1.4684449)"
id="g2439">
<g
id="g2472">
<g
id="g2585"
transform="translate(0,-15.084391)">
transform="translate(0,-15.084391)"
id="g2585">
<g
transform="translate(1.0665725)"
id="g4610">
id="g2427"
transform="matrix(1.9229347,0,0,1.9229347,9.8747364,70.748305)">
<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
transform="translate(-0.16733365,0.61658838)"
id="g2513">
<path
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"
id="path949"
style="fill:#10163a;fill-opacity:1;stroke-width:0.978574"
transform="matrix(0.13087523,0,0,0.13087523,-5.3126573,6.9692701)" />
<g
style="stroke:#10163a;stroke-width:0.999994;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="g1711"
transform="translate(-0.91048867,42.172992)">
<g
id="g2505"
transform="translate(0.09191978,50.168306)">
<g
transform="translate(-0.68526563,40.225035)"
id="g2638">
<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"
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"
id="path915"
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"
inkscape:path-effect="#path-effect917" />
<path
inkscape:path-effect="#path-effect2614"
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"
id="path915-9"
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"
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
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 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"
id="path915-9-0"
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"
inkscape:path-effect="#path-effect2633" />
</g>
</g>
</g>
</g>
</g>
<g
id="g2560"
transform="translate(-28.409706,-8.3958791)">
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"
@ -205,64 +251,10 @@
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
transform="matrix(1.9229347,0,0,1.9229347,9.8747364,70.748305)"
id="g2427">
<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
id="g2513"
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
transform="translate(-0.91048867,42.172992)"
id="g1711"
style="stroke:#10163a;stroke-width:0.999994;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
<g
transform="translate(0.09191978,50.168306)"
id="g2505">
<g
id="g2638"
transform="translate(-0.68526563,40.225035)">
<path
inkscape:path-effect="#path-effect917"
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"
id="path915"
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"
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
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 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-9"
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>
<path
id="path2782"
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"
style="fill:#7367f0;fill-opacity:1;stroke-width:0.0984039" />
</g>
</g>
id="path2782" />
</g>
</g>
</g>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -3,10 +3,11 @@ repos:
hooks:
- id: eslint
name: eslint
entry: scripts/lint.sh
entry: go run cmd/mage/main.go frontend:lint
language: system
files: \.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx
types: [file]
pass_filenames: false
- hooks:
- id: check-yaml
- id: end-of-file-fixer

View File

@ -1,27 +0,0 @@
# 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
- Task sorting & filtering
- Redesigned the Task Details UI
- Implement task group actions (duplicate/delete all tasks/sort)
### Fixed
- removed CORS middleware to fix security issue
- Added 3 retries with backoff to initial database connection [(#47)](https://github.com/JordanKnott/taskcafe/issues/47)
- Can now actually set a due date
## [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

View File

@ -4,13 +4,8 @@ Thanks for wanting to contribute to Taskcafe!
### Where do I go from here?
So you want to contribute to Taskcafe? Great!
If you have noticed a bug or want to add a new feature, please [create an issue](https://github.com/JordanKnott/taskcafe/issues/new/choose) for it before starting any work on a pull request.
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!
If you have noticed a bug or have a feature request, make one! If best to get confirmation
of your bug or feature before starting work on a pull request.
### Fork & create a branch
@ -32,10 +27,6 @@ The `description` is a decriptive summary of the change the PR will make.
- 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`
### Unwanted PRs
- Please do not submit pull requests containing only typo fixes, fixed spelling mistakes, or minor wording changes.
### Git Commit Message Style
This project uses the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format.

View File

@ -1,38 +1,24 @@
<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>
<p align="center">
Was this project useful? Please consider <a href="https://www.buymeacoffee.com/jordanknott">donating</a> to help me improve it!
</p>
![Taskcafe](./.github/taskcafe-full.png)
**Please note that this project is still in active development. Some options may not work yet! For updates on development, join the Discord server**
[![Discord](https://img.shields.io/discord/745396499613220955)](https://discord.gg/JkQDruh)
[![Releases](https://img.shields.io/github/v/release/JordanKnott/taskcafe)](https://github.com/JordanKnott/taskcafe/releases)
[![Dockerhub](https://img.shields.io/docker/v/taskcafe/taskcafe?label=docker)](https://hub.docker.com/repository/docker/taskcafe/taskcafe)
[![Go Report Card](https://goreportcard.com/badge/github.com/JordanKnott/taskcafe)](https://goreportcard.com/report/github.com/JordanKnott/taskcafe)
## Overview
![Taskcafe](./.github/taskcafe_preview.png)
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
Currently Taskcafe only offers basic task tracking through a Kanban board.
Currently you can do the following to tasks:
- Task sorting & filtering
- Add colors & named labels
- Add due dates
- Descriptions written in Markdown
@ -95,7 +81,6 @@ 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`.
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.
Make sure to fill out the database section of the config in order to connect it to your database.
@ -104,8 +89,6 @@ Then run the database migrations with `taskcafe migrate`.
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)?
One of the primary goals of Taskcafe is to provide a project management tool that I personally enjoy using for my

View File

@ -1,47 +0,0 @@
# Config file for [Air](https://github.com/cosmtrek/air) in TOML format
# Working directory
# . or absolute path, please note that the directories following must be under root.
root = "."
tmp_dir = "tmp"
[build]
# Just plain old shell command. You could use `make` as well.
cmd = "go build -o ./dist/taskcafe cmd/taskcafe/main.go"
# Binary file yields from `cmd`.
bin = "dist/taskcafe"
# Customize binary.
full_bin = "./dist/taskcafe web"
# Watch these filename extensions.
include_ext = ["go"]
# Ignore these filename extensions or directories.
exclude_dir = ["dist", "frontend"]
# Watch these directories if you specified.
include_dir = []
# Exclude files.
exclude_file = []
# This log file places in your tmp_dir.
log = "air.log"
# It's not necessary to trigger build each time file changes if it's too frequent.
delay = 1000 # ms
# Stop running old binary when build errors occur.
stop_on_error = true
# Send Interrupt signal before killing process (windows does not support this feature)
send_interrupt = false
# Delay after sending Interrupt signal
kill_delay = 500 # ms
[log]
# Show log time
time = false
[color]
# Customize each part's color. If no color found, use the raw app log.
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
# Delete tmp directory on exit
clean_on_exit = true

View File

@ -1,5 +1,5 @@
[server]
hostname = '0.0.0.0:3333'
[general]
host = '0.0.0.0:3333'
[email_notifications]
enabled = true

View File

@ -12,7 +12,7 @@ services:
volumes:
- taskcafe-postgres:/var/lib/postgresql/data
ports:
- 8855:5432
- 5432:5432
mailhog:
image: mailhog/mailhog:latest
restart: always

View File

@ -30,10 +30,7 @@
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }],
"no-case-declarations": "off",
"no-plusplus": "off",
"react/prop-types": 0,
"no-continue": "off",
"react/jsx-props-no-spreading": "off",
"no-param-reassign": "off",
"import/extensions": [

View File

@ -6,6 +6,14 @@
"@apollo/client": "^3.0.0-rc.8",
"@apollo/react-common": "^3.1.4",
"@apollo/react-hooks": "^3.1.3",
"@fortawesome/fontawesome-svg-core": "^1.2.27",
"@fortawesome/free-brands-svg-icons": "^5.12.1",
"@fortawesome/free-regular-svg-icons": "^5.12.1",
"@fortawesome/free-solid-svg-icons": "^5.12.1",
"@fortawesome/react-fontawesome": "^0.1.8",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/axios": "^0.14.0",
"@types/color": "^3.0.1",
"@types/date-fns": "^2.6.0",
@ -20,7 +28,6 @@
"@types/react-router": "^5.1.4",
"@types/react-router-dom": "^5.1.3",
"@types/react-select": "^3.0.13",
"@types/react-timeago": "^4.1.1",
"@types/styled-components": "^5.0.0",
"apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8",
@ -33,13 +40,13 @@
"axios-auth-refresh": "^2.2.7",
"color": "^3.1.2",
"date-fns": "^2.14.0",
"dayjs": "^1.9.1",
"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.20",
"lodash": "^4.17.15",
"moment": "^2.24.0",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-autosize-textarea": "^7.0.0",
@ -52,9 +59,6 @@
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.0",
"react-select": "^3.1.0",
"react-timeago": "^4.4.0",
"react-toastify": "^6.0.8",
"rich-markdown-editor": "^10.6.5",
"styled-components": "^5.0.1",
"typescript": "~3.7.2"
},
@ -91,10 +95,10 @@
"@graphql-codegen/typescript-operations": "^1.13.2",
"@graphql-codegen/typescript-react-apollo": "^1.13.2",
"@storybook/addon-actions": "^5.3.13",
"@storybook/addon-links": "^5.3.13",
"@storybook/addon-backgrounds": "^5.3.17",
"@storybook/addon-docs": "^5.3.17",
"@storybook/addon-knobs": "^5.3.17",
"@storybook/addon-links": "^5.3.13",
"@storybook/addon-storysource": "^5.3.17",
"@storybook/addon-viewport": "^5.3.17",
"@storybook/addons": "^5.3.13",

View File

@ -5,7 +5,6 @@ import GlobalTopNavbar from 'App/TopNavbar';
import {
useUsersQuery,
useDeleteUserAccountMutation,
useDeleteInvitedUserAccountMutation,
useCreateUserAccountMutation,
UsersDocument,
UsersQuery,
@ -172,22 +171,11 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
const AdminRoute = () => {
useEffect(() => {
document.title = 'Admin | Taskcafé';
document.title = 'Taskcafé | Admin';
}, []);
const { loading, data } = useUsersQuery();
const { showPopup, hidePopup } = usePopup();
const { user } = useCurrentUser();
const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({
update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
produce(cache, draftCache => {
draftCache.invitedUsers = cache.invitedUsers.filter(
u => u.id !== response.data.deleteInvitedUserAccount.invitedUser.id,
);
}),
);
},
});
const [deleteUser] = useDeleteUserAccountMutation({
update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
@ -227,16 +215,11 @@ const AdminRoute = () => {
<Admin
initialTab={0}
users={data.users}
invitedUsers={data.invitedUsers}
canInviteUser={user.roles.org === 'admin'}
onInviteUser={NOOP}
onUpdateUserPassword={() => {
hidePopup();
}}
onDeleteInvitedUser={invitedUserID => {
deleteInvitedUser({ variables: { invitedUserID } });
hidePopup();
}}
onDeleteUser={(userID, newOwnerID) => {
deleteUser({ variables: { userID, newOwnerID } });
hidePopup();

View File

@ -5,7 +5,6 @@ import * as H from 'history';
import Dashboard from 'Dashboard';
import Admin from 'Admin';
import Projects from 'Projects';
import Outline from 'Outline';
import Project from 'Projects/Project';
import Teams from 'Teams';
import Login from 'Auth';
@ -37,7 +36,6 @@ const Routes: React.FC<RoutesProps> = () => (
<Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
<Route path="/outline" component={Outline} />
</MainContent>
</Switch>
);

View File

@ -7,7 +7,7 @@ import { useHistory } from 'react-router';
import { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import {
RoleCode,
useTopNavbarQuery,
useMeQuery,
useDeleteProjectMutation,
useGetProjectsQuery,
GetProjectsDocument,
@ -19,7 +19,6 @@ import { Link } from 'react-router-dom';
import MiniProfile from 'shared/components/MiniProfile';
import cache from 'App/cache';
import NOOP from 'shared/utils/noop';
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
const TeamContainer = styled.div`
display: flex;
@ -137,7 +136,7 @@ const ProjectFinder = () => {
return {
id: team.id,
name: team.name,
projects: projects.filter(project => project.team && project.team.id === team.id),
projects: projects.filter(project => project.team.id === team.id),
};
});
return (
@ -198,7 +197,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, proje
<Popup title={null} tab={0}>
<ProjectSettings
onDeleteProject={() => {
setTab(1, { width: 300 });
setTab(1, 300);
}}
/>
</Popup>
@ -230,12 +229,10 @@ type GlobalTopNavbarProps = {
menuType?: Array<MenuItem>;
onChangeRole?: (userID: string, roleCode: RoleCode) => void;
projectMembers?: null | Array<TaskUser>;
projectInvitedMembers?: null | Array<InvitedUser>;
onSaveProjectName?: (projectName: string) => void;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
onSetTab?: (tab: number) => void;
onRemoveFromBoard?: (userID: string) => void;
onRemoveInvitedFromBoard?: (email: string) => void;
};
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
@ -248,14 +245,12 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
name,
popupContent,
projectMembers,
projectInvitedMembers,
onInviteUser,
onSaveProjectName,
onRemoveInvitedFromBoard,
onRemoveFromBoard,
}) => {
const { user, setUserRoles, setUser } = useCurrentUser();
const { loading, data } = useTopNavbarQuery({
const { data } = useMeQuery({
onCompleted: response => {
if (user && user.roles) {
setUserRoles({
@ -305,31 +300,13 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
}}
/>
</Popup>,
{ width: 195 },
195,
);
};
const onOpenSettings = ($target: React.RefObject<HTMLElement>) => {
if (popupContent) {
showPopup($target, popupContent, { width: 185 });
}
};
const onNotificationClick = ($target: React.RefObject<HTMLElement>) => {
if (data) {
showPopup(
$target,
<NotificationPopup>
{data.notifications.map(notification => (
<NotificationItem
title={notification.entity.name}
description={`${notification.actor.name} added you as a meber to the task "${notification.entity.name}"`}
createdAt={notification.createdAt}
/>
))}
</NotificationPopup>,
{ width: 415, borders: false, diamondColor: '#7367f0' },
);
showPopup($target, popupContent, 185);
}
};
@ -337,34 +314,6 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
return null;
}
const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => {
const member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null;
if (member) {
showPopup(
$targetRef,
<MiniProfile
onRemoveFromBoard={() => {
if (onRemoveInvitedFromBoard) {
onRemoveInvitedFromBoard(member.email);
}
}}
invited
user={{
id: member.email,
fullName: member.email,
bio: 'Invited',
profileIcon: {
bgColor: '#000',
url: null,
initials: member.email.charAt(0),
},
}}
bio=""
/>,
);
}
};
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
const warning =
@ -414,18 +363,16 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
canEditProjectName={userIsTeamOrProjectAdmin}
canInviteUser={userIsTeamOrProjectAdmin}
onMemberProfile={onMemberProfile}
onInvitedMemberProfile={onInvitedMemberProfile}
onInviteUser={onInviteUser}
onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner}
onNotificationClick={onNotificationClick}
onNotificationClick={NOOP}
onSetTab={onSetTab}
onRemoveFromBoard={onRemoveFromBoard}
onDashboardClick={() => {
history.push('/');
}}
projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
onProfileClick={onProfileClick}
onSaveName={onSaveProjectName}
onOpenSettings={onOpenSettings}

View File

@ -3,7 +3,6 @@ import jwtDecode from 'jwt-decode';
import { createBrowserHistory } from 'history';
import { Router } from 'react-router';
import { PopupProvider } from 'shared/components/PopupMenu';
import { ToastContainer } from 'react-toastify';
import { setAccessToken } from 'shared/utils/accessToken';
import styled, { ThemeProvider } from 'styled-components';
import NormalizeStyles from './NormalizeStyles';
@ -12,39 +11,6 @@ import theme from './ThemeStyles';
import Routes from './Routes';
import { UserContext, CurrentUserRaw, CurrentUserRoles, PermissionLevel, PermissionObjectType } from './context';
import 'react-toastify/dist/ReactToastify.css';
const StyledContainer = styled(ToastContainer).attrs({
// custom props
})`
.Toastify__toast-container {
}
.Toastify__toast {
padding: 5px;
margin-left: 5px;
margin-right: 5px;
border-radius: 10px;
background: #7367f0;
color: #fff;
}
.Toastify__toast--error {
background: rgba(${props => props.theme.colors.danger});
}
.Toastify__toast--warning {
background: rgba(${props => props.theme.colors.warning});
}
.Toastify__toast--success {
background: rgba(${props => props.theme.colors.success});
}
.Toastify__toast-body {
}
.Toastify__progress-bar {
}
.Toastify__close-button {
display: none;
}
`;
const history = createBrowserHistory();
type RefreshTokenResponse = {
accessToken: string;
@ -106,18 +72,6 @@ const App = () => {
)}
</PopupProvider>
</Router>
<StyledContainer
position="bottom-right"
autoClose={5000}
hideProgressBar
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
limit={5}
/>
</ThemeProvider>
</UserContext.Provider>
</>

View File

@ -59,7 +59,7 @@ const Install = () => {
} else {
const response: RefreshTokenResponse = await x.data;
const { accessToken: newToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(newToken);
const claims: JWTToken = jwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: {
@ -69,7 +69,7 @@ const Install = () => {
},
};
setUser(currentUser);
setAccessToken(newToken);
setAccessToken(accessToken);
if (!isInstalled) {
history.replace('/install');
}

View File

@ -1,24 +0,0 @@
import React from 'react';
import { DragDebugWrapper } from './Styles';
type DragDebugProps = {
zone: ImpactZone | null;
depthTarget: number;
draggedNodes: Array<string> | null;
};
const DragDebug: React.FC<DragDebugProps> = ({ zone, depthTarget, draggedNodes }) => {
let aboveID = null;
let belowID = null;
if (zone) {
aboveID = zone.above ? zone.above.node.id : null;
belowID = zone.below ? zone.below.node.id : null;
}
return (
<DragDebugWrapper>{`aboveID=${aboveID} / belowID=${belowID} / depthTarget=${depthTarget} draggedNodes=${
draggedNodes ? draggedNodes.toString() : null
}`}</DragDebugWrapper>
);
};
export default DragDebug;

View File

@ -1,41 +0,0 @@
import React from 'react';
import { getDimensions } from './utils';
import { DragIndicatorBar } from './Styles';
type DragIndicatorProps = {
container: React.RefObject<HTMLDivElement>;
zone: ImpactZone;
depthTarget: number;
};
const DragIndicator: React.FC<DragIndicatorProps> = ({ container, zone, depthTarget }) => {
let top = 0;
let width = 0;
if (zone.below === null) {
if (zone.above) {
const entry = getDimensions(zone.above.dimensions.entry);
const children = getDimensions(zone.above.dimensions.children);
if (children) {
top = children.top;
width = children.width - depthTarget * 35;
} else if (entry) {
top = entry.bottom;
width = entry.width - depthTarget * 35;
}
}
} else if (zone.below) {
const entry = getDimensions(zone.below.dimensions.entry);
if (entry) {
top = entry.top;
width = entry.width - depthTarget * 35;
}
}
let left = 0;
if (container && container.current) {
left = container.current.getBoundingClientRect().left + (depthTarget - 1) * 35;
width = container.current.getBoundingClientRect().width - depthTarget * 35;
}
return <DragIndicatorBar top={top} left={left} width={width} />;
};
export default DragIndicator;

View File

@ -1,242 +0,0 @@
import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react';
import { Dot } from 'shared/icons';
import styled from 'styled-components';
import {
findNextDraggable,
getDimensions,
getTargetDepth,
getNodeAbove,
getBelowParent,
findNodeAbove,
getNodeOver,
getLastChildInBranch,
findNodeDepth,
} from './utils';
import { useDrag } from './useDrag';
const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 9px;
background: rgba(${p => p.theme.colors.primary});
svg {
fill: rgba(${p => p.theme.colors.text.primary});
stroke: rgba(${p => p.theme.colors.text.primary});
}
`;
type DraggerProps = {
container: React.RefObject<HTMLDivElement>;
draggedNodes: { nodes: Array<string>; first?: OutlineNode | null };
isDragging: boolean;
onDragEnd: (zone: ImpactZone) => void;
initialPos: { x: number; y: number };
};
const Dragger: React.FC<DraggerProps> = ({ draggedNodes, container, onDragEnd, isDragging, initialPos }) => {
const [pos, setPos] = useState<{ x: number; y: number }>(initialPos);
const { outline, impact, setImpact } = useDrag();
const $handle = useRef<HTMLDivElement>(null);
const handleMouseUp = useCallback(() => {
onDragEnd(impact ? impact.zone : { below: null, above: null });
}, [impact]);
const handleMouseMove = useCallback(
e => {
e.preventDefault();
const { clientX, clientY, pageX, pageY } = e;
setPos({ x: clientX, y: clientY });
const { curDepth, curPosition, curDraggable } = getNodeOver({ x: clientX, y: clientY }, outline.current);
let depthTarget: number = 0;
let aboveNode: null | OutlineNode = null;
let belowNode: null | OutlineNode = null;
if (curPosition === 'before') {
belowNode = curDraggable;
} else {
aboveNode = curDraggable;
}
// if belowNode has the depth of 1, then the above element will be a part of a different branch
const { relationships, nodes } = outline.current;
if (!belowNode || !aboveNode) {
if (belowNode) {
aboveNode = findNodeAbove(outline.current, curDepth, belowNode);
} else if (aboveNode) {
let targetBelowNode: RelationshipChild | null = null;
const parent = relationships.get(aboveNode.parent);
if (aboveNode.children !== 0 && !aboveNode.collapsed) {
const abr = relationships.get(aboveNode.id);
if (abr) {
const newTarget = abr.children[0];
if (newTarget) {
targetBelowNode = newTarget;
}
}
} else if (parent) {
const aboveNodeIndex = parent.children.findIndex(c => aboveNode && c.id === aboveNode.id);
if (aboveNodeIndex !== -1) {
if (aboveNodeIndex === parent.children.length - 1) {
targetBelowNode = getBelowParent(aboveNode, outline.current);
} else {
const nextChild = parent.children[aboveNodeIndex + 1];
targetBelowNode = nextChild ?? null;
}
}
}
if (targetBelowNode) {
const depthNodes = nodes.get(targetBelowNode.depth);
if (depthNodes) {
belowNode = depthNodes.get(targetBelowNode.id) ?? null;
}
}
}
}
// if outside outline, get either first or last item in list based on mouse Y
if (!aboveNode && !belowNode) {
if (container && container.current) {
const bounds = container.current.getBoundingClientRect();
if (clientY < bounds.top + bounds.height / 2) {
const rootChildren = outline.current.relationships.get('root');
const rootDepth = outline.current.nodes.get(1);
if (rootChildren && rootDepth) {
const firstChild = rootChildren.children[0];
belowNode = rootDepth.get(firstChild.id) ?? null;
aboveNode = null;
}
} else {
// TODO: enhance to actually get last child item, not last top level branch
const rootChildren = outline.current.relationships.get('root');
const rootDepth = outline.current.nodes.get(1);
if (rootChildren && rootDepth) {
const lastChild = rootChildren.children[rootChildren.children.length - 1];
const lastParentNode = rootDepth.get(lastChild.id) ?? null;
if (lastParentNode) {
const lastBranchChild = getLastChildInBranch(outline.current, lastParentNode);
if (lastBranchChild) {
const lastChildDepth = outline.current.nodes.get(lastBranchChild.depth);
if (lastChildDepth) {
aboveNode = lastChildDepth.get(lastBranchChild.id) ?? null;
}
}
}
}
}
}
}
if (aboveNode) {
const { ancestors } = findNodeDepth(outline.current.published, aboveNode.id);
for (let i = 0; i < draggedNodes.nodes.length; i++) {
const nodeID = draggedNodes.nodes[i];
if (ancestors.find(c => c === nodeID)) {
if (draggedNodes.first) {
belowNode = draggedNodes.first;
aboveNode = findNodeAbove(outline.current, aboveNode ? aboveNode.depth : 1, draggedNodes.first);
} else {
const { depth } = findNodeDepth(outline.current.published, nodeID);
const nodeDepth = outline.current.nodes.get(depth);
const targetNode = nodeDepth ? nodeDepth.get(nodeID) : null;
if (targetNode) {
belowNode = targetNode;
aboveNode = findNodeAbove(outline.current, depth, targetNode);
}
}
}
}
}
// calculate available depths
let minDepth = 1;
let maxDepth = 2;
if (aboveNode) {
const aboveParent = relationships.get(aboveNode.parent);
if (aboveNode.children !== 0 && !aboveNode.collapsed) {
minDepth = aboveNode.depth + 1;
maxDepth = aboveNode.depth + 1;
} else if (aboveParent) {
minDepth = aboveNode.depth;
maxDepth = aboveNode.depth + 1;
const aboveNodeIndex = aboveParent.children.findIndex(c => aboveNode && c.id === aboveNode.id);
if (aboveNodeIndex === aboveParent.children.length - 1) {
minDepth = belowNode ? belowNode.depth : minDepth;
}
}
}
if (aboveNode) {
const dimensions = outline.current.dimensions.get(aboveNode.id);
const entry = getDimensions(dimensions?.entry);
if (entry) {
depthTarget = getTargetDepth(clientX, entry.left, { min: minDepth, max: maxDepth });
}
}
let aboveImpact: null | ImpactZoneData = null;
let belowImpact: null | ImpactZoneData = null;
if (aboveNode) {
const aboveDim = outline.current.dimensions.get(aboveNode.id);
if (aboveDim) {
aboveImpact = {
node: aboveNode,
dimensions: aboveDim,
};
}
}
if (belowNode) {
const belowDim = outline.current.dimensions.get(belowNode.id);
if (belowDim) {
belowImpact = {
node: belowNode,
dimensions: belowDim,
};
}
}
setImpact({
zone: {
above: aboveImpact,
below: belowImpact,
},
depth: depthTarget,
});
},
[outline.current.nodes],
);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, []);
const styles = useMemo(() => {
const position: 'absolute' | 'relative' = isDragging ? 'absolute' : 'relative';
return {
cursor: isDragging ? '-webkit-grabbing' : '-webkit-grab',
transform: `translate(${pos.x - 10}px, ${pos.y - 4}px)`,
transition: isDragging ? 'none' : 'transform 500ms',
zIndex: isDragging ? 2 : 1,
position,
};
}, [isDragging, pos]);
return (
<>
{pos && (
<Container ref={$handle} style={styles}>
<Dot width={18} height={18} />
</Container>
)}
</>
);
};
export default Dragger;

View File

@ -1,155 +0,0 @@
import React, { useRef, useEffect } from 'react';
import { Dot, CaretDown, CaretRight } from 'shared/icons';
import { EntryChildren, EntryWrapper, EntryContent, EntryInnerContent, EntryHandle, ExpandButton } from './Styles';
import { useDrag } from './useDrag';
function getCaretPosition(editableDiv: any) {
let caretPos = 0;
let sel: any = null;
let range: any = null;
if (window.getSelection) {
sel = window.getSelection();
if (sel && sel.rangeCount) {
range = sel.getRangeAt(0);
if (range.commonAncestorContainer.parentNode === editableDiv) {
caretPos = range.endOffset;
}
}
}
return caretPos;
}
type EntryProps = {
id: string;
collapsed?: boolean;
onToggleCollapse: (id: string, collapsed: boolean) => void;
parentID: string;
onStartDrag: (e: { id: string; clientX: number; clientY: number }) => void;
onStartSelect: (e: { id: string; depth: number }) => void;
isRoot?: boolean;
selection: null | Array<{ id: string }>;
draggedNodes: null | Array<string>;
entries: Array<ItemElement>;
onCancelDrag: () => void;
position: number;
chain?: Array<string>;
depth?: number;
};
const Entry: React.FC<EntryProps> = ({
id,
parentID,
isRoot = false,
selection,
onToggleCollapse,
onStartSelect,
position,
onCancelDrag,
onStartDrag,
collapsed = false,
draggedNodes,
entries,
chain = [],
depth = 0,
}) => {
const $entry = useRef<HTMLDivElement>(null);
const $children = useRef<HTMLDivElement>(null);
const { setNodeDimensions, clearNodeDimensions } = useDrag();
useEffect(() => {
if (isRoot) return;
if ($entry && $entry.current) {
setNodeDimensions(id, {
entry: $entry,
children: entries.length !== 0 ? $children : null,
});
}
return () => {
clearNodeDimensions(id);
};
}, [position, depth, entries]);
let showHandle = true;
if (draggedNodes && draggedNodes.length === 1 && draggedNodes.find(c => c === id)) {
showHandle = false;
}
let isSelected = false;
if (selection && selection.find(c => c.id === id)) {
isSelected = true;
}
let onSaveTimer: any = null;
const onSaveTimeout = 300;
return (
<EntryWrapper isSelected={isSelected} isDragging={!showHandle}>
{!isRoot && (
<EntryContent>
{entries.length !== 0 && (
<ExpandButton onClick={() => onToggleCollapse(id, !collapsed)}>
{collapsed ? <CaretRight width={20} height={20} /> : <CaretDown width={20} height={20} />}
</ExpandButton>
)}
{showHandle && (
<EntryHandle
onMouseUp={() => onCancelDrag()}
onMouseDown={e => {
onStartDrag({ id, clientX: e.clientX, clientY: e.clientY });
}}
>
<Dot width={18} height={18} />
</EntryHandle>
)}
<EntryInnerContent
onMouseDown={() => {
onStartSelect({ id, depth });
}}
onKeyDown={e => {
if (e.key === 'z' && e.ctrlKey) {
if ($entry && $entry.current) {
console.log(getCaretPosition($entry.current));
}
} else {
clearTimeout(onSaveTimer);
if ($entry && $entry.current) {
onSaveTimer = setTimeout(() => {
if ($entry && $entry.current) {
console.log($entry.current.textContent);
}
}, onSaveTimeout);
}
}
}}
contentEditable
ref={$entry}
>
{`${id.toString()} - ${position}`}
</EntryInnerContent>
</EntryContent>
)}
{entries.length !== 0 && !collapsed && (
<EntryChildren ref={$children} isRoot={isRoot}>
{entries
.sort((a, b) => a.position - b.position)
.map(entry => (
<Entry
parentID={id}
key={entry.id}
position={entry.position}
depth={depth + 1}
draggedNodes={draggedNodes}
collapsed={entry.collapsed}
id={entry.id}
onStartSelect={onStartSelect}
onStartDrag={onStartDrag}
onCancelDrag={onCancelDrag}
entries={entry.children ?? []}
chain={[...chain, id]}
selection={selection}
onToggleCollapse={onToggleCollapse}
/>
))}
</EntryChildren>
)}
</EntryWrapper>
);
};
export default Entry;

View File

@ -1,164 +0,0 @@
import styled, { css } from 'styled-components';
import { mixin } from 'shared/utils/styles';
export const EntryWrapper = styled.div<{ isDragging: boolean; isSelected: boolean }>`
position: relative;
${props =>
props.isDragging &&
css`
&:before {
border-radius: 3px;
content: '';
position: absolute;
top: 2px;
right: -5px;
left: -5px;
bottom: -2px;
background-color: #eceef0;
}
`}
${props =>
props.isSelected &&
css`
&:before {
border-radius: 3px;
content: '';
position: absolute;
top: 2px;
right: -5px;
bottom: -2px;
left: -5px;
background-color: rgba(${props.theme.colors.primary}, 0.75);
}
`}
`;
export const EntryChildren = styled.div<{ isRoot: boolean }>`
position: relative;
${props =>
!props.isRoot &&
css`
margin-left: 10px;
padding-left: 25px;
border-left: 1px solid rgba(${props.theme.colors.text.primary}, 0.6);
`}
`;
export const PageContent = styled.div`
min-height: calc(100vh - 146px);
width: 100%;
position: relative;
display: flex;
flex-direction: column;
box-shadow: none;
user-select: none;
margin-left: auto;
margin-right: auto;
max-width: 700px;
padding-left: 56px;
padding-right: 56px;
padding-top: 24px;
padding-bottom: 24px;
text-size-adjust: none;
`;
export const DragHandle = styled.div<{ top: number; left: number }>`
display: flex;
align-items: center;
justify-content: center;
position: fixed;
left: 0;
top: 0;
transform: translate3d(${props => props.left}px, ${props => props.top}px, 0);
transition: transform 0.2s cubic-bezier(0.2, 0, 0, 1);
width: 18px;
height: 18px;
color: rgb(75, 81, 85);
border-radius: 9px;
`;
export const RootWrapper = styled.div``;
export const EntryHandle = styled.div`
display: flex;
align-items: center;
justify-content: center;
position: absolute;
left: 501px;
top: 7px;
width: 18px;
height: 18px;
color: rgba(${p => p.theme.colors.text.primary});
border-radius: 9px;
&:hover {
background: rgba(${p => p.theme.colors.primary});
}
svg {
fill: rgba(${p => p.theme.colors.text.primary});
stroke: rgba(${p => p.theme.colors.text.primary});
}
`;
export const EntryInnerContent = styled.div`
padding-top: 4px;
font-size: 15px;
white-space: pre-wrap;
line-height: 24px;
min-height: 24px;
overflow-wrap: break-word;
position: relative;
user-select: text;
color: rgba(${p => p.theme.colors.text.primary});
&::selection {
background: #a49de8;
}
&:focus {
outline: 0;
}
`;
export const DragDebugWrapper = styled.div`
position: absolute;
left: 42px;
bottom: 24px;
color: #fff;
`;
export const DragIndicatorBar = styled.div<{ left: number; top: number; width: number }>`
position: absolute;
width: ${props => props.width}px;
top: ${props => props.top}px;
left: ${props => props.left}px;
height: 4px;
border-radius: 3px;
background: rgb(204, 204, 204);
`;
export const ExpandButton = styled.div`
top: 6px;
cursor: default;
color: transparent;
position: absolute;
top: 6px;
display: flex;
align-items: center;
justify-content: center;
left: 478px;
width: 20px;
height: 20px;
svg {
fill: transparent;
}
`;
export const EntryContent = styled.div`
position: relative;
margin-left: -500px;
padding-left: 524px;
&:hover ${ExpandButton} svg {
fill: rgb(${props => props.theme.colors.text.primary});
}
`;
export const PageContainer = styled.div`
overflow: scroll;
`;

View File

@ -1,504 +0,0 @@
import React, { useState, useRef, useEffect, useMemo, useCallback, useContext, memo, createRef } from 'react';
import { DotCircle } from 'shared/icons';
import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import _ from 'lodash';
import produce from 'immer';
import Entry from './Entry';
import DragIndicator from './DragIndicator';
import Dragger from './Dragger';
import DragDebug from './DragDebug';
import { DragContext } from './useDrag';
import {
PageContainer,
DragDebugWrapper,
DragIndicatorBar,
PageContent,
EntryChildren,
EntryInnerContent,
EntryWrapper,
EntryContent,
RootWrapper,
EntryHandle,
} from './Styles';
import {
transformToTree,
findNode,
findNodeDepth,
getNumberOfChildren,
validateDepth,
getDimensions,
findNextDraggable,
getNodeOver,
getCorrectNode,
findCommonParent,
} from './utils';
import NOOP from 'shared/utils/noop';
type OutlineCommand = {
nodes: Array<{
id: string;
prev: { position: number; parent: string | null };
next: { position: number; parent: string | null };
}>;
};
type ItemCollapsed = {
id: string;
collapsed: boolean;
};
const listItems: Array<ItemElement> = [
{ id: 'root', position: 4096, parent: null, collapsed: false },
{ id: 'entry-1', position: 4096, parent: 'root', collapsed: false },
{ id: 'entry-1_3', position: 4096 * 3, parent: 'entry-1', collapsed: false },
{ id: 'entry-1_3_1', position: 4096, parent: 'entry-1_3', collapsed: false },
{ id: 'entry-1_3_2', position: 4096 * 2, parent: 'entry-1_3', collapsed: false },
{ id: 'entry-1_3_3', position: 4096 * 3, parent: 'entry-1_3', collapsed: false },
{ id: 'entry-1_3_3_1', position: 4096 * 1, parent: 'entry-1_3_3', collapsed: false },
{ id: 'entry-1_3_3_1_1', position: 4096 * 1, parent: 'entry-1_3_3_1', collapsed: false },
{ id: 'entry-2', position: 4096 * 2, parent: 'root', collapsed: false },
{ id: 'entry-3', position: 4096 * 3, parent: 'root', collapsed: false },
{ id: 'entry-4', position: 4096 * 4, parent: 'root', collapsed: false },
{ id: 'entry-5', position: 4096 * 5, parent: 'root', collapsed: false },
];
const Outline: React.FC = () => {
const [items, setItems] = useState(listItems);
const [selecting, setSelecting] = useState<{
isSelecting: boolean;
node: { id: string; depth: number } | null;
}>({ isSelecting: false, node: null });
const [selection, setSelection] = useState<null | { nodes: Array<{ id: string }>; first?: OutlineNode | null }>(null);
const [dragging, setDragging] = useState<{
show: boolean;
draggedNodes: null | Array<string>;
initialPos: { x: number; y: number };
}>({ show: false, draggedNodes: null, initialPos: { x: 0, y: 0 } });
const [impact, setImpact] = useState<null | {
listPosition: number;
zone: ImpactZone;
depthTarget: number;
}>(null);
const selectRef = useRef<{ isSelecting: boolean; hasSelection: boolean; node: { id: string; depth: number } | null }>(
{
isSelecting: false,
node: null,
hasSelection: false,
},
);
const impactRef = useRef<null | { listPosition: number; depth: number; zone: ImpactZone }>(null);
useEffect(() => {
if (impact) {
impactRef.current = { zone: impact.zone, depth: impact.depthTarget, listPosition: impact.listPosition };
}
}, [impact]);
useEffect(() => {
selectRef.current.isSelecting = selecting.isSelecting;
selectRef.current.node = selecting.node;
}, [selecting]);
const $content = useRef<HTMLDivElement>(null);
const outline = useRef<OutlineData>({
published: new Map<string, string>(),
dimensions: new Map<string, NodeDimensions>(),
nodes: new Map<number, Map<string, OutlineNode>>(),
relationships: new Map<string, NodeRelationships>(),
});
const tree = transformToTree(_.cloneDeep(items));
let root: any = null;
if (tree.length === 1) {
root = tree[0];
}
const outlineHistory = useRef<{ commands: Array<OutlineCommand>; current: number }>({ current: -1, commands: [] });
useEffect(() => {
outline.current.relationships = new Map<string, NodeRelationships>();
outline.current.published = new Map<string, string>();
outline.current.nodes = new Map<number, Map<string, OutlineNode>>();
const collapsedMap = items.reduce((map, next) => {
if (next.collapsed) {
map.set(next.id, true);
}
return map;
}, new Map<string, boolean>());
items.forEach(item => outline.current.published.set(item.id, item.parent ?? 'root'));
for (let i = 0; i < items.length; i++) {
const { collapsed, position, id, parent: curParent } = items[i];
if (id === 'root') {
continue;
}
const parent = curParent ?? 'root';
outline.current.published.set(id, parent ?? 'root');
const { depth, ancestors } = findNodeDepth(outline.current.published, id);
const collapsedParent = ancestors.slice(0, -1).find(a => collapsedMap.get(a));
if (collapsedParent) {
continue;
}
const children = getNumberOfChildren(root, ancestors);
if (!outline.current.nodes.has(depth)) {
outline.current.nodes.set(depth, new Map<string, OutlineNode>());
}
const targetDepthNodes = outline.current.nodes.get(depth);
if (targetDepthNodes) {
targetDepthNodes.set(id, {
id,
children,
position,
depth,
ancestors,
collapsed,
parent,
});
}
if (!outline.current.relationships.has(parent)) {
outline.current.relationships.set(parent, {
self: {
depth: depth - 1,
id: parent,
},
children: [],
numberOfSubChildren: 0,
});
}
const nodeRelations = outline.current.relationships.get(parent);
if (nodeRelations) {
outline.current.relationships.set(parent, {
self: nodeRelations.self,
numberOfSubChildren: nodeRelations.numberOfSubChildren + children,
children: [...nodeRelations.children, { id, position, depth, children }].sort(
(a, b) => a.position - b.position,
),
});
}
}
}, [items]);
const handleKeyDown = useCallback(e => {
if (e.code === 'KeyZ' && e.ctrlKey) {
const currentCommand = outlineHistory.current.commands[outlineHistory.current.current];
if (currentCommand) {
setItems(prevItems =>
produce(prevItems, draftItems => {
currentCommand.nodes.forEach(node => {
const idx = prevItems.findIndex(c => c.id === node.id);
if (idx !== -1) {
draftItems[idx].parent = node.prev.parent;
draftItems[idx].position = node.prev.position;
}
});
outlineHistory.current.current--;
}),
);
}
} else if (e.code === 'KeyY' && e.ctrlKey) {
const currentCommand = outlineHistory.current.commands[outlineHistory.current.current + 1];
if (currentCommand) {
setItems(prevItems =>
produce(prevItems, draftItems => {
currentCommand.nodes.forEach(node => {
const idx = prevItems.findIndex(c => c.id === node.id);
if (idx !== -1) {
draftItems[idx].parent = node.next.parent;
draftItems[idx].position = node.next.position;
}
});
outlineHistory.current.current++;
}),
);
}
}
}, []);
const handleMouseUp = useCallback(
e => {
if (selectRef.current.hasSelection && !selectRef.current.isSelecting) {
setSelection(null);
}
if (selectRef.current.isSelecting) {
setSelecting({ isSelecting: false, node: null });
}
},
[dragging, selecting],
);
const handleMouseMove = useCallback(e => {
if (selectRef.current.isSelecting && selectRef.current.node) {
const { clientX, clientY } = e;
const dimensions = outline.current.dimensions.get(selectRef.current.node.id);
if (dimensions) {
const entry = getDimensions(dimensions.entry);
if (entry) {
const isAbove = clientY < entry.top;
const isBelow = clientY > entry.bottom;
if (!isAbove && !isBelow && selectRef.current.hasSelection) {
const nodeDepth = outline.current.nodes.get(selectRef.current.node.depth);
const aboveNode = nodeDepth ? nodeDepth.get(selectRef.current.node.id) : null;
if (aboveNode) {
setSelection({ nodes: [{ id: selectRef.current.node.id }], first: aboveNode });
selectRef.current.hasSelection = false;
}
}
if (isAbove || isBelow) {
e.preventDefault();
const { curDraggable } = getNodeOver({ x: clientX, y: clientY }, outline.current);
const nodeDepth = outline.current.nodes.get(selectRef.current.node.depth);
const selectedNode = nodeDepth ? nodeDepth.get(selectRef.current.node.id) : null;
let aboveNode: OutlineNode | undefined | null = null;
let belowNode: OutlineNode | undefined | null = null;
if (isBelow) {
aboveNode = selectedNode;
belowNode = curDraggable;
} else {
aboveNode = curDraggable;
belowNode = selectedNode;
}
if (aboveNode && belowNode) {
const aboveDim = outline.current.dimensions.get(aboveNode.id);
const belowDim = outline.current.dimensions.get(belowNode.id);
if (aboveDim && belowDim) {
const aboveDimBounds = getDimensions(aboveDim.entry);
const belowDimBounds = getDimensions(belowDim.children ? belowDim.children : belowDim.entry);
const aboveDimY = aboveDimBounds ? aboveDimBounds.bottom : 0;
const belowDimY = belowDimBounds ? belowDimBounds.top : 0;
const inbetweenNodes: Array<{ id: string }> = [];
for (const [id, dimension] of outline.current.dimensions.entries()) {
if (id === aboveNode.id || id === belowNode.id) {
inbetweenNodes.push({ id });
continue;
}
const targetNodeBounds = getDimensions(dimension.entry);
if (targetNodeBounds) {
if (
Math.round(aboveDimY) <= Math.round(targetNodeBounds.top) &&
Math.round(belowDimY) >= Math.round(targetNodeBounds.bottom)
) {
inbetweenNodes.push({ id });
}
}
}
const filteredNodes = inbetweenNodes.filter(n => {
const parent = outline.current.published.get(n.id);
if (parent) {
const foundParent = inbetweenNodes.find(c => c.id === parent);
if (foundParent) {
return false;
}
}
return true;
});
selectRef.current.hasSelection = true;
setSelection({ nodes: filteredNodes, first: aboveNode });
}
}
}
}
}
}
}, []);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
document.addEventListener('keydown', handleKeyDown);
};
}, []);
if (!root) {
return null;
}
return (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<DragContext.Provider
value={{
outline,
impact,
setImpact: data => {
if (data) {
const { zone, depth } = data;
let listPosition = 65535;
if (zone.above && zone.above.node.depth + 1 <= depth && zone.above.node.collapsed) {
const aboveChildren = items
.filter(i => (zone.above ? i.parent === zone.above.node.id : false))
.sort((a, b) => a.position - b.position);
const lastChild = aboveChildren[aboveChildren.length - 1];
if (lastChild) {
listPosition = lastChild.position * 2.0;
}
} else {
console.log(zone.above);
console.log(zone.below);
const correctNode = getCorrectNode(outline.current, zone.above ? zone.above.node : null, depth);
console.log(correctNode);
const listAbove = validateDepth(correctNode, depth);
const listBelow = validateDepth(zone.below ? zone.below.node : null, depth);
console.log(listAbove, listBelow);
if (listAbove && listBelow) {
listPosition = (listAbove.position + listBelow.position) / 2.0;
} else if (listAbove && !listBelow) {
listPosition = listAbove.position * 2.0;
} else if (!listAbove && listBelow) {
listPosition = listBelow.position / 2.0;
}
}
if (!zone.above && zone.below) {
const newPosition = zone.below.node.position / 2.0;
setImpact(() => ({
zone,
listPosition: newPosition,
depthTarget: depth,
}));
}
if (zone.above) {
// console.log(`prev=${prev} next=${next} targetPosition=${targetPosition}`);
// let targetID = depthTarget === 1 ? 'root' : node.ancestors[depthTarget - 1];
// targetID = targetID ?? node.id;
setImpact(() => ({
zone,
listPosition,
depthTarget: depth,
}));
}
} else {
setImpact(null);
}
},
setNodeDimensions: (nodeID, ref) => {
outline.current.dimensions.set(nodeID, ref);
},
clearNodeDimensions: nodeID => {
outline.current.dimensions.delete(nodeID);
},
}}
>
<>
<PageContainer>
<PageContent>
<RootWrapper ref={$content}>
<Entry
onStartSelect={({ id, depth }) => {
setSelection(null);
setSelecting({ isSelecting: true, node: { id, depth } });
}}
onToggleCollapse={(id, collapsed) => {
setItems(prevItems =>
produce(prevItems, draftItems => {
const idx = prevItems.findIndex(c => c.id === id);
if (idx !== -1) {
draftItems[idx].collapsed = collapsed;
}
}),
);
}}
id="root"
parentID="root"
isRoot
selection={selection ? selection.nodes : null}
draggedNodes={dragging.draggedNodes}
position={root.position}
entries={root.children}
onCancelDrag={() => {
setImpact(null);
setDragging({ show: false, draggedNodes: null, initialPos: { x: 0, y: 0 } });
}}
onStartDrag={e => {
if (e.id !== 'root') {
if (selectRef.current.hasSelection && selection && selection.nodes.find(c => c.id === e.id)) {
setImpact(null);
setDragging({
show: true,
draggedNodes: [...selection.nodes.map(c => c.id)],
initialPos: { x: e.clientX, y: e.clientY },
});
} else {
setImpact(null);
setDragging({ show: true, draggedNodes: [e.id], initialPos: { x: e.clientX, y: e.clientY } });
}
}
}}
/>
</RootWrapper>
</PageContent>
</PageContainer>
{dragging.show && dragging.draggedNodes && (
<Dragger
container={$content}
initialPos={dragging.initialPos}
draggedNodes={{ nodes: dragging.draggedNodes, first: selection ? selection.first : null }}
isDragging={dragging.show}
onDragEnd={() => {
if (dragging.draggedNodes && impactRef.current) {
const { zone, depth, listPosition } = impactRef.current;
const noZone = !zone.above && !zone.below;
if (!noZone) {
let parentID = 'root';
if (zone.above) {
parentID = zone.above.node.ancestors[depth - 1];
}
let reparent = true;
for (let i = 0; i < dragging.draggedNodes.length; i++) {
const draggedID = dragging.draggedNodes[i];
const prevItem = items.find(i => i.id === draggedID);
if (prevItem && prevItem.position === listPosition && prevItem.parent === parentID) {
reparent = false;
break;
}
}
// TODO: set reparent if list position changed but parent did not
//
if (reparent) {
// UPDATE OUTLINE DATA AFTER NODE MOVE
setItems(itemsPrev =>
produce(itemsPrev, draftItems => {
if (dragging.draggedNodes) {
const command: OutlineCommand = { nodes: [] };
outlineHistory.current.current += 1;
dragging.draggedNodes.forEach(n => {
const curDragging = itemsPrev.findIndex(i => i.id === n);
command.nodes.push({
id: n,
prev: {
parent: draftItems[curDragging].parent,
position: draftItems[curDragging].position,
},
next: {
parent: parentID,
position: listPosition,
},
});
draftItems[curDragging].parent = parentID;
draftItems[curDragging].position = listPosition;
});
outlineHistory.current.commands[outlineHistory.current.current] = command;
if (outlineHistory.current.commands[outlineHistory.current.current + 1]) {
outlineHistory.current.commands.splice(outlineHistory.current.current + 1);
}
}
}),
);
}
}
}
setImpact(null);
setDragging({ show: false, draggedNodes: null, initialPos: { x: 0, y: 0 } });
}}
/>
)}
</>
</DragContext.Provider>
{impact && <DragIndicator depthTarget={impact.depthTarget} container={$content} zone={impact.zone} />}
{impact && (
<DragDebug zone={impact.zone ?? null} draggedNodes={dragging.draggedNodes} depthTarget={impact.depthTarget} />
)}
</>
);
};
export default Outline;

View File

@ -1,22 +0,0 @@
import React, { useContext } from 'react';
type DragContextData = {
impact: null | { zone: ImpactZone; depthTarget: number };
outline: React.MutableRefObject<OutlineData>;
setNodeDimensions: (
nodeID: string,
ref: { entry: React.RefObject<HTMLElement>; children: React.RefObject<HTMLElement> | null },
) => void;
clearNodeDimensions: (nodeID: string) => void;
setImpact: (data: ImpactData | null) => void;
};
export const DragContext = React.createContext<DragContextData | null>(null);
export const useDrag = () => {
const ctx = useContext(DragContext);
if (ctx) {
return ctx;
}
throw new Error('context is null');
};

View File

@ -1,361 +0,0 @@
import _ from 'lodash';
export function getCorrectNode(data: OutlineData, node: OutlineNode | null, depth: number) {
if (node) {
console.log(depth, node);
if (depth === node.depth) {
return node;
}
const parent = node.ancestors[depth];
console.log('parent', parent);
if (parent) {
const parentNode = data.relationships.get(parent);
if (parentNode) {
const parentDepth = parentNode.self.depth;
const nodeDepth = data.nodes.get(parentDepth);
return nodeDepth ? nodeDepth.get(parent) : null;
}
}
}
return null;
}
export function validateDepth(node: OutlineNode | null | undefined, depth: number) {
if (node) {
return node.depth === depth ? node : null;
}
return null;
}
export function getNodeAbove(node: OutlineNode, startingParent: RelationshipChild, outline: OutlineData) {
let hasChildren = true;
let nodeAbove: null | RelationshipChild = null;
let aboveTargetID = startingParent.id;
while (hasChildren) {
const targetParent = outline.relationships.get(aboveTargetID);
if (targetParent) {
const parentNodes = outline.nodes.get(targetParent.self.depth);
const parentNode = parentNodes ? parentNodes.get(targetParent.self.id) : null;
if (targetParent.children.length === 0) {
if (parentNode) {
nodeAbove = {
id: parentNode.id,
depth: parentNode.depth,
position: parentNode.position,
children: parentNode.children,
};
console.log('node above', nodeAbove);
}
hasChildren = false;
continue;
}
nodeAbove = targetParent.children[targetParent.children.length - 1];
if (targetParent.numberOfSubChildren === 0) {
hasChildren = false;
} else {
aboveTargetID = nodeAbove.id;
}
} else {
const target = outline.relationships.get(node.ancestors[0]);
if (target) {
const targetChild = target.children.find(i => i.id === aboveTargetID);
if (targetChild) {
nodeAbove = targetChild;
}
hasChildren = false;
}
}
}
console.log('final node above', nodeAbove);
return nodeAbove;
}
export function getBelowParent(node: OutlineNode, outline: OutlineData) {
const { relationships, nodes } = outline;
const parentDepth = nodes.get(node.depth - 1);
const parent = parentDepth ? parentDepth.get(node.parent) : null;
if (parent) {
const grandfather = relationships.get(parent.parent);
if (grandfather) {
const parentIndex = grandfather.children.findIndex(c => c.id === parent.id);
if (parentIndex !== -1) {
if (parentIndex === grandfather.children.length - 1) {
const root = relationships.get(node.ancestors[0]);
if (root) {
const ancestorIndex = root.children.findIndex(c => c.id === node.ancestors[1]);
if (ancestorIndex !== -1) {
const nextAncestor = root.children[ancestorIndex + 1];
if (nextAncestor) {
return nextAncestor;
}
}
}
} else {
const nextChild = grandfather.children[parentIndex + 1];
if (nextChild) {
return nextChild;
}
}
}
}
}
return null;
}
export function getDimensions(ref: React.RefObject<HTMLElement> | null | undefined) {
if (ref && ref.current) {
return ref.current.getBoundingClientRect();
}
return null;
}
export function getTargetDepth(mouseX: number, handleLeft: number, availableDepths: { min: number; max: number }) {
if (mouseX > handleLeft) {
return availableDepths.max;
}
let curDepth = availableDepths.max - 1;
for (let x = availableDepths.min; x < availableDepths.max; x++) {
const breakpoint = handleLeft - x * 35;
// console.log(`mouseX=${mouseX} breakpoint=${breakpoint} x=${x} curDepth=${curDepth}`);
if (mouseX > breakpoint) {
return curDepth;
}
curDepth -= 1;
}
return availableDepths.min;
}
export function findNextDraggable(pos: { x: number; y: number }, outline: OutlineData, curDepth: number) {
let index = 0;
const currentDepthNodes = outline.nodes.get(curDepth);
let nodeAbove: null | RelationshipChild = null;
if (!currentDepthNodes) {
return null;
}
for (const [id, node] of currentDepthNodes) {
const dimensions = outline.dimensions.get(id);
const target = dimensions ? getDimensions(dimensions.entry) : null;
const children = dimensions ? getDimensions(dimensions.children) : null;
if (target) {
console.log(
`[${id}] ${pos.y} <= ${target.bottom} = ${pos.y <= target.bottom} / ${pos.y} >= ${target.top} = ${pos.y >=
target.top}`,
);
if (pos.y <= target.bottom && pos.y >= target.top) {
const middlePoint = target.top + target.height / 2;
const position: ImpactPosition = pos.y > middlePoint ? 'after' : 'before';
return {
found: true,
node,
position,
};
}
}
if (children) {
console.log(
`[${id}] ${pos.y} <= ${children.bottom} = ${pos.y <= children.bottom} / ${pos.y} >= ${children.top} = ${pos.y >=
children.top}`,
);
if (pos.y <= children.bottom && pos.y >= children.top) {
const position: ImpactPosition = 'after';
return { found: false, node, position };
}
}
index += 1;
}
return null;
}
export function transformToTree(arr: any) {
const nodes: any = {};
return arr.filter(function(obj: any) {
var id = obj['id'],
parentId = obj['parent'];
nodes[id] = _.defaults(obj, nodes[id], { children: [] });
parentId && (nodes[parentId] = nodes[parentId] || { children: [] })['children'].push(obj);
return !parentId;
});
}
export function findNode(parentID: string, nodeID: string, data: OutlineData) {
const nodeRelations = data.relationships.get(parentID);
if (nodeRelations) {
const nodeDepth = data.nodes.get(nodeRelations.self.depth + 1);
if (nodeDepth) {
const node = nodeDepth.get(nodeID);
return node ?? null;
}
}
return null;
}
export function findNodeDepth(published: Map<string, string>, id: string) {
let currentID = id;
let breaker = 0;
let depth = 0;
let ancestors = [id];
while (currentID !== 'root') {
const nextID = published.get(currentID);
if (nextID) {
ancestors = [nextID, ...ancestors];
currentID = nextID;
depth += 1;
breaker += 1;
if (breaker > 100) {
throw new Error('node depth breaker was thrown');
}
} else {
throw new Error('unable to find nextID');
}
}
return { depth, ancestors };
}
export function getNumberOfChildren(root: ItemElement, ancestors: Array<string>) {
let currentBranch = root;
for (let i = 1; i < ancestors.length; i++) {
const nextBranch = currentBranch.children ? currentBranch.children.find(c => c.id === ancestors[i]) : null;
if (nextBranch) {
currentBranch = nextBranch;
} else {
throw new Error('unable to find next branch');
}
}
return currentBranch.children ? currentBranch.children.length : 0;
}
export function findNodeAbove(outline: OutlineData, curDepth: number, belowNode: OutlineNode) {
let targetAboveNode: null | RelationshipChild = null;
if (curDepth === 1) {
const relations = outline.relationships.get(belowNode.ancestors[0]);
if (relations) {
const parentIndex = relations.children.findIndex(n => belowNode && n.id === belowNode.ancestors[1]);
if (parentIndex !== -1) {
const aboveParent = relations.children[parentIndex - 1];
if (parentIndex === 0) {
targetAboveNode = null;
} else {
targetAboveNode = getNodeAbove(belowNode, aboveParent, outline);
}
}
}
} else {
const relations = outline.relationships.get(belowNode.parent);
if (relations) {
const currentIndex = relations.children.findIndex(n => belowNode && n.id === belowNode.id);
// is first child, so use parent
if (currentIndex === 0) {
const parentNodes = outline.nodes.get(belowNode.depth - 1);
const parentNode = parentNodes ? parentNodes.get(belowNode.parent) : null;
if (parentNode) {
targetAboveNode = {
id: belowNode.parent,
depth: belowNode.depth - 1,
position: parentNode.position,
children: parentNode.children,
};
}
} else if (currentIndex !== -1) {
// is not first child, so first prev sibling
const aboveParentNode = relations.children[currentIndex - 1];
if (aboveParentNode) {
targetAboveNode = getNodeAbove(belowNode, aboveParentNode, outline);
if (targetAboveNode === null) {
targetAboveNode = aboveParentNode;
}
}
}
}
}
if (targetAboveNode) {
const depthNodes = outline.nodes.get(targetAboveNode.depth);
if (depthNodes) {
return depthNodes.get(targetAboveNode.id) ?? null;
}
}
return null;
}
export function getNodeOver(mouse: { x: number; y: number }, outline: OutlineData) {
let curDepth = 1;
let curDraggables: any;
let curDraggable: any;
let curPosition: ImpactPosition = 'after';
while (outline.nodes.size + 1 > curDepth) {
curDraggables = outline.nodes.get(curDepth);
if (curDraggables) {
const nextDraggable = findNextDraggable(mouse, outline, curDepth);
if (nextDraggable) {
curDraggable = nextDraggable.node;
curPosition = nextDraggable.position;
if (nextDraggable.found) {
break;
}
curDepth += 1;
} else {
break;
}
}
}
return {
curDepth,
curDraggable,
curPosition,
};
}
export function findCommonParent(outline: OutlineData, aboveNode: OutlineNode, belowNode: OutlineNode) {
let aboveParentID = null;
let depth = 0;
for (let aIdx = aboveNode.ancestors.length - 1; aIdx !== 0; aIdx--) {
depth = aIdx;
const aboveNodeParent = aboveNode.ancestors[aIdx];
for (let bIdx = belowNode.ancestors.length - 1; bIdx !== 0; bIdx--) {
if (belowNode.ancestors[bIdx] === aboveNodeParent) {
aboveParentID = aboveNodeParent;
}
}
}
if (aboveParentID) {
const parent = outline.relationships.get(aboveParentID) ?? null;
if (parent) {
return {
parent,
depth,
};
}
return null;
}
return null;
}
export function getLastChildInBranch(outline: OutlineData, lastParentNode: OutlineNode) {
let curParentRelation = outline.relationships.get(lastParentNode.id);
if (!curParentRelation) {
return { id: lastParentNode.id, depth: 1 };
}
let hasChildren = lastParentNode.children !== 0;
let depth = 1;
let finalID: null | string = null;
while (hasChildren) {
if (curParentRelation) {
const lastChild = curParentRelation.children.sort((a, b) => a.position - b.position)[
curParentRelation.children.length - 1
];
depth += 1;
if (lastChild.children === 0) {
finalID = lastChild.id;
break;
}
curParentRelation = outline.relationships.get(lastChild.id);
} else {
hasChildren = false;
}
}
if (finalID !== null) {
return { id: finalID, depth };
}
return null;
}

View File

@ -3,32 +3,15 @@ import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import { getAccessToken } from 'shared/utils/accessToken';
import Settings from 'shared/components/Settings';
import {
useMeQuery,
useClearProfileAvatarMutation,
useUpdateUserPasswordMutation,
useUpdateUserInfoMutation,
MeQuery,
MeDocument,
} from 'shared/generated/graphql';
import { useMeQuery, useClearProfileAvatarMutation, useUpdateUserPasswordMutation } from 'shared/generated/graphql';
import axios from 'axios';
import { useCurrentUser } from 'App/context';
import NOOP from 'shared/utils/noop';
import { toast } from 'react-toastify';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
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 [updateUserInfo] = useUpdateUserInfoMutation();
const [updateUserPassword] = useUpdateUserPasswordMutation();
const { loading, data, refetch } = useMeQuery();
useEffect(() => {
@ -76,14 +59,6 @@ const Projects = () => {
}}
onResetPassword={(password, done) => {
updateUserPassword({ variables: { userID: user.id, password } });
toast('Password was changed!');
done();
}}
onChangeUserInfo={(d, done) => {
updateUserInfo({
variables: { name: d.full_name, bio: d.bio, email: d.email, initials: d.initials },
});
toast('User info was saved!');
done();
}}
onProfileAvatarRemove={() => {

View File

@ -1,323 +0,0 @@
import React, { useState, useEffect } from 'react';
import styled, { css } from 'styled-components';
import { Checkmark, User, Calendar, Tags, Clock } from 'shared/icons';
import { TaskMetaFilters, TaskMeta, TaskMetaMatch, DueDateFilterType } from 'shared/components/Lists';
import Input from 'shared/components/ControlledInput';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import produce from 'immer';
import { mixin } from 'shared/utils/styles';
import Member from 'shared/components/Member';
const FilterMember = styled(Member)`
margin: 2px 0;
&:hover {
cursor: pointer;
background: rgba(${props => props.theme.colors.primary});
}
`;
export const Labels = styled.ul`
list-style: none;
margin: 0 8px;
padding: 0;
margin-bottom: 8px;
`;
export const Label = styled.li`
position: relative;
`;
export const CardLabel = styled.span<{ active: boolean; color: string }>`
${props =>
props.active &&
css`
margin-left: 4px;
box-shadow: -8px 0 ${mixin.darken(props.color, 0.12)};
border-radius: 3px;
`}
cursor: pointer;
font-weight: 700;
margin: 0 0 4px;
min-height: 20px;
padding: 6px 12px;
position: relative;
transition: padding 85ms, margin 85ms, box-shadow 85ms;
background-color: ${props => props.color};
color: #fff;
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-height: 31px;
`;
export const ActionsList = styled.ul`
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
`;
export const ActionItem = styled.li`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: rgb(115, 103, 240);
}
`;
export const ActionTitle = styled.span`
margin-left: 20px;
`;
const ActionItemSeparator = styled.li`
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
font-size: 12px;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.75rem;
padding-bottom: 0.25rem;
`;
const ActiveIcon = styled(Checkmark)`
position: absolute;
right: 4px;
`;
const ItemIcon = styled.div`
position: absolute;
`;
const TaskNameInput = styled(Input)`
margin: 0;
`;
const ActionItemLine = styled.div`
height: 1px;
border-top: 1px solid #414561;
margin: 0.25rem !important;
`;
type FilterMetaProps = {
filters: TaskMetaFilters;
userID: string;
labels: React.RefObject<Array<ProjectLabel>>;
members: React.RefObject<Array<TaskUser>>;
onChangeTaskMetaFilter: (filters: TaskMetaFilters) => void;
};
const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter, userID, labels, members }) => {
const [currentFilters, setFilters] = useState(filters);
const [nameFilter, setNameFilter] = useState(filters.taskName ? filters.taskName.name : '');
const [currentLabel, setCurrentLabel] = useState('');
const handleSetFilters = (f: TaskMetaFilters) => {
setFilters(f);
onChangeTaskMetaFilter(f);
};
const handleNameChange = (nFilter: string) => {
handleSetFilters(
produce(currentFilters, draftFilters => {
draftFilters.taskName = nFilter !== '' ? { name: nFilter } : null;
}),
);
setNameFilter(nFilter);
};
const { setTab } = usePopup();
const handleSetDueDate = (filterType: DueDateFilterType, label: string) => {
handleSetFilters(
produce(currentFilters, draftFilters => {
if (draftFilters.dueDate && draftFilters.dueDate.type === filterType) {
draftFilters.dueDate = null;
} else {
draftFilters.dueDate = {
label,
type: filterType,
};
}
}),
);
};
return (
<>
<Popup tab={0} title={null}>
<ActionsList>
<TaskNameInput
width="100%"
onChange={e => handleNameChange(e.currentTarget.value)}
value={nameFilter}
variant="alternate"
placeholder="Task name..."
/>
<ActionItemSeparator>QUICK ADD</ActionItemSeparator>
<ActionItem
onClick={() => {
handleSetFilters(
produce(currentFilters, draftFilters => {
if (members.current) {
const member = members.current.find(m => m.id === userID);
const draftMember = draftFilters.members.find(m => m.id === userID);
if (member && !draftMember) {
draftFilters.members.push({ id: userID, username: member.username ? member.username : '' });
} else {
draftFilters.members = draftFilters.members.filter(m => m.id !== userID);
}
}
}),
);
}}
>
<ItemIcon>
<User width={12} height={12} />
</ItemIcon>
<ActionTitle>Just my tasks</ActionTitle>
{currentFilters.members.find(m => m.id === userID) && <ActiveIcon width={12} height={12} />}
</ActionItem>
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.THIS_WEEK, 'Due this week')}>
<ItemIcon>
<Calendar width={12} height={12} />
</ItemIcon>
<ActionTitle>Due this week</ActionTitle>
{currentFilters.dueDate && currentFilters.dueDate.type === DueDateFilterType.THIS_WEEK && (
<ActiveIcon width={12} height={12} />
)}
</ActionItem>
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.NEXT_WEEK, 'Due next week')}>
<ItemIcon>
<Calendar width={12} height={12} />
</ItemIcon>
<ActionTitle>Due next week</ActionTitle>
{currentFilters.dueDate && currentFilters.dueDate.type === DueDateFilterType.NEXT_WEEK && (
<ActiveIcon width={12} height={12} />
)}
</ActionItem>
<ActionItemLine />
<ActionItem onClick={() => setTab(1)}>
<ItemIcon>
<Tags width={12} height={12} />
</ItemIcon>
<ActionTitle>By Label</ActionTitle>
</ActionItem>
<ActionItem onClick={() => setTab(2)}>
<ItemIcon>
<User width={12} height={12} />
</ItemIcon>
<ActionTitle>By Member</ActionTitle>
</ActionItem>
<ActionItem onClick={() => setTab(3)}>
<ItemIcon>
<Clock width={12} height={12} />
</ItemIcon>
<ActionTitle>By Due Date</ActionTitle>
</ActionItem>
</ActionsList>
</Popup>
<Popup tab={1} title="By Labels">
<Labels>
{labels.current &&
labels.current
// .filter(label => '' === '' || (label.name && label.name.toLowerCase().startsWith(''.toLowerCase())))
.map(label => (
<Label key={label.id}>
<CardLabel
key={label.id}
color={label.labelColor.colorHex}
active={currentLabel === label.id}
onMouseEnter={() => {
setCurrentLabel(label.id);
}}
onClick={() => {
handleSetFilters(
produce(currentFilters, draftFilters => {
if (draftFilters.labels.find(l => l.id === label.id)) {
draftFilters.labels = draftFilters.labels.filter(l => l.id !== label.id);
} else {
draftFilters.labels.push({
id: label.id,
name: label.name ?? '',
color: label.labelColor.colorHex,
});
}
}),
);
}}
>
{label.name}
</CardLabel>
</Label>
))}
</Labels>
</Popup>
<Popup tab={2} title="By Member">
<ActionsList>
{members.current &&
members.current.map(member => (
<FilterMember
key={member.id}
member={member}
showName
onCardMemberClick={() => {
handleSetFilters(
produce(currentFilters, draftFilters => {
if (draftFilters.members.find(m => m.id === member.id)) {
draftFilters.members = draftFilters.members.filter(m => m.id !== member.id);
} else {
draftFilters.members.push({ id: member.id, username: member.username ?? '' });
}
}),
);
}}
/>
))}
</ActionsList>
</Popup>
<Popup tab={3} title="By Due Date">
<ActionsList>
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.TODAY, 'Today')}>
<ActionTitle>Today</ActionTitle>
</ActionItem>
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.THIS_WEEK, 'Due this week')}>
<ActionTitle>This week</ActionTitle>
</ActionItem>
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.NEXT_WEEK, 'Due next week')}>
<ActionTitle>Next week</ActionTitle>
</ActionItem>
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.OVERDUE, 'Overdue')}>
<ActionTitle>Overdue</ActionTitle>
</ActionItem>
<ActionItemLine />
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.TOMORROW, 'In the next day')}>
<ActionTitle>In the next day</ActionTitle>
</ActionItem>
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.ONE_WEEK, 'In the next week')}>
<ActionTitle>In the next week</ActionTitle>
</ActionItem>
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.TWO_WEEKS, 'In the next two weeks')}>
<ActionTitle>In the next two weeks</ActionTitle>
</ActionItem>
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.THREE_WEEKS, 'In the next three weeks')}>
<ActionTitle>In the next three weeks</ActionTitle>
</ActionItem>
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.NO_DUE_DATE, 'Has no due date')}>
<ActionTitle>Has no due date</ActionTitle>
</ActionItem>
</ActionsList>
</Popup>
</>
);
};
export default FilterMeta;

View File

@ -1,149 +0,0 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { Checkmark } from 'shared/icons';
import { TaskStatusFilter, TaskStatus, TaskSince } from 'shared/components/Lists';
export const ActionsList = styled.ul`
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
`;
export const ActionExtraMenuContainer = styled.div`
visibility: hidden;
position: absolute;
left: 100%;
top: -4px;
padding-left: 2px;
width: 100%;
`;
export const ActionItem = styled.li`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: rgb(115, 103, 240);
}
&:hover ${ActionExtraMenuContainer} {
visibility: visible;
}
`;
export const ActionTitle = styled.span`
margin-left: 20px;
`;
export const ActionExtraMenu = styled.ul`
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
padding: 5px;
padding-top: 8px;
border-radius: 5px;
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
color: #c2c6dc;
background: #262c49;
border: 1px solid rgba(0, 0, 0, 0.1);
border-color: #414561;
`;
export const ActionExtraMenuItem = styled.li`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: rgb(115, 103, 240);
}
`;
const ActionExtraMenuSeparator = styled.li`
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
font-size: 12px;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
`;
const ActiveIcon = styled(Checkmark)`
position: absolute;
`;
type FilterStatusProps = {
filter: TaskStatusFilter;
onChangeTaskStatusFilter: (filter: TaskStatusFilter) => void;
};
const FilterStatus: React.FC<FilterStatusProps> = ({ filter, onChangeTaskStatusFilter }) => {
const [currentFilter, setFilter] = useState(filter);
const handleFilterChange = (f: TaskStatusFilter) => {
setFilter(f);
onChangeTaskStatusFilter(f);
};
const handleCompleteClick = (s: TaskSince) => {
handleFilterChange({ status: TaskStatus.COMPLETE, since: s });
};
return (
<ActionsList>
<ActionItem onClick={() => handleFilterChange({ status: TaskStatus.INCOMPLETE, since: TaskSince.ALL })}>
{currentFilter.status === TaskStatus.INCOMPLETE && <ActiveIcon width={12} height={12} />}
<ActionTitle>Incomplete Tasks</ActionTitle>
</ActionItem>
<ActionItem>
{currentFilter.status === TaskStatus.COMPLETE && <ActiveIcon width={12} height={12} />}
<ActionTitle>Compelete Tasks</ActionTitle>
<ActionExtraMenuContainer>
<ActionExtraMenu>
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.ALL)}>
{currentFilter.since === TaskSince.ALL && <ActiveIcon width={12} height={12} />}
<ActionTitle>All completed tasks</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuSeparator>Marked complete since</ActionExtraMenuSeparator>
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.TODAY)}>
{currentFilter.since === TaskSince.TODAY && <ActiveIcon width={12} height={12} />}
<ActionTitle>Today</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.YESTERDAY)}>
{currentFilter.since === TaskSince.YESTERDAY && <ActiveIcon width={12} height={12} />}
<ActionTitle>Yesterday</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.ONE_WEEK)}>
{currentFilter.since === TaskSince.ONE_WEEK && <ActiveIcon width={12} height={12} />}
<ActionTitle>1 week</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.TWO_WEEKS)}>
{currentFilter.since === TaskSince.TWO_WEEKS && <ActiveIcon width={12} height={12} />}
<ActionTitle>2 weeks</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleCompleteClick(TaskSince.THREE_WEEKS)}>
{currentFilter.since === TaskSince.THREE_WEEKS && <ActiveIcon width={12} height={12} />}
<ActionTitle>3 weeks</ActionTitle>
</ActionExtraMenuItem>
</ActionExtraMenu>
</ActionExtraMenuContainer>
</ActionItem>
<ActionItem onClick={() => handleFilterChange({ status: TaskStatus.ALL, since: TaskSince.ALL })}>
{currentFilter.status === TaskStatus.ALL && <ActiveIcon width={12} height={12} />}
<ActionTitle>All Tasks</ActionTitle>
</ActionItem>
</ActionsList>
);
};
export default FilterStatus;

View File

@ -1,85 +0,0 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/utils/sorting';
export const ActionsList = styled.ul`
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
`;
export const ActionItem = styled.li`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: rgb(115, 103, 240);
}
`;
export const ActionTitle = styled.span`
margin-left: 20px;
`;
const ActionItemSeparator = styled.li`
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
font-size: 12px;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.75rem;
padding-bottom: 0.25rem;
`;
type SortPopupProps = {
sorting: TaskSorting;
onChangeTaskSorting: (taskSorting: TaskSorting) => void;
};
const SortPopup: React.FC<SortPopupProps> = ({ sorting, onChangeTaskSorting }) => {
const [currentSorting, setSorting] = useState(sorting);
const handleSetSorting = (s: TaskSorting) => {
setSorting(s);
onChangeTaskSorting(s);
};
return (
<ActionsList>
<ActionItem onClick={() => handleSetSorting({ type: TaskSortingType.NONE, direction: TaskSortingDirection.ASC })}>
<ActionTitle>None</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.DUE_DATE, direction: TaskSortingDirection.ASC })}
>
<ActionTitle>Due date</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.MEMBERS, direction: TaskSortingDirection.ASC })}
>
<ActionTitle>Members</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.LABELS, direction: TaskSortingDirection.ASC })}
>
<ActionTitle>Labels</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
>
<ActionTitle>Task title</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.COMPLETE, direction: TaskSortingDirection.ASC })}
>
<ActionTitle>Complete</ActionTitle>
</ActionItem>
</ActionsList>
);
};
export default SortPopup;

View File

@ -8,7 +8,6 @@ import {
useSetTaskCompleteMutation,
useToggleTaskLabelMutation,
useFindProjectQuery,
useSortTaskGroupMutation,
useUpdateTaskGroupNameMutation,
useUpdateTaskNameMutation,
useCreateTaskMutation,
@ -22,101 +21,18 @@ import {
useUnassignTaskMutation,
useUpdateTaskDueDateMutation,
FindProjectQuery,
useDuplicateTaskGroupMutation,
DuplicateTaskGroupMutation,
DuplicateTaskGroupDocument,
useDeleteTaskGroupTasksMutation,
} from 'shared/generated/graphql';
import QuickCardEditor from 'shared/components/QuickCardEditor';
import ListActions from 'shared/components/ListActions';
import MemberManager from 'shared/components/MemberManager';
import SimpleLists, {
TaskStatus,
TaskSince,
TaskStatusFilter,
TaskMeta,
TaskMetaMatch,
TaskMetaFilters,
} from 'shared/components/Lists';
import { TaskSorting, TaskSortingType, TaskSortingDirection, sortTasks } from 'shared/utils/sorting';
import SimpleLists from 'shared/components/Lists';
import produce from 'immer';
import MiniProfile from 'shared/components/MiniProfile';
import DueDateManager from 'shared/components/DueDateManager';
import EmptyBoard from 'shared/components/EmptyBoard';
import NOOP from 'shared/utils/noop';
import LabelManagerEditor from 'Projects/Project/LabelManagerEditor';
import Chip from 'shared/components/Chip';
import { toast } from 'react-toastify';
import { useCurrentUser } from 'App/context';
import FilterStatus from './FilterStatus';
import FilterMeta from './FilterMeta';
import SortPopup from './SortPopup';
const FilterChip = styled(Chip)`
margin-right: 4px;
`;
type MetaFilterCloseFn = (meta: TaskMeta, key: string) => void;
const renderTaskSortingLabel = (sorting: TaskSorting) => {
if (sorting.type === TaskSortingType.TASK_TITLE) {
return 'Sort: Card title';
}
if (sorting.type === TaskSortingType.MEMBERS) {
return 'Sort: Members';
}
if (sorting.type === TaskSortingType.DUE_DATE) {
return 'Sort: Due Date';
}
if (sorting.type === TaskSortingType.LABELS) {
return 'Sort: Labels';
}
return 'Sort';
};
const renderMetaFilters = (filters: TaskMetaFilters, onClose: MetaFilterCloseFn) => {
const filterChips = [];
if (filters.taskName) {
filterChips.push(
<FilterChip
key="task-name"
label={`Title: ${filters.taskName.name}`}
onClose={() => onClose(TaskMeta.TITLE, 'task-name')}
/>,
);
}
if (filters.dueDate) {
filterChips.push(
<FilterChip
key="due-date"
label={filters.dueDate.label}
onClose={() => onClose(TaskMeta.DUE_DATE, 'due-date')}
/>,
);
}
for (const memberFilter of filters.members) {
filterChips.push(
<FilterChip
key={`member-${memberFilter.id}`}
label={`Member: ${memberFilter.username}`}
onClose={() => onClose(TaskMeta.MEMBER, memberFilter.id)}
/>,
);
}
for (const labelFilter of filters.labels) {
filterChips.push(
<FilterChip
key={`label-${labelFilter.id}`}
label={labelFilter.name === '' ? 'Label' : `Label: ${labelFilter.name}`}
color={labelFilter.color}
onClose={() => onClose(TaskMeta.LABEL, labelFilter.id)}
/>,
);
}
return filterChips;
};
const ProjectBar = styled.div`
display: flex;
@ -131,14 +47,14 @@ const ProjectActions = styled.div`
align-items: center;
`;
const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
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-of-type) {
&:not(:last-child) {
margin-right: 16px;
}
@ -158,25 +74,6 @@ const ProjectActionText = styled.span`
padding-left: 4px;
`;
type ProjectActionProps = {
onClick?: (target: React.RefObject<HTMLElement>) => void;
disabled?: boolean;
};
const ProjectAction: React.FC<ProjectActionProps> = ({ onClick, disabled = false, children }) => {
const $container = useRef<HTMLDivElement>(null);
const handleClick = () => {
if (onClick) {
onClick($container);
}
};
return (
<ProjectActionWrapper ref={$container} onClick={handleClick} disabled={disabled}>
{children}
</ProjectActionWrapper>
);
};
interface QuickCardEditorState {
isOpen: boolean;
target: React.RefObject<HTMLElement> | null;
@ -202,18 +99,18 @@ export const BoardLoading = () => {
<>
<ProjectBar>
<ProjectActions>
<ProjectAction>
<ProjectAction disabled>
<CheckCircle width={13} height={13} />
<ProjectActionText>All Tasks</ProjectActionText>
</ProjectAction>
<ProjectAction>
<Sort width={13} height={13} />
<ProjectActionText>Sort</ProjectActionText>
</ProjectAction>
<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>
@ -235,43 +132,17 @@ export const BoardLoading = () => {
);
};
const initTaskStatusFilter: TaskStatusFilter = {
status: TaskStatus.ALL,
since: TaskSince.ALL,
};
const initTaskMetaFilters: TaskMetaFilters = {
match: TaskMetaMatch.MATCH_ANY,
dueDate: null,
taskName: null,
labels: [],
members: [],
};
const initTaskSorting: TaskSorting = {
type: TaskSortingType.NONE,
direction: TaskSortingDirection.ASC,
};
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 membersRef = useRef<Array<TaskUser>>([]);
const { showPopup, hidePopup } = usePopup();
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
const [taskStatusFilter, setTaskStatusFilter] = useState(initTaskStatusFilter);
const [taskMetaFilters, setTaskMetaFilters] = useState(initTaskMetaFilters);
const [taskSorting, setTaskSorting] = useState(initTaskSorting);
const history = useHistory();
const [sortTaskGroup] = useSortTaskGroupMutation({
onCompleted: () => {
toast('List was sorted');
},
});
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
update: (client, deletedTaskGroupData) => {
updateApolloCache<FindProjectQuery>(
@ -324,36 +195,6 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
const { loading, data } = useFindProjectQuery({
variables: { projectID },
});
const [deleteTaskGroupTasks] = useDeleteTaskGroupTasksMutation({
update: (client, resp) =>
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const idx = cache.findProject.taskGroups.findIndex(
t => t.id === resp.data.deleteTaskGroupTasks.taskGroupID,
);
if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks = [];
}
}),
{ projectID },
),
});
const [duplicateTaskGroup] = useDuplicateTaskGroupMutation({
update: (client, resp) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
}),
{ projectID },
);
},
});
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
const [setTaskComplete] = useSetTaskCompleteMutation();
@ -384,7 +225,6 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
);
},
});
const { user } = useCurrentUser();
const [deleteTask] = useDeleteTaskMutation();
const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => {
@ -414,7 +254,6 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
id: `${Math.round(Math.random() * -1000000)}`,
name,
complete: false,
completedAt: null,
taskGroup: {
__typename: 'TaskGroup',
id: taskGroup.id,
@ -451,18 +290,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
if (loading) {
return <BoardLoading />;
}
const getTaskStatusFilterLabel = (filter: TaskStatusFilter) => {
if (filter.status === TaskStatus.COMPLETE) {
return 'Complete';
}
if (filter.status === TaskStatus.INCOMPLETE) {
return 'Incomplete';
}
return 'All Tasks';
};
if (data && user) {
if (data) {
labelsRef.current = data.findProject.labels;
membersRef.current = data.findProject.members;
const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
const currentTask = taskGroup ? taskGroup.tasks.find(t => t.id === taskID) : null;
@ -486,84 +315,23 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<>
<ProjectBar>
<ProjectActions>
<ProjectAction
onClick={target => {
showPopup(
target,
<Popup tab={0} title={null}>
<FilterStatus
filter={taskStatusFilter}
onChangeTaskStatusFilter={filter => {
setTaskStatusFilter(filter);
hidePopup();
}}
/>
</Popup>,
{ width: 185 },
);
}}
>
<ProjectAction disabled>
<CheckCircle width={13} height={13} />
<ProjectActionText>{getTaskStatusFilterLabel(taskStatusFilter)}</ProjectActionText>
<ProjectActionText>All Tasks</ProjectActionText>
</ProjectAction>
<ProjectAction
onClick={target => {
showPopup(
target,
<Popup tab={0} title={null}>
<SortPopup
sorting={taskSorting}
onChangeTaskSorting={sorting => {
setTaskSorting(sorting);
}}
/>
</Popup>,
{ width: 185 },
);
}}
>
<Sort width={13} height={13} />
<ProjectActionText>{renderTaskSortingLabel(taskSorting)}</ProjectActionText>
</ProjectAction>
<ProjectAction
onClick={target => {
showPopup(
target,
<FilterMeta
filters={taskMetaFilters}
onChangeTaskMetaFilter={filter => {
setTaskMetaFilters(filter);
}}
userID={user?.id}
labels={labelsRef}
members={membersRef}
/>,
{ width: 200 },
);
}}
>
<ProjectAction disabled>
<Filter width={13} height={13} />
<ProjectActionText>Filter</ProjectActionText>
</ProjectAction>
{renderMetaFilters(taskMetaFilters, (meta, id) => {
setTaskMetaFilters(
produce(taskMetaFilters, draftFilters => {
if (meta === TaskMeta.MEMBER) {
draftFilters.members = draftFilters.members.filter(m => m.id !== id);
} else if (meta === TaskMeta.LABEL) {
draftFilters.labels = draftFilters.labels.filter(m => m.id !== id);
} else if (meta === TaskMeta.TITLE) {
draftFilters.taskName = null;
} else if (meta === TaskMeta.DUE_DATE) {
draftFilters.dueDate = null;
}
}),
);
})}
<ProjectAction disabled>
<Sort width={13} height={13} />
<ProjectActionText>Sort</ProjectActionText>
</ProjectAction>
</ProjectActions>
<ProjectActions>
<ProjectAction
onClick={$labelsRef => {
ref={$labelsRef}
onClick={() => {
showPopup(
$labelsRef,
<LabelManagerEditor
@ -636,9 +404,6 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
});
}}
taskGroups={data.findProject.taskGroups}
taskStatusFilter={taskStatusFilter}
taskMetaFilters={taskMetaFilters}
taskSorting={taskSorting}
onCreateTask={onCreateTask}
onCreateTaskGroup={onCreateList}
onCardMemberClick={($targetRef, _taskID, memberID) => {
@ -663,44 +428,15 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
showPopup(
$targetRef,
<Popup title="List actions" tab={0} onClose={() => hidePopup()}>
<ListActions
taskGroupID={taskGroupID}
onDeleteTaskGroupTasks={() => {
deleteTaskGroupTasks({ variables: { taskGroupID } });
hidePopup();
}}
onSortTaskGroup={taskSort => {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
if (taskGroup) {
const tasks: Array<{ taskID: string; position: number }> = taskGroup.tasks
.sort((a, b) => sortTasks(a, b, taskSort))
.reduce((prevTasks: Array<{ taskID: string; position: number }>, t, idx) => {
prevTasks.push({ taskID: t.id, position: (idx + 1) * 2048 });
return tasks;
}, []);
sortTaskGroup({ variables: { taskGroupID, tasks } });
hidePopup();
}
}}
onDuplicateTaskGroup={newName => {
const idx = data.findProject.taskGroups.findIndex(t => t.id === taskGroupID);
if (idx !== -1) {
const taskGroups = data.findProject.taskGroups.sort((a, b) => a.position - b.position);
const prevPos = taskGroups[idx].position;
const next = taskGroups[idx + 1];
let newPos = prevPos * 2;
if (next) {
newPos = (prevPos + next.position) / 2.0;
}
duplicateTaskGroup({ variables: { projectID, taskGroupID, name: newName, position: newPos } });
hidePopup();
}
}}
onArchiveTaskGroup={tgID => {
deleteTaskGroup({ variables: { taskGroupID: tgID } });
hidePopup();
}}
/>,
/>
</Popup>,
);
}}
/>

View File

@ -290,26 +290,25 @@ const Details: React.FC<DetailsProps> = ({
},
});
if (loading) {
return null;
return <div>loading</div>;
}
if (!data) {
return null;
return <div>loading</div>;
}
return (
<>
<Modal
width={1070}
width={768}
onClose={() => {
history.push(projectURL);
}}
renderContent={() => {
return (
<TaskDetails
me={data.me.user}
task={data.findTask}
onChecklistDrop={checklist => {
updateTaskChecklistLocation({
variables: { taskChecklistID: checklist.id, position: checklist.position },
variables: { checklistID: checklist.id, position: checklist.position },
optimisticResponse: {
__typename: 'Mutation',
@ -324,24 +323,20 @@ const Details: React.FC<DetailsProps> = ({
},
});
}}
onChecklistItemDrop={(prevChecklistID, taskChecklistID, checklistItem) => {
onChecklistItemDrop={(prevChecklistID, checklistID, checklistItem) => {
updateTaskChecklistItemLocation({
variables: {
taskChecklistID,
taskChecklistItemID: checklistItem.id,
position: checklistItem.position,
},
variables: { checklistID, checklistItemID: checklistItem.id, position: checklistItem.position },
optimisticResponse: {
__typename: 'Mutation',
updateTaskChecklistItemLocation: {
__typename: 'UpdateTaskChecklistItemLocationPayload',
prevChecklistID,
taskChecklistID,
checklistID,
checklistItem: {
__typename: 'TaskChecklistItem',
position: checklistItem.position,
id: checklistItem.id,
taskChecklistID,
taskChecklistID: checklistID,
},
},
},
@ -505,7 +500,6 @@ const Details: React.FC<DetailsProps> = ({
onCancel={NOOP}
/>
</Popup>,
{ showDiamond: false, targetPadding: '0' },
);
}}
/>

View File

@ -3,11 +3,32 @@ 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';

View File

@ -3,7 +3,6 @@ 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 AsyncSelect from 'react-select/async';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import {
useParams,
@ -16,12 +15,11 @@ import {
} from 'react-router-dom';
import {
useUpdateProjectMemberRoleMutation,
useInviteProjectMembersMutation,
useCreateProjectMemberMutation,
useDeleteProjectMemberMutation,
useToggleTaskLabelMutation,
useUpdateProjectNameMutation,
useFindProjectQuery,
useDeleteInvitedProjectMemberMutation,
useUpdateTaskNameMutation,
useCreateTaskMutation,
useDeleteTaskMutation,
@ -39,20 +37,12 @@ import Input from 'shared/components/Input';
import Member from 'shared/components/Member';
import EmptyBoard from 'shared/components/EmptyBoard';
import NOOP from 'shared/utils/noop';
import { Lock, Cross } from 'shared/icons';
import Button from 'shared/components/Button';
import { useApolloClient } from '@apollo/react-hooks';
import TaskAssignee from 'shared/components/TaskAssignee';
import gql from 'graphql-tag';
import { colourStyles } from 'shared/components/Select';
import Board, { BoardLoading } from './Board';
import Details from './Details';
import LabelManagerEditor from './LabelManagerEditor';
const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant';
const RFC2822_EMAIL = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => {
const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || '');
@ -80,299 +70,29 @@ const MemberList = styled.div`
margin: 8px 0;
`;
type InviteUserData = {
email?: string;
suerID?: string;
};
type UserManagementPopupProps = {
projectID: string;
users: Array<User>;
projectMembers: Array<TaskUser>;
onInviteProjectMembers: (data: Array<InviteUserData>) => void;
onAddProjectMember: (userID: string) => void;
};
const VisibiltyPrivateIcon = styled(Lock)`
padding-right: 4px;
`;
const VisibiltyButtonText = styled.span`
color: rgba(${props => props.theme.colors.text.primary});
`;
const ShareActions = styled.div`
border-top: 1px solid #414561;
margin-top: 8px;
padding-top: 8px;
display: flex;
align-items: center;
justify-content: space-between;
`;
const VisibiltyButton = styled.button`
cursor: pointer;
margin: 2px 4px;
padding: 2px 4px;
align-items: center;
justify-content: center;
border-bottom: 1px solid transparent;
&:hover ${VisibiltyButtonText} {
color: rgba(${props => props.theme.colors.text.secondary});
}
&:hover ${VisibiltyPrivateIcon} {
fill: rgba(${props => props.theme.colors.text.secondary});
stroke: rgba(${props => props.theme.colors.text.secondary});
}
&:hover {
border-bottom: 1px solid rgba(${props => props.theme.colors.primary});
}
`;
type MemberFilterOptions = {
projectID?: null | string;
teamID?: null | string;
organization?: boolean;
};
const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => {
console.log(input.trim().length < 3);
if (input && input.trim().length < 3) {
return [];
}
const res = await client.query({
query: gql`
query {
searchMembers(input: {searchFilter:"${input}", projectID:"${projectID}"}) {
id
similarity
status
user {
id
fullName
email
profileIcon {
url
initials
bgColor
}
}
}
}
`,
});
let results: any = [];
const emails: Array<string> = [];
console.log(res.data && res.data.searchMembers);
if (res.data && res.data.searchMembers) {
results = [
...res.data.searchMembers.map((m: any) => {
if (m.status === 'INVITED') {
console.log(`${m.id} is added`);
return {
label: m.id,
value: {
id: m.id,
type: 2,
profileIcon: {
bgColor: '#ccc',
initials: m.id.charAt(0),
},
},
};
} else {
console.log(`${m.user.email} is added`);
emails.push(m.user.email);
return {
label: m.user.fullName,
value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon },
};
}
}),
];
console.log(results);
}
if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) {
results = [
...results,
{
label: input,
value: {
id: input,
type: 1,
profileIcon: {
bgColor: '#ccc',
initials: input.charAt(0),
},
},
},
];
}
return results;
};
type UserOptionProps = {
innerProps: any;
isDisabled: boolean;
isFocused: boolean;
label: string;
data: any;
getValue: any;
};
const OptionWrapper = styled.div<{ isFocused: boolean }>`
cursor: pointer;
padding: 4px 8px;
${props => props.isFocused && `background: rgba(${props.theme.colors.primary});`}
display: flex;
align-items: center;
`;
const OptionContent = styled.div`
display: flex;
flex-direction: column;
margin-left: 12px;
`;
const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>`
display: flex;
align-items: center;
font-size: ${p => p.fontSize}px;
color: rgba(${p => (p.quiet ? p.theme.colors.text.primary : p.theme.colors.text.primary)});
`;
const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
console.log(data);
return !isDisabled ? (
<OptionWrapper {...innerProps} isFocused={isFocused}>
<TaskAssignee
size={32}
member={{
id: '',
fullName: data.value.label,
profileIcon: data.value.profileIcon,
}}
/>
<OptionContent>
<OptionLabel fontSize={16} quiet={false}>
{label}
</OptionLabel>
{data.value.type === 2 && (
<OptionLabel fontSize={14} quiet>
Joined
</OptionLabel>
)}
</OptionContent>
</OptionWrapper>
) : null;
};
const OptionValueWrapper = styled.div`
background: rgba(${props => props.theme.colors.bg.primary});
border-radius: 4px;
margin: 2px;
padding: 3px 6px 3px 4px;
display: flex;
align-items: center;
`;
const OptionValueLabel = styled.span`
font-size: 12px;
color: rgba(${props => props.theme.colors.text.secondary});
`;
const OptionValueRemove = styled.button`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
outline: none;
padding: 0;
margin: 0;
margin-left: 4px;
`;
const OptionValue = ({ data, removeProps }: any) => {
return (
<OptionValueWrapper>
<OptionValueLabel>{data.label}</OptionValueLabel>
<OptionValueRemove {...removeProps}>
<Cross width={14} height={14} />
</OptionValueRemove>
</OptionValueWrapper>
);
};
const InviteButton = styled(Button)`
margin-top: 12px;
height: 32px;
padding: 4px 12px;
width: 100%;
justify-content: center;
`;
const InviteContainer = styled.div`
min-height: 300px;
display: flex;
flex-direction: column;
`;
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({
projectID,
users,
projectMembers,
onInviteProjectMembers,
}) => {
const client = useApolloClient();
const [invitedUsers, setInvitedUsers] = useState<Array<any> | null>(null);
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, projectMembers, onAddProjectMember }) => {
return (
<Popup tab={0} title="Invite a user">
<InviteContainer>
<AsyncSelect
getOptionValue={option => option.value.id}
placeholder="Email address or username"
noOptionsMessage={() => null}
onChange={(e: any) => {
setInvitedUsers(e);
}}
isMulti
autoFocus
cacheOptions
styles={colourStyles}
defaultOption
components={{
MultiValue: OptionValue,
Option: UserOption,
IndicatorSeparator: null,
DropdownIndicator: null,
}}
loadOptions={(i, cb) => fetchMembers(client, projectID, {}, i, cb)}
<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=""
/>
</InviteContainer>
<InviteButton
onClick={() => {
if (invitedUsers) {
onInviteProjectMembers(
invitedUsers.map(user => {
if (user.value.type === 0) {
return {
userID: user.value.id,
};
}
return {
email: user.value.id,
};
}),
);
}
}}
disabled={invitedUsers === null}
hoverVariant="none"
fontSize="16px"
>
Send Invite
</InviteButton>
))}
</MemberList>
</Popup>
);
};
@ -415,30 +135,11 @@ const Project = () => {
const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY);
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
const [deleteTask] = useDeleteTaskMutation({
update: (client, resp) =>
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const taskGroupIdx = draftCache.findProject.taskGroups.findIndex(
tg => tg.tasks.findIndex(t => t.id === resp.data.deleteTask.taskID) !== -1,
);
if (taskGroupIdx !== -1) {
draftCache.findProject.taskGroups[taskGroupIdx].tasks = cache.findProject.taskGroups[
taskGroupIdx
].tasks.filter(t => t.id !== resp.data.deleteTask.taskID);
}
}),
{ projectID },
),
});
const [deleteTask] = useDeleteTaskMutation();
const [updateTaskName] = useUpdateTaskNameMutation();
const { loading, data, error } = useFindProjectQuery({
const { loading, data } = useFindProjectQuery({
variables: { projectID },
});
@ -456,36 +157,14 @@ const Project = () => {
},
});
const [inviteProjectMembers] = useInviteProjectMembersMutation({
const [createProjectMember] = useCreateProjectMemberMutation({
update: (client, response) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.members = [
...cache.findProject.members,
...response.data.inviteProjectMembers.members,
];
draftCache.findProject.invitedMembers = [
...cache.findProject.invitedMembers,
...response.data.inviteProjectMembers.invitedMembers,
];
}),
{ projectID },
);
},
});
const [deleteInvitedProjectMember] = useDeleteInvitedProjectMemberMutation({
update: (client, response) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.invitedMembers = cache.findProject.invitedMembers.filter(
m => m.email !== response.data.deleteInvitedProjectMember.invitedMember.email,
);
draftCache.findProject.members.push({ ...response.data.createProjectMember.member });
}),
{ projectID },
);
@ -526,9 +205,6 @@ const Project = () => {
</>
);
}
if (error) {
history.push('/projects');
}
if (data) {
labelsRef.current = data.findProject.labels;
@ -545,10 +221,6 @@ const Project = () => {
deleteProjectMember({ variables: { userID, projectID } });
hidePopup();
}}
onRemoveInvitedFromBoard={email => {
deleteInvitedProjectMember({ variables: { projectID, email } });
hidePopup();
}}
onSaveProjectName={projectName => {
updateProjectName({ variables: { projectID, name: projectName } });
}}
@ -556,10 +228,8 @@ const Project = () => {
showPopup(
$target,
<UserManagementPopup
projectID={projectID}
onInviteProjectMembers={members => {
inviteProjectMembers({ variables: { projectID, members } });
hidePopup();
onAddProjectMember={userID => {
createProjectMember({ variables: { userID, projectID } });
}}
users={data.users}
projectMembers={data.findProject.members}
@ -570,9 +240,8 @@ const Project = () => {
menuType={[{ name: 'Board', link: location.pathname }]}
currentTab={0}
projectMembers={data.findProject.members}
projectInvitedMembers={data.findProject.invitedMembers}
projectID={projectID}
teamID={data.findProject.team ? data.findProject.team.id : null}
teamID={data.findProject.team.id}
name={data.findProject.name}
/>
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />
@ -601,21 +270,10 @@ const Project = () => {
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
}}
onTaskDescriptionChange={(updatedTask, newDescription) => {
updateTaskDescription({
variables: { taskID: updatedTask.id, description: newDescription },
optimisticResponse: {
__typename: 'Mutation',
updateTaskDescription: {
__typename: 'Task',
id: updatedTask.id,
description: newDescription,
},
},
});
updateTaskDescription({ variables: { taskID: updatedTask.id, description: newDescription } });
}}
onDeleteTask={deletedTask => {
deleteTask({ variables: { taskID: deletedTask.id } });
history.push(`${match.url}/board`);
}}
onOpenAddLabelPopup={(task, $targetRef) => {
taskLabelsRef.current = task.labels;

View File

@ -21,6 +21,33 @@ import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import NOOP from 'shared/utils/noop';
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 = {
@ -29,10 +56,6 @@ type CreateTeamFormProps = {
const CreateTeamFormContainer = styled.form``;
const CreateTeamButton = styled(Button)`
width: 100%;
`;
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
const { register, handleSubmit } = useForm<CreateTeamData>();
const createTeam = (data: CreateTeamData) => {
@ -186,6 +209,13 @@ const ProjectsContainer = styled.div`
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;
@ -193,6 +223,10 @@ const AddTeamButton = styled(Button)`
right: 12px;
`;
const CreateFirstTeam = styled(Button)`
margin-top: 8px;
`;
type ShowNewProject = {
open: boolean;
initialTeamID: null | string;
@ -226,20 +260,17 @@ const Projects = () => {
},
});
if (loading) {
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
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 personalProjects = projects
.filter(p => p.team === null)
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
});
const projectTeams = teams
.sort((a, b) => {
const textA = a.name.toUpperCase();
@ -251,7 +282,7 @@ const Projects = () => {
id: team.id,
name: team.name,
projects: projects
.filter(project => project.team && project.team.id === team.id)
.filter(project => project.team.id === team.id)
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
@ -292,35 +323,39 @@ const Projects = () => {
Add Team
</AddTeamButton>
)}
<div>
<ProjectSectionTitleWrapper>
<ProjectSectionTitle>Personal Projects</ProjectSectionTitle>
</ProjectSectionTitleWrapper>
<ProjectList>
{personalProjects.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>
))}
<ProjectListItem>
<ProjectAddTile
onClick={() => {
setShowNewProject({ open: true, initialTeamID: 'no-team' });
{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();
}}
>
<ProjectTileFade />
<ProjectAddTileDetails>
<ProjectTileName centered>Create new project</ProjectTileName>
</ProjectAddTileDetails>
</ProjectAddTile>
</ProjectListItem>
</ProjectList>
</div>
<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}>
@ -374,7 +409,7 @@ const Projects = () => {
initialTeamID={showNewProject.initialTeamID}
onCreateProject={(name, teamID) => {
if (user) {
createProject({ variables: { teamID, name } });
createProject({ variables: { teamID, name, userID: user.id } });
setShowNewProject({ open: false, initialTeamID: null });
}
}}

View File

@ -57,7 +57,7 @@ export const TeamPopup: React.FC<TeamPopupProps> = ({ history, name, teamID }) =
<Popup title={null} tab={0}>
<TeamSettings
onDeleteTeam={() => {
setTab(1, { width: 340 });
setTab(1, 340);
}}
/>
</Popup>
@ -85,30 +85,18 @@ type TeamsRouteProps = {
const Teams = () => {
const { teamID } = useParams<TeamsRouteProps>();
const history = useHistory();
const { loading, data } = useGetTeamQuery({
variables: { teamID },
onCompleted: resp => {
document.title = `${resp.findTeam.name} | Taskcafé`;
},
});
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 (
<GlobalTopNavbar
menuType={[
{ name: 'Projects', link: `${match.url}` },
{ name: 'Members', link: `${match.url}/members` },
]}
currentTab={currentTab}
onSetTab={tab => {
setCurrentTab(tab);
}}
onSaveProjectName={NOOP}
projectID={null}
name={null}
/>
<>
<span>loading</span>
</>
);
}
if (data && user) {

View File

@ -8,34 +8,13 @@ import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error';
import { enableMapSet } from 'immer';
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import dayjs from 'dayjs';
import updateLocale from 'dayjs/plugin/updateLocale';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween';
import weekday from 'dayjs/plugin/weekday';
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
dayjs.extend(isSameOrAfter);
dayjs.extend(weekday);
dayjs.extend(isBetween);
dayjs.extend(customParseFormat);
enableMapSet();
dayjs.extend(updateLocale);
dayjs.updateLocale('en', {
week: {
dow: 1, // First day of week is Monday
doy: 7, // First week of year must contain 1 January (7 + 1 - 1)
},
});
let forward$;
let isRefreshing = false;
let pendingRequests: any = [];

View File

View File

@ -16,12 +16,11 @@ import {
} from './Styles';
type NameEditorProps = {
buttonLabel?: string;
onSave: (listName: string) => void;
onCancel: () => void;
};
export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCancel, buttonLabel = 'Save' }) => {
const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
const $editorRef = useRef<HTMLTextAreaElement>(null);
const [listName, setListName] = useState('');
useEffect(() => {
@ -29,11 +28,6 @@ export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCa
$editorRef.current.focus();
}
});
const onSave = (newName: string) => {
if (newName.replace(/\s+/g, '') !== '') {
handleSave(newName);
}
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
@ -66,7 +60,7 @@ export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCa
}
}}
>
{buttonLabel}
Save
</AddListButton>
<CancelAdd onClick={() => onCancel()}>
<Cross width={16} height={16} />
@ -104,7 +98,7 @@ const AddList: React.FC<AddListProps> = ({ onSave }) => {
) : (
<Placeholder>
<AddIconWrapper>
<Plus width={12} height={12} />
<Plus size={12} color="#c2c6dc" />
</AddIconWrapper>
Add another list
</Placeholder>

View File

@ -51,9 +51,7 @@ export const Default = () => {
},
},
]}
invitedUsers={[]}
onAddUser={action('add user')}
onDeleteInvitedUser={action('delete invited user')}
/>
</ThemeProvider>
</>

View File

@ -104,8 +104,8 @@ type TeamRoleManagerPopupProps = {
user: User;
users: Array<User>;
warning?: string | null;
canChangeRole?: boolean;
onChangeRole?: (roleCode: RoleCode) => void;
canChangeRole: boolean;
onChangeRole: (roleCode: RoleCode) => void;
updateUserPassword?: (user: TaskUser, password: string) => void;
onDeleteUser?: (userID: string, newOwnerID: string | null) => void;
};
@ -430,7 +430,6 @@ const TabNavItem = styled.li`
display: block;
position: relative;
`;
const TabNavItemButton = styled.button<{ active: boolean }>`
cursor: pointer;
display: flex;
@ -451,10 +450,6 @@ const TabNavItemButton = styled.button<{ active: boolean }>`
fill: rgba(115, 103, 240);
}
`;
const TabItemUser = styled(User)<{ active: boolean }>`
fill: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')}
stroke: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')}
`;
const TabNavItemSpan = styled.span`
text-align: left;
@ -517,7 +512,7 @@ const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
}}
>
<TabNavItemButton active={active}>
<TabItemUser width={14} height={14} active={active} />
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
<TabNavItemSpan>{name}</TabNavItemSpan>
</TabNavItemButton>
</TabNavItem>
@ -530,10 +525,8 @@ type AdminProps = {
onDeleteUser: (userID: string, newOwnerID: string | null) => void;
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
users: Array<User>;
invitedUsers: Array<InvitedUserAccount>;
canInviteUser: boolean;
onUpdateUserPassword: (user: TaskUser, password: string) => void;
onDeleteInvitedUser: (invitedUserID: string) => void;
};
const Admin: React.FC<AdminProps> = ({
@ -542,9 +535,7 @@ const Admin: React.FC<AdminProps> = ({
onUpdateUserPassword,
canInviteUser,
onDeleteUser,
onDeleteInvitedUser,
onInviteUser,
invitedUsers,
users,
}) => {
const warning =
@ -561,7 +552,6 @@ const Admin: React.FC<AdminProps> = ({
<TabNavContent>
{items.map((item, idx) => (
<NavItem
key={item.name}
onClick={(tab, top) => {
if ($tabNav && $tabNav.current) {
const pos = $tabNav.current.getBoundingClientRect();
@ -581,7 +571,7 @@ const Admin: React.FC<AdminProps> = ({
<TabContent>
<MemberListWrapper>
<MemberListHeader>
<ListTitle>{`Members (${users.length + invitedUsers.length})`}</ListTitle>
<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.
@ -639,65 +629,6 @@ const Admin: React.FC<AdminProps> = ({
</MemberListItem>
);
})}
{invitedUsers.map(member => {
return (
<MemberListItem>
<MemberProfile
showRoleIcons
size={32}
onMemberProfile={NOOP}
member={{
id: member.id,
fullName: member.email,
profileIcon: {
bgColor: '#fff',
url: null,
initials: member.email.charAt(0),
},
}}
/>
<MemberListItemDetails>
<MemberItemName>{member.email}</MemberItemName>
<MemberItemUsername>Invited</MemberItemUsername>
</MemberListItemDetails>
<MemberItemOptions>
<MemberItemOption
variant="outline"
onClick={$target => {
showPopup(
$target,
<TeamRoleManagerPopup
user={{
id: member.id,
fullName: member.email,
profileIcon: {
bgColor: '#fff',
url: null,
initials: member.email.charAt(0),
},
member: {
teams: [],
projects: [],
},
owned: {
teams: [],
projects: [],
},
}}
users={users}
onDeleteUser={() => {
onDeleteInvitedUser(member.id);
}}
/>,
);
}}
>
Manage
</MemberItemOption>
</MemberItemOptions>
</MemberListItem>
);
})}
</MemberList>
</MemberListWrapper>
</TabContent>

View File

@ -1,7 +1,7 @@
import React, { useRef } from 'react';
import styled, { css } from 'styled-components/macro';
const Text = styled.span<{ fontSize: string; justifyTextContent: string; hasIcon?: boolean }>`
const Text = styled.span<{ fontSize: string; justifyTextContent: string }>`
position: relative;
display: flex;
align-items: center;
@ -9,11 +9,6 @@ const Text = styled.span<{ fontSize: string; justifyTextContent: string; hasIcon
transition: all 0.2s ease;
font-size: ${props => props.fontSize};
color: rgba(${props => props.theme.colors.text.secondary});
${props =>
props.hasIcon &&
css`
padding-left: 4px;
`}
`;
const Base = styled.button<{ color: string; disabled: boolean }>`
@ -23,8 +18,6 @@ const Base = styled.button<{ color: string; disabled: boolean }>`
cursor: pointer;
padding: 0.75rem 2rem;
border-radius: ${props => props.theme.borderRadius.alternate};
display: flex;
align-items: center;
${props =>
props.disabled &&
@ -35,38 +28,22 @@ const Base = styled.button<{ color: string; disabled: boolean }>`
`}
`;
const Filled = styled(Base)<{ hoverVariant: HoverVariant }>`
const Filled = styled(Base)`
background: rgba(${props => props.theme.colors[props.color]});
${props =>
props.hoverVariant === 'boxShadow' &&
css`
&:hover {
box-shadow: 0 8px 25px -8px rgba(${props.theme.colors[props.color]});
box-shadow: 0 8px 25px -8px rgba(${props => props.theme.colors[props.color]});
}
`}
`;
const Outline = styled(Base)<{ invert: boolean }>`
const Outline = styled(Base)`
border: 1px solid rgba(${props => props.theme.colors[props.color]});
background: transparent;
${props =>
props.invert
? css`
background: rgba(${props.theme.colors[props.color]});
& ${Text} {
color: rgba(${props.theme.colors.text.secondary});
color: rgba(${props => props.theme.colors[props.color]});
}
&:hover {
background: rgba(${props.theme.colors[props.color]}, 0.8);
background: rgba(${props => props.theme.colors[props.color]}, 0.08);
}
`
: css`
& ${Text} {
color: rgba(${props.theme.colors[props.color]});
}
&:hover {
background: rgba(${props.theme.colors[props.color]}, 0.08);
}
`}
`;
const Flat = styled(Base)`
@ -127,16 +104,12 @@ const Relief = styled(Base)`
}
`;
type HoverVariant = 'boxShadow' | 'none';
type ButtonProps = {
fontSize?: string;
variant?: 'filled' | 'outline' | 'flat' | 'lineDown' | 'gradient' | 'relief';
hoverVariant?: HoverVariant;
color?: 'primary' | 'danger' | 'success' | 'warning' | 'dark';
disabled?: boolean;
type?: 'button' | 'submit';
icon?: JSX.Element;
invert?: boolean;
className?: string;
onClick?: ($target: React.RefObject<HTMLButtonElement>) => void;
justifyTextContent?: string;
@ -145,13 +118,10 @@ type ButtonProps = {
const Button: React.FC<ButtonProps> = ({
disabled = false,
fontSize = '14px',
invert = false,
color = 'primary',
variant = 'filled',
hoverVariant = 'boxShadow',
type = 'button',
justifyTextContent = 'center',
icon,
onClick,
className,
children,
@ -165,17 +135,8 @@ const Button: React.FC<ButtonProps> = ({
switch (variant) {
case 'filled':
return (
<Filled
ref={$button}
hoverVariant={hoverVariant}
type={type}
onClick={handleClick}
className={className}
disabled={disabled}
color={color}
>
{icon && icon}
<Text hasIcon={typeof icon !== 'undefined'} justifyTextContent={justifyTextContent} fontSize={fontSize}>
<Filled ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}>
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
{children}
</Text>
</Filled>
@ -184,7 +145,6 @@ const Button: React.FC<ButtonProps> = ({
return (
<Outline
ref={$button}
invert={invert}
type={type}
onClick={handleClick}
className={className}

View File

@ -1,7 +1,9 @@
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, Clock } from 'shared/icons';
import { CheckCircle, CheckSquareOutline } from 'shared/icons';
import { RefObject } from 'react';
import TaskAssignee from 'shared/components/TaskAssignee';
export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
@ -18,9 +20,7 @@ export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'no
stroke: rgba(${props.theme.colors.success});
`}
`;
export const ClockIcon = styled(Clock)<{ color: string }>`
fill: ${props => props.color};
`;
export const ClockIcon = styled(FontAwesomeIcon)``;
export const EditorTextarea = styled(TextareaAutosize)`
overflow: hidden;
@ -147,11 +147,6 @@ export const ListCardLabelText = styled.span`
line-height: 16px;
`;
export const ListCardLabelsWrapper = styled.div`
overflow: auto;
position: relative;
`;
export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
${props =>
props.variant === 'small'
@ -183,6 +178,8 @@ export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
`;
export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>`
overflow: auto;
position: relative;
&:hover {
opacity: 0.8;
}

View File

@ -1,5 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { Pencil, Eye, List } from 'shared/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
import { faClock, faEye } from '@fortawesome/free-regular-svg-icons';
import {
EditorTextarea,
CardMember,
@ -18,7 +20,6 @@ import {
ListCardLabels,
ListCardLabel,
ListCardLabelText,
ListCardLabelsWrapper,
ListCardOperation,
CardTitle,
CardMembers,
@ -153,12 +154,10 @@ const Card = React.forwardRef(
}
}}
>
<Pencil width={8} height={8} />
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
</ListCardOperation>
)}
<ListCardDetails complete={complete ?? false}>
{labels && labels.length !== 0 && (
<ListCardLabelsWrapper>
<ListCardLabels
toggleLabels={toggleLabels}
toggleDirection={toggleDirection}
@ -169,7 +168,8 @@ const Card = React.forwardRef(
}
}}
>
{labels
{labels &&
labels
.slice()
.sort((a, b) => a.labelColor.position - b.labelColor.position)
.map(label => (
@ -187,8 +187,6 @@ const Card = React.forwardRef(
</ListCardLabel>
))}
</ListCardLabels>
</ListCardLabelsWrapper>
)}
{editable ? (
<EditorContent>
{complete && <CompleteIcon width={16} height={16} />}
@ -216,18 +214,18 @@ const Card = React.forwardRef(
<ListCardBadges>
{watched && (
<ListCardBadge>
<Eye width={8} height={8} />
<FontAwesomeIcon color="#6b778c" icon={faEye} size="xs" />
</ListCardBadge>
)}
{dueDate && (
<DueDateCardBadge isPastDue={dueDate.isPastDue}>
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} width={8} height={8} />
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} icon={faClock} size="xs" />
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
</DueDateCardBadge>
)}
{description && (
<DescriptionBadge>
<List width={8} height={8} />
<FontAwesomeIcon color="#6b778c" icon={faList} size="xs" />
</DescriptionBadge>
)}
{checklists && (

View File

@ -1,15 +1,15 @@
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 CancelIconWrapper = styled.div`
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')};

View File

@ -1,12 +1,12 @@
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 { Cross } from 'shared/icons';
import {
CardComposerWrapper,
CancelIconWrapper,
CancelIcon,
AddCardButton,
ComposerControls,
ComposerControlsSaveSection,
@ -26,9 +26,10 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
useOnOutsideClick($cardRef, true, onClose, null);
useOnEscapeKeyDown(isOpen, onClose);
return (
<CardComposerWrapper isOpen={isOpen} ref={$cardRef}>
<CardComposerWrapper isOpen={isOpen}>
<Card
title={cardName}
ref={$cardRef}
taskID=""
taskGroupID=""
editable
@ -51,9 +52,7 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
>
Add Card
</AddCardButton>
<CancelIconWrapper onClick={() => onClose()}>
<Cross width={12} height={12} />
</CancelIconWrapper>
<CancelIcon onClick={onClose} icon={faTimes} color="#42526e" />
</ComposerControlsSaveSection>
<ComposerControlsActionsSection />
</ComposerControls>

View File

@ -25,7 +25,7 @@ const WindowTitle = styled.div`
const WindowTitleIcon = styled(CheckSquareOutline)`
top: 10px;
left: -32px;
left: -40px;
position: absolute;
`;

View File

@ -1,71 +0,0 @@
import React from 'react';
import styled, { css } from 'styled-components';
import { Cross } from 'shared/icons';
const LabelText = styled.span`
margin-left: 10px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(${props => props.theme.colors.text.primary});
`;
const Container = styled.div<{ color?: string }>`
min-height: 26px;
min-width: 26px;
font-size: 0.8rem;
border-radius: 20px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
${props =>
props.color
? css`
background: ${props.color};
& ${LabelText} {
color: rgba(${props.theme.colors.text.secondary});
}
`
: css`
background: rgba(${props.theme.colors.bg.primary});
`}
`;
const CloseButton = styled.button`
cursor: pointer;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0 4px;
background: rgba(0, 0, 0, 0.15);
&:hover {
background: rgba(0, 0, 0, 0.25);
}
`;
type ChipProps = {
label: string;
onClose?: () => void;
color?: string;
className?: string;
};
const Chip: React.FC<ChipProps> = ({ label, onClose, color, className }) => {
return (
<Container className={className} color={color}>
<LabelText>{label}</LabelText>
{onClose && (
<CloseButton onClick={() => onClose()}>
<Cross width={12} height={12} />
</CloseButton>
)}
</Container>
);
};
export default Chip;

View File

@ -35,7 +35,7 @@ export const Default = () => {
<Wrapper>
<Input label="Label placeholder" />
<Input width="100%" placeholder="Placeholder" />
<Input icon={<User width={20} height={20} />} width="100%" placeholder="Placeholder" />
<Input icon={<User size={20} />} width="100%" placeholder="Placeholder" />
</Wrapper>
</ThemeProvider>
</>

View File

@ -18,7 +18,7 @@ const DropdownMenu: React.FC<DropdownMenuProps> = ({ left, top, onLogout, onClos
<Container ref={$containerRef} left={left} top={top}>
<Wrapper>
<ActionItem onClick={onAdminConsole}>
<User width={16} height={16} />
<User size={16} color="#c2c6dc" />
<ActionTitle>Profile</ActionTitle>
</ActionItem>
<Separator />
@ -54,7 +54,7 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ showAdminConsole, onAdminCons
</>
)}
<ActionItem onClick={onProfile}>
<User width={16} height={16} />
<User size={16} color="#c2c6dc" />
<ActionTitle>Profile</ActionTitle>
</ActionItem>
<ActionsList>

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, forwardRef } from 'react';
import dayjs from 'dayjs';
import moment from 'moment';
import styled from 'styled-components';
import DatePicker from 'react-datepicker';
import _ from 'lodash';
@ -110,15 +110,19 @@ const HeaderActions = styled.div`
`;
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
const now = dayjs();
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
const now = moment();
const [textStartDate, setTextStartDate] = useState(now.format('YYYY-MM-DD'));
const [startDate, setStartDate] = useState(new Date());
useEffect(() => {
const newDate = dayjs(startDate).format('YYYY-MM-DD');
setValue('endDate', newDate);
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',
@ -134,8 +138,9 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
'November',
'December',
];
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
const saveDueDate = (data: any) => {
const newDate = dayjs(`${data.endDate} ${dayjs(data.endTime).format('h:mm A')}`, 'YYYY-MM-DD h:mm A');
const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A');
if (newDate.isValid()) {
onDueDateChange(task, newDate.toDate());
}
@ -144,12 +149,11 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
return (
<DueDateInput
id="endTime"
value={value}
name="endTime"
ref={$ref}
width="100%"
variant="alternate"
label="Time"
label="Date"
onClick={onClick}
/>
);
@ -164,7 +168,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
width="100%"
variant="alternate"
label="Date"
defaultValue={now.format('YYYY-MM-DD')}
defaultValue={textStartDate}
ref={register({
required: 'End date is required.',
})}
@ -173,7 +177,6 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
<FormField>
<Controller
control={control}
defaultValue={now.toDate()}
name="endTime"
render={({ onChange, onBlur, value }) => (
<DatePicker
@ -241,9 +244,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
selected={startDate}
inline
onChange={date => {
if (date) {
setStartDate(date);
}
setStartDate(date ?? new Date());
}}
/>
</DueDatePickerWrapper>

View File

@ -35,7 +35,7 @@ export const Default = () => {
<Wrapper>
<Input label="Label placeholder" />
<Input width="100%" placeholder="Placeholder" />
<Input icon={<User width={20} height={20} />} width="100%" placeholder="Placeholder" />
<Input icon={<User size={20} />} width="100%" placeholder="Placeholder" />
</Wrapper>
</ThemeProvider>
</>

View File

@ -78,7 +78,6 @@ const Icon = styled.div`
type InputProps = {
variant?: 'normal' | 'alternate';
disabled?: boolean;
label?: string;
width?: string;
floatingLabel?: boolean;
@ -92,7 +91,6 @@ type InputProps = {
name?: string;
className?: string;
defaultValue?: string;
value?: string;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
};
@ -117,7 +115,6 @@ function useCombinedRefs(...refs: any) {
const Input = React.forwardRef(
(
{
disabled = false,
width = 'auto',
variant = 'normal',
type = 'text',
@ -132,7 +129,6 @@ const Input = React.forwardRef(
onClick,
floatingLabel,
defaultValue,
value,
id,
}: InputProps,
$ref: any,
@ -162,7 +158,6 @@ const Input = React.forwardRef(
onChange={e => {
setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false);
}}
disabled={disabled}
hasValue={hasValue}
ref={combinedRef}
id={id}
@ -171,7 +166,6 @@ const Input = React.forwardRef(
onClick={onClick}
autoComplete={autocomplete ? 'on' : 'off'}
defaultValue={defaultValue}
value={value}
hasIcon={typeof icon !== 'undefined'}
width={width}
placeholder={placeholder}

View File

@ -73,6 +73,7 @@ export const HeaderName = styled(TextareaAutosize)`
box-shadow: none;
font-weight: 600;
margin: -4px 0;
padding: 4px 8px;
letter-spacing: normal;
word-spacing: normal;

View File

@ -102,7 +102,7 @@ const List = React.forwardRef(
{children && children}
<AddCardContainer hidden={isComposerOpen}>
<AddCardButton onClick={() => onOpenComposer(id)}>
<Plus width={12} height={12} />
<Plus size={12} color="#c2c6dc" />
<AddCardButtonText>Add another card</AddCardButtonText>
</AddCardButton>
</AddCardContainer>

View File

@ -0,0 +1,18 @@
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')} />;
};

View File

@ -1,100 +1,50 @@
import React from 'react';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { NameEditor } from 'shared/components/AddList';
import NOOP from 'shared/utils/noop';
import styled from 'styled-components';
import { TaskSorting, TaskSortingDirection, TaskSortingType } from 'shared/utils/sorting';
import { InnerContent, ListActionsWrapper, ListActionItemWrapper, ListActionItem, ListSeparator } from './Styles';
const CopyWrapper = styled.div`
margin: 0 12px;
`;
type Props = {
taskGroupID: string;
onDuplicateTaskGroup: (newTaskGroupName: string) => void;
onDeleteTaskGroupTasks: () => void;
onArchiveTaskGroup: (taskGroupID: string) => void;
onSortTaskGroup: (taskSorting: TaskSorting) => void;
};
const LabelManager: React.FC<Props> = ({
taskGroupID,
onDeleteTaskGroupTasks,
onDuplicateTaskGroup,
onArchiveTaskGroup,
onSortTaskGroup,
}) => {
const { setTab } = usePopup();
onArchiveTaskGroup: (taskGroupID: string) => void;
};
const LabelManager: React.FC<Props> = ({ taskGroupID, onArchiveTaskGroup }) => {
return (
<>
<Popup tab={0} title={null}>
<InnerContent>
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => setTab(1)}>
<ListActionItem>Duplicate</ListActionItem>
<ListActionItemWrapper>
<ListActionItem>Add card...</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper onClick={() => setTab(2)}>
<ListActionItem>Sort</ListActionItem>
<ListActionItemWrapper>
<ListActionItem>Copy List...</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper>
<ListActionItem>Move card...</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper>
<ListActionItem>Watch</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
<ListSeparator />
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => onDeleteTaskGroupTasks()}>
<ListActionItem>Delete All Tasks</ListActionItem>
<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>Delete</ListActionItem>
<ListActionItem>Archive This List</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
</InnerContent>
</Popup>
<Popup tab={1} title="Copy list" onClose={NOOP}>
<CopyWrapper>
<NameEditor
onCancel={NOOP}
onSave={listName => {
onDuplicateTaskGroup(listName);
}}
buttonLabel="Duplicate"
/>
</CopyWrapper>
</Popup>
<Popup tab={2} title="Sort list" onClose={NOOP}>
<InnerContent>
<ListActionsWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Task title</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Due date</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.COMPLETE, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Complete</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.LABELS, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Labels</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.MEMBERS, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Members</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
</InnerContent>
</Popup>
</>
);
};
export default LabelManager;

View File

@ -4,8 +4,10 @@ export const Container = styled.div`
flex: 1;
user-select: none;
white-space: nowrap;
margin-bottom: 8px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 8px;
::-webkit-scrollbar {
height: 10px;
@ -33,9 +35,10 @@ export const BoardWrapper = styled.div`
user-select: none;
white-space: nowrap;
margin-bottom: 8px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 4px;
padding-bottom: 8px;
position: absolute;
top: 0;
right: 0;

View File

@ -10,132 +10,9 @@ import {
getNewDraggablePosition,
getAfterDropDraggableList,
} from 'shared/utils/draggables';
import dayjs from 'dayjs';
import { TaskSorting, TaskSortingType, TaskSortingDirection, sortTasks } from 'shared/utils/sorting';
import moment from 'moment';
import { Container, BoardContainer, BoardWrapper } from './Styles';
import shouldMetaFilter from './metaFilter';
export enum TaskMeta {
NONE,
TITLE,
MEMBER,
LABEL,
DUE_DATE,
}
export enum TaskMetaMatch {
MATCH_ANY,
MATCH_ALL,
}
export enum TaskStatus {
ALL,
COMPLETE,
INCOMPLETE,
}
export enum TaskSince {
ALL,
TODAY,
YESTERDAY,
ONE_WEEK,
TWO_WEEKS,
THREE_WEEKS,
}
export type TaskStatusFilter = {
status: TaskStatus;
since: TaskSince;
};
export interface TaskMetaFilterName {
meta: TaskMeta;
value?: string | dayjs.Dayjs | null;
id?: string | null;
}
export type TaskNameMetaFilter = {
name: string;
};
export enum DueDateFilterType {
TODAY,
TOMORROW,
THIS_WEEK,
NEXT_WEEK,
ONE_WEEK,
TWO_WEEKS,
THREE_WEEKS,
OVERDUE,
NO_DUE_DATE,
}
export type DueDateMetaFilter = {
type: DueDateFilterType;
label: string;
};
export type MemberMetaFilter = {
id: string;
username: string;
};
export type LabelMetaFilter = {
id: string;
name: string;
color: string;
};
export type TaskMetaFilters = {
match: TaskMetaMatch;
dueDate: DueDateMetaFilter | null;
taskName: TaskNameMetaFilter | null;
members: Array<MemberMetaFilter>;
labels: Array<LabelMetaFilter>;
};
function shouldStatusFilter(task: Task, filter: TaskStatusFilter) {
if (filter.status === TaskStatus.ALL) {
return true;
}
if (filter.status === TaskStatus.INCOMPLETE && task.complete === false) {
return true;
}
if (filter.status === TaskStatus.COMPLETE && task.completedAt && task.complete === true) {
const completedAt = dayjs(task.completedAt);
const REFERENCE = dayjs();
switch (filter.since) {
case TaskSince.TODAY:
const TODAY = REFERENCE.clone().startOf('day');
return completedAt.isSame(TODAY, 'd');
case TaskSince.YESTERDAY:
const YESTERDAY = REFERENCE.clone()
.subtract(1, 'day')
.startOf('day');
return completedAt.isSameOrAfter(YESTERDAY, 'd');
case TaskSince.ONE_WEEK:
const ONE_WEEK = REFERENCE.clone()
.subtract(7, 'day')
.startOf('day');
return completedAt.isSameOrAfter(ONE_WEEK, 'd');
case TaskSince.TWO_WEEKS:
const TWO_WEEKS = REFERENCE.clone()
.subtract(14, 'day')
.startOf('day');
return completedAt.isSameOrAfter(TWO_WEEKS, 'd');
case TaskSince.THREE_WEEKS:
const THREE_WEEKS = REFERENCE.clone()
.subtract(21, 'day')
.startOf('day');
return completedAt.isSameOrAfter(THREE_WEEKS, 'd');
default:
return true;
}
}
return false;
}
interface SimpleProps {
taskGroups: Array<TaskGroup>;
@ -151,29 +28,8 @@ interface SimpleProps {
onCardMemberClick: OnCardMemberClick;
onCardLabelClick: () => void;
cardLabelVariant: CardLabelVariant;
taskStatusFilter?: TaskStatusFilter;
taskMetaFilters?: TaskMetaFilters;
taskSorting?: TaskSorting;
}
const initTaskStatusFilter: TaskStatusFilter = {
status: TaskStatus.ALL,
since: TaskSince.ALL,
};
const initTaskMetaFilters: TaskMetaFilters = {
match: TaskMetaMatch.MATCH_ANY,
dueDate: null,
taskName: null,
labels: [],
members: [],
};
const initTaskSorting: TaskSorting = {
type: TaskSortingType.NONE,
direction: TaskSortingDirection.ASC,
};
const SimpleLists: React.FC<SimpleProps> = ({
taskGroups,
onTaskDrop,
@ -187,9 +43,6 @@ const SimpleLists: React.FC<SimpleProps> = ({
cardLabelVariant,
onExtraMenuOpen,
onCardMemberClick,
taskStatusFilter = initTaskStatusFilter,
taskMetaFilters = initTaskMetaFilters,
taskSorting = initTaskSorting,
}) => {
const onDragEnd = ({ draggableId, source, destination, type }: DropResult) => {
if (typeof destination === 'undefined') return;
@ -311,18 +164,10 @@ const SimpleLists: React.FC<SimpleProps> = ({
<ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}>
{taskGroup.tasks
.slice()
.filter(t => shouldStatusFilter(t, taskStatusFilter))
.filter(t => shouldMetaFilter(t, taskMetaFilters))
.sort((a: any, b: any) => a.position - b.position)
.sort((a: any, b: any) => sortTasks(a, b, taskSorting))
.map((task: Task, taskIndex: any) => {
return (
<Draggable
key={task.id}
draggableId={task.id}
index={taskIndex}
isDragDisabled={taskSorting.type !== TaskSortingType.NONE}
>
<Draggable key={task.id} draggableId={task.id} index={taskIndex}>
{taskProvided => {
return (
<Card
@ -353,7 +198,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
task.dueDate
? {
isPastDue: false,
formattedDate: dayjs(task.dueDate).format('MMM D, YYYY'),
formattedDate: moment(task.dueDate).format('MMM D, YYYY'),
}
: undefined
}

View File

@ -1,132 +0,0 @@
import { TaskMetaFilters, DueDateFilterType } from 'shared/components/Lists';
import dayjs from 'dayjs';
enum ShouldFilter {
NO_FILTER,
VALID,
NOT_VALID,
}
function shouldFilter(cond: boolean) {
return cond ? ShouldFilter.VALID : ShouldFilter.NOT_VALID;
}
export default function shouldMetaFilter(task: Task, filters: TaskMetaFilters) {
let isFiltered = ShouldFilter.NO_FILTER;
if (filters.taskName) {
isFiltered = shouldFilter(task.name.toLowerCase().startsWith(filters.taskName.name.toLowerCase()));
}
if (filters.dueDate) {
if (isFiltered === ShouldFilter.NO_FILTER) {
isFiltered = ShouldFilter.NOT_VALID;
}
if (filters.dueDate.type === DueDateFilterType.NO_DUE_DATE) {
isFiltered = shouldFilter(!(task.dueDate && task.dueDate !== null));
}
if (task.dueDate) {
const taskDueDate = dayjs(task.dueDate);
const today = dayjs();
let start;
let end;
switch (filters.dueDate.type) {
case DueDateFilterType.OVERDUE:
isFiltered = shouldFilter(taskDueDate.isBefore(today));
break;
case DueDateFilterType.TODAY:
isFiltered = shouldFilter(taskDueDate.isSame(today, 'day'));
break;
case DueDateFilterType.TOMORROW:
isFiltered = shouldFilter(
taskDueDate.isBefore(
today
.clone()
.add(1, 'day')
.endOf('day'),
),
);
break;
case DueDateFilterType.THIS_WEEK:
start = today
.clone()
.weekday(0)
.startOf('day');
end = today
.clone()
.weekday(6)
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
case DueDateFilterType.NEXT_WEEK:
start = today
.clone()
.weekday(0)
.add(7, 'day')
.startOf('day');
end = today
.clone()
.weekday(6)
.add(7, 'day')
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
case DueDateFilterType.ONE_WEEK:
start = today.clone().startOf('day');
end = today
.clone()
.add(7, 'day')
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
case DueDateFilterType.TWO_WEEKS:
start = today.clone().startOf('day');
end = today
.clone()
.add(14, 'day')
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
case DueDateFilterType.THREE_WEEKS:
start = today.clone().startOf('day');
end = today
.clone()
.add(21, 'day')
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
default:
isFiltered = ShouldFilter.NOT_VALID;
}
}
}
if (filters.members.length !== 0) {
if (isFiltered === ShouldFilter.NO_FILTER) {
isFiltered = ShouldFilter.NOT_VALID;
}
for (const member of filters.members) {
if (task.assigned) {
if (task.assigned.findIndex(m => m.id === member.id) !== -1) {
isFiltered = ShouldFilter.VALID;
}
}
}
}
if (filters.labels.length !== 0) {
if (isFiltered === ShouldFilter.NO_FILTER) {
isFiltered = ShouldFilter.NOT_VALID;
}
for (const label of filters.labels) {
if (task.labels) {
if (task.labels.findIndex(m => m.projectLabel.id === label.id) !== -1) {
isFiltered = ShouldFilter.VALID;
}
}
}
}
if (isFiltered === ShouldFilter.NO_FILTER) {
return true;
}
if (isFiltered === ShouldFilter.VALID) {
return true;
}
return false;
}

View File

@ -1,24 +0,0 @@
import React from 'react';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import LoadingSpinner from '.';
export default {
component: LoadingSpinner,
title: 'Login',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#cdd3e1', default: true },
],
},
};
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<LoadingSpinner />
</>
);
};

View File

@ -1,42 +0,0 @@
import styled, { keyframes } from 'styled-components';
const LoadingSpinnerKeyframes = keyframes`
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
`;
export const LoadingSpinnerWrapper = styled.div<{ color: string; size: string; borderSize: string; thickness: string }>`
display: inline-block;
position: relative;
width: ${props => props.borderSize};
height: ${props => props.borderSize};
& > div {
box-sizing: border-box;
display: block;
position: absolute;
width: ${props => props.size};
height: ${props => props.size};
margin: ${props => props.thickness};
border: ${props => props.thickness} solid rgba(${props => props.theme.colors[props.color]});
border-radius: 50%;
animation: 1.2s ${LoadingSpinnerKeyframes} cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: rgba(${props => props.theme.colors[props.color]}) transparent transparent transparent;
}
& > div:nth-child(1) {
animation-delay: -0.45s;
}
& > div:nth-child(2) {
animation-delay: -0.3s;
}
& > div:nth-child(3) {
animation-delay: -0.15s;
}
`;
export default LoadingSpinnerWrapper;

View File

@ -1,41 +0,0 @@
import React from 'react';
import { LoadingSpinnerWrapper} from './Styles';
type LoadingSpinnerProps = {
color?: 'primary' | 'danger' | 'success' | 'warning' | 'dark';
size?: string;
borderSize?: string;
thickness?: string;
};
/**
* The default parameters may not be applicable to every scenario
*
* While borderSize and size should be a single prop,
* it is currently not as such because it would require math to be done to strings
* e.g "80px - 16"
*
*
* @param color
* @param size The size of the spinner. It is recommended to be at least 16 px less than the borderSize
* @param thickness
* @param borderSize Border size affects the size of the border which if is too small may break the spinner.
* @constructor
*/
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
color = 'primary',
size = '64px',
thickness = '8px',
borderSize = '80px',
}) => {
return (
<LoadingSpinnerWrapper color={color} size={size} thickness={thickness} borderSize={borderSize}>
<div />
<div />
<div />
</LoadingSpinnerWrapper>
);
};
export default LoadingSpinner;

View File

@ -3,7 +3,6 @@ import AccessAccount from 'shared/undraw/AccessAccount';
import { User, Lock, Taskcafe } from 'shared/icons';
import { useForm } from 'react-hook-form';
import LoadingSpinner from 'shared/components/LoadingSpinner';
import {
Form,
LogoWrapper,
@ -54,7 +53,7 @@ const Login = ({ onSubmit }: LoginProps) => {
ref={register({ required: 'Username is required' })}
/>
<FormIcon>
<User width={20} height={20} />
<User color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.username && <FormError>{errors.username.message}</FormError>}
@ -74,7 +73,6 @@ const Login = ({ onSubmit }: LoginProps) => {
<ActionButtons>
<RegisterButton variant="outline">Register</RegisterButton>
{!isComplete && <LoadingSpinner size="32px" thickness="2px" borderSize="48px" />}
<LoginButton type="submit" disabled={!isComplete}>
Login
</LoginButton>

View File

@ -44,7 +44,6 @@ type MemberProps = {
showName?: boolean;
className?: string;
showCheckmark?: boolean;
size?: number;
};
const CardMemberWrapper = styled.div<{ ref: any }>`
@ -64,7 +63,6 @@ const Member: React.FC<MemberProps> = ({
showName,
showCheckmark = false,
className,
size = 28,
}) => {
const $targetRef = useRef<HTMLDivElement>();
return (
@ -79,7 +77,7 @@ const Member: React.FC<MemberProps> = ({
}
}}
>
<TaskAssignee onMemberProfile={NOOP} size={32} member={member} />
<TaskAssignee onMemberProfile={NOOP} size={28} member={member} />
{showName && <CardMemberName>{member.fullName}</CardMemberName>}
{showCheckmark && <CardCheckmark width={12} height={12} />}
</CardMemberWrapper>

View File

@ -47,7 +47,6 @@ const permissions = [
type MiniProfileProps = {
bio: string;
user: TaskUser;
invited?: boolean;
onRemoveFromTask?: () => void;
onChangeRole?: (roleCode: RoleCode) => void;
onRemoveFromBoard?: () => void;
@ -57,7 +56,6 @@ type MiniProfileProps = {
const MiniProfile: React.FC<MiniProfileProps> = ({
user,
bio,
invited,
canChangeRole,
onRemoveFromTask,
onChangeRole,
@ -76,7 +74,7 @@ const MiniProfile: React.FC<MiniProfileProps> = ({
)}
<ProfileInfo>
<InfoTitle>{user.fullName}</InfoTitle>
{invited ? <InfoUsername>Invited</InfoUsername> : <InfoUsername>{`@${user.username}`}</InfoUsername>}
<InfoUsername>{`@${user.username}`}</InfoUsername>
<InfoBio>{bio}</InfoBio>
</ProfileInfo>
</Profile>

View File

@ -15,20 +15,18 @@ export const ScrollOverlay = styled.div`
export const ClickableOverlay = styled.div`
min-height: 100%;
background: rgba(0, 0, 0, 0.4);
display: flex;
justify-content: center;
`;
export const StyledModal = styled.div<{ width: number; height: number }>`
export const StyledModal = styled.div<{ width: number }>`
display: inline-block;
position: relative;
width: ${props => props.width}px;
height: ${props => props.height}px;
left: 0;
right: 0;
top: 48px;
bottom: 16px;
margin: auto;
margin: 48px 0 80px;
width: 100%;
background: #262c49;
max-width: ${props => props.width}px;
vertical-align: middle;
border-radius: 6px;
border-radius: 3px;
${mixin.boxShadowMedium}
`;

View File

@ -1,10 +1,9 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useRef } from 'react';
import ReactDOM from 'react-dom';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import useWindowSize from 'shared/hooks/useWindowSize';
import styled from 'styled-components';
import { Cross } from 'shared/icons';
import { ScrollOverlay, ClickableOverlay, StyledModal } from './Styles';
const $root: HTMLElement = document.getElementById('root')!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
@ -15,50 +14,21 @@ type ModalProps = {
renderContent: () => JSX.Element;
};
function getAdjustedHeight(height: number) {
if (height >= 900) {
return height - 150;
}
if (height >= 800) {
return height - 125;
}
return height - 70;
}
const CloseIcon = styled(Cross)`
position: absolute;
top: 16px;
right: -32px;
cursor: pointer;
fill: rgba(${props => props.theme.colors.text.primary});
&:hover {
fill: rgba(${props => props.theme.colors.text.secondary});
}
`;
const InnerModal: React.FC<ModalProps> = ({ width, onClose: tellParentToClose, renderContent }) => {
const Modal: React.FC<ModalProps> = ({ width, onClose: tellParentToClose, renderContent }) => {
const $modalRef = useRef<HTMLDivElement>(null);
const $clickableOverlayRef = useRef<HTMLDivElement>(null);
const [_width, height] = useWindowSize();
useOnOutsideClick($modalRef, true, tellParentToClose, $clickableOverlayRef);
useOnEscapeKeyDown(true, tellParentToClose);
return (
return ReactDOM.createPortal(
<ScrollOverlay>
<ClickableOverlay ref={$clickableOverlayRef}>
<StyledModal width={width} height={getAdjustedHeight(height)} ref={$modalRef}>
<StyledModal width={width} ref={$modalRef}>
{renderContent()}
<CloseIcon onClick={() => tellParentToClose()} width={20} height={20} />
</StyledModal>
</ClickableOverlay>
</ScrollOverlay>
);
};
const Modal: React.FC<ModalProps> = ({ width, onClose: tellParentToClose, renderContent }) => {
return ReactDOM.createPortal(
<InnerModal width={width} onClose={tellParentToClose} renderContent={renderContent} />,
</ScrollOverlay>,
$root,
);
};

View File

@ -217,13 +217,13 @@ type NewProjectProps = {
initialTeamID: string | null;
teams: Array<Team>;
onClose: () => void;
onCreateProject: (projectName: string, teamID: string | null) => void;
onCreateProject: (projectName: string, teamID: string) => void;
};
const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose, onCreateProject }) => {
const [projectName, setProjectName] = useState('');
const [team, setTeam] = useState<null | string>(initialTeamID);
const options = [{ label: 'No team', value: 'no-team' }, ...teams.map(t => ({ label: t.name, value: t.id }))];
const options = teams.map(t => ({ label: t.name, value: t.id }));
return (
<Overlay>
<Content>
@ -271,8 +271,8 @@ const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose,
</ProjectInfo>
<CreateButton
onClick={() => {
if (projectName !== '') {
onCreateProject(projectName, team === 'no-team' ? null : team);
if (team && projectName !== '') {
onCreateProject(projectName, team);
}
}}
>

View File

@ -1,111 +0,0 @@
import React from 'react';
import styled from 'styled-components';
import TimeAgo from 'react-timeago';
import { Popup } from 'shared/components/PopupMenu';
const ItemWrapper = styled.div`
cursor: pointer;
border-bottom: 1px solid #414561;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1rem;
padding-bottom: 1rem;
justify-content: space-between;
display: flex;
&:hover {
background: #10163a;
}
`;
const ItemWrapperContent = styled.div`
display: flex;
align-items: flex-start;
`;
const ItemIconContainer = styled.span`
position: relative;
display: inline-flex;
align-items: center;
`;
const ItemTextContainer = styled.div`
margin-left: 0.5rem;
margin-right: 0.5rem;
`;
const ItemTextTitle = styled.span`
font-weight: 500;
display: block;
color: rgba(${props => props.theme.colors.primary});
font-size: 14px;
`;
const ItemTextDesc = styled.span`
font-size: 12px;
`;
const ItemTimeAgo = styled.span`
margin-top: 0.25rem;
white-space: nowrap;
font-size: 11px;
`;
type NotificationItemProps = {
title: string;
description: string;
createdAt: string;
};
export const NotificationItem: React.FC<NotificationItemProps> = ({ title, description, createdAt }) => {
return (
<ItemWrapper>
<ItemWrapperContent>
<ItemIconContainer />
<ItemTextContainer>
<ItemTextTitle>{title}</ItemTextTitle>
<ItemTextDesc>{description}</ItemTextDesc>
</ItemTextContainer>
</ItemWrapperContent>
<TimeAgo date={createdAt} component={ItemTimeAgo} />
</ItemWrapper>
);
};
const NotificationHeader = styled.div`
padding: 0.75rem;
text-align: center;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
background: rgba(${props => props.theme.colors.primary});
`;
const NotificationHeaderTitle = styled.span`
font-size: 14px;
color: rgba(${props => props.theme.colors.text.secondary});
`;
const NotificationFooter = styled.div`
cursor: pointer;
padding: 0.5rem;
text-align: center;
color: rgba(${props => props.theme.colors.primary});
&:hover {
background: #10163a;
}
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
`;
const NotificationPopup: React.FC = ({ children }) => {
return (
<Popup title={null} tab={0} borders={false} padding={false}>
<NotificationHeader>
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
</NotificationHeader>
<ul>{children}</ul>
<NotificationFooter>View All</NotificationFooter>
</Popup>
);
};
export default NotificationPopup;

View File

@ -5,7 +5,6 @@ import ControlledInput from 'shared/components/ControlledInput';
export const Container = styled.div<{
invertY: boolean;
invert: boolean;
targetPadding: string;
top: number;
left: number;
ref: any;
@ -16,7 +15,7 @@ export const Container = styled.div<{
display: block;
position: absolute;
width: ${props => props.width}px;
padding-top: ${props => props.targetPadding};
padding-top: 10px;
height: auto;
z-index: 40000;
${props =>
@ -29,18 +28,14 @@ export const Container = styled.div<{
css`
top: auto;
padding-top: 0;
padding-bottom: ${props.targetPadding};
padding-bottom: 10px;
bottom: ${props.top}px;
`}
`;
export const Wrapper = styled.div<{ padding: boolean; borders: boolean }>`
${props =>
props.padding &&
css`
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;
@ -48,12 +43,8 @@ export const Wrapper = styled.div<{ padding: boolean; borders: boolean }>`
color: #c2c6dc;
background: #262c49;
${props =>
props.borders &&
css`
border: 1px solid rgba(0, 0, 0, 0.1);
border-color: #414561;
`}
`;
export const Header = styled.div`
@ -335,7 +326,7 @@ export const PreviousButton = styled.div`
cursor: pointer;
`;
export const ContainerDiamond = styled.div<{ borders: boolean; color: string; invert: boolean; invertY: boolean }>`
export const ContainerDiamond = styled.div<{ invert: boolean; invertY: boolean }>`
${props => (props.invert ? 'right: 10px; ' : 'left: 15px;')}
position: absolute;
width: 10px;
@ -356,10 +347,6 @@ export const ContainerDiamond = styled.div<{ borders: boolean; color: string; in
transform: rotate(45deg) translate(-7px);
z-index: 10;
background: ${props => props.color};
${props =>
props.borders &&
css`
background: #262c49;
border-color: #414561;
`}
`;

View File

@ -15,37 +15,9 @@ import {
Wrapper,
} from './Styles';
function getPopupOptions(options?: PopupOptions) {
const popupOptions = {
borders: true,
diamondColor: '#262c49',
targetPadding: '10px',
showDiamond: true,
width: 316,
};
if (options) {
if (options.borders) {
popupOptions.borders = options.borders;
}
if (options.width) {
popupOptions.width = options.width;
}
if (options.targetPadding) {
popupOptions.targetPadding = options.targetPadding;
}
if (typeof options.showDiamond !== 'undefined' && options.showDiamond !== null) {
popupOptions.showDiamond = options.showDiamond;
}
if (options.diamondColor) {
popupOptions.diamondColor = options.diamondColor;
}
}
return popupOptions;
}
type PopupContextState = {
show: (target: RefObject<HTMLElement>, content: JSX.Element, options?: PopupOptions) => void;
setTab: (newTab: number, options?: PopupOptions) => void;
show: (target: RefObject<HTMLElement>, content: JSX.Element, width?: string | number) => void;
setTab: (newTab: number, width?: number | string) => void;
getCurrentTab: () => number;
hide: () => void;
};
@ -54,44 +26,23 @@ type PopupProps = {
title: string | null;
onClose?: () => void;
tab: number;
padding?: boolean;
borders?: boolean;
diamondColor?: string;
};
type PopupContainerProps = {
top: number;
left: number;
invert: boolean;
targetPadding: string;
invertY: boolean;
onClose: () => void;
width?: string | number;
};
const PopupContainer: React.FC<PopupContainerProps> = ({
width,
top,
left,
onClose,
children,
invert,
invertY,
targetPadding,
}) => {
const PopupContainer: React.FC<PopupContainerProps> = ({ width, top, left, onClose, children, invert, invertY }) => {
const $containerRef = useRef<HTMLDivElement>(null);
const [currentTop, setCurrentTop] = useState(top);
useOnOutsideClick($containerRef, true, onClose, null);
return (
<Container
targetPadding={targetPadding}
width={width ?? 316}
left={left}
top={currentTop}
ref={$containerRef}
invert={invert}
invertY={invertY}
>
<Container width={width ?? 316} left={left} top={currentTop} ref={$containerRef} invert={invert} invertY={invertY}>
{children}
</Container>
);
@ -122,28 +73,13 @@ type PopupState = {
currentTab: number;
previousTab: number;
content: JSX.Element | null;
options: PopupOptionsInternal | null;
width?: string | number;
};
const { Provider, Consumer } = PopupContext;
const canUseDOM = !!(typeof window !== 'undefined' && window.document && window.document.createElement);
type PopupOptionsInternal = {
width: number;
borders: boolean;
targetPadding: string;
diamondColor: string;
showDiamond: boolean;
};
type PopupOptions = {
targetPadding?: string | null;
showDiamond?: boolean | null;
width?: number | null;
borders?: boolean | null;
diamondColor?: string | null;
};
const defaultState = {
isOpen: false,
left: 0,
@ -153,12 +89,11 @@ const defaultState = {
currentTab: 0,
previousTab: 0,
content: null,
options: null,
};
export const PopupProvider: React.FC = ({ children }) => {
const [currentState, setState] = useState<PopupState>(defaultState);
const show = (target: RefObject<HTMLElement>, content: JSX.Element, options?: PopupOptions) => {
const show = (target: RefObject<HTMLElement>, content: JSX.Element, width?: number | string) => {
if (target && target.current) {
const bounds = target.current.getBoundingClientRect();
let top = bounds.top + bounds.height;
@ -167,7 +102,6 @@ export const PopupProvider: React.FC = ({ children }) => {
top = window.innerHeight - bounds.top;
invertY = true;
}
const popupOptions = getPopupOptions(options);
if (bounds.left + 304 + 30 > window.innerWidth) {
setState({
isOpen: true,
@ -178,7 +112,7 @@ export const PopupProvider: React.FC = ({ children }) => {
currentTab: 0,
previousTab: 0,
content,
options: popupOptions,
width: width ?? 316,
});
} else {
setState({
@ -190,7 +124,7 @@ export const PopupProvider: React.FC = ({ children }) => {
currentTab: 0,
previousTab: 0,
content,
options: popupOptions,
width: width ?? 316,
});
}
}
@ -205,21 +139,20 @@ export const PopupProvider: React.FC = ({ children }) => {
currentTab: 0,
previousTab: 0,
content: null,
options: null,
});
};
const portalTarget = canUseDOM ? document.body : null; // appease flow
const setTab = (newTab: number, options?: PopupOptions) => {
setState((prevState: PopupState) =>
produce(prevState, draftState => {
draftState.previousTab = currentState.currentTab;
draftState.currentTab = newTab;
if (options) {
draftState.options = getPopupOptions(options);
}
}),
);
const setTab = (newTab: number, width?: number | string) => {
const newWidth = width ?? currentState.width;
setState((prevState: PopupState) => {
return {
...prevState,
previousTab: currentState.currentTab,
currentTab: newTab,
width: newWidth,
};
});
};
const getCurrentTab = () => {
@ -230,26 +163,17 @@ export const PopupProvider: React.FC = ({ children }) => {
<Provider value={{ hide, show, setTab, getCurrentTab }}>
{portalTarget &&
currentState.isOpen &&
currentState.options &&
createPortal(
<PopupContainer
invertY={currentState.invertY}
invert={currentState.invert}
top={currentState.top}
targetPadding={currentState.options.targetPadding}
left={currentState.left}
onClose={() => setState(defaultState)}
width={currentState.options.width}
width={currentState.width ?? 316}
>
{currentState.content}
{currentState.options.showDiamond && (
<ContainerDiamond
color={currentState.options.diamondColor}
borders={currentState.options.borders}
invertY={currentState.invertY}
invert={currentState.invert}
/>
)}
<ContainerDiamond invertY={currentState.invertY} invert={currentState.invert} />
</PopupContainer>,
portalTarget,
)}
@ -273,16 +197,8 @@ const PopupMenu: React.FC<Props> = ({ width, title, top, left, onClose, noHeader
useOnOutsideClick($containerRef, true, onClose, null);
return (
<Container
targetPadding="10px"
invertY={false}
width={width ?? 316}
invert={false}
left={left}
top={top}
ref={$containerRef}
>
<Wrapper padding borders>
<Container invertY={false} width={width ?? 316} invert={false} left={left} top={top} ref={$containerRef}>
<Wrapper>
{onPrevious && (
<PreviousButton onClick={onPrevious}>
<AngleLeft color="#c2c6dc" />
@ -306,7 +222,7 @@ const PopupMenu: React.FC<Props> = ({ width, title, top, left, onClose, noHeader
);
};
export const Popup: React.FC<PopupProps> = ({ borders = true, padding = true, title, onClose, tab, children }) => {
export const Popup: React.FC<PopupProps> = ({ title, onClose, tab, children }) => {
const { getCurrentTab, setTab } = usePopup();
if (getCurrentTab() !== tab) {
return null;
@ -314,7 +230,7 @@ export const Popup: React.FC<PopupProps> = ({ borders = true, padding = true, ti
return (
<>
<Wrapper borders={borders} padding={padding}>
<Wrapper>
{tab > 0 && (
<PreviousButton
onClick={() => {

View File

@ -13,7 +13,7 @@ export const AddProjectItem: React.FC<AddProjectItemProps> = ({ onAddProject })
onAddProject();
}}
>
<Plus width={12} height={12} />
<Plus size={20} color="#c2c6dc" />
<AddProjectLabel>New Project</AddProjectLabel>
</AddProjectWrapper>
);

View File

@ -55,7 +55,7 @@ const Register = ({ onSubmit }: RegisterProps) => {
ref={register({ required: 'Full name is required' })}
/>
<FormIcon>
<User width={20} height={20} />
<User color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.username && <FormError>{errors.username.message}</FormError>}
@ -68,7 +68,7 @@ const Register = ({ onSubmit }: RegisterProps) => {
ref={register({ required: 'Username is required' })}
/>
<FormIcon>
<User width={20} height={20} />
<User color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.username && <FormError>{errors.username.message}</FormError>}
@ -84,7 +84,7 @@ const Register = ({ onSubmit }: RegisterProps) => {
})}
/>
<FormIcon>
<User width={20} height={20} />
<User color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.email && <FormError>{errors.email.message}</FormError>}
@ -103,7 +103,7 @@ const Register = ({ onSubmit }: RegisterProps) => {
})}
/>
<FormIcon>
<User width={20} height={20} />
<User color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.initials && <FormError>{errors.initials.message}</FormError>}

View File

@ -16,7 +16,7 @@ function getBackgroundColor(isDisabled: boolean, isSelected: boolean, isFocused:
return null;
}
export const colourStyles = {
const colourStyles = {
control: (styles: any, data: any) => {
return {
...styles,

View File

@ -27,7 +27,6 @@ export const Default = () => {
<BaseStyles />
<Settings
profile={profile}
onChangeUserInfo={action('change user info')}
onResetPassword={action('reset password')}
onProfileAvatarRemove={action('remove')}
onProfileAvatarChange={action('profile avatar change')}

View File

@ -10,11 +10,6 @@ const PasswordInput = styled(Input)`
margin-bottom: 0;
`;
const UserInfoInput = styled(Input)`
margin-top: 30px;
margin-bottom: 0;
`;
const FormError = styled.span`
font-size: 12px;
color: rgba(${props => props.theme.colors.warning});
@ -223,7 +218,7 @@ const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
}}
>
<TabNavItemButton active={active}>
<User width={20} height={20} />
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
<TabNavItemSpan>{name}</TabNavItemSpan>
</TabNavItemButton>
</TabNavItem>
@ -245,7 +240,6 @@ const SaveButton = styled(Button)`
type SettingsProps = {
onProfileAvatarChange: () => void;
onProfileAvatarRemove: () => void;
onChangeUserInfo: (data: UserInfoData, done: () => void) => void;
onResetPassword: (password: string, done: () => void) => void;
profile: TaskUser;
};
@ -306,93 +300,9 @@ const ResetPasswordTab: React.FC<ResetPasswordTabProps> = ({ onResetPassword })
);
};
type UserInfoData = {
full_name: string;
bio: string;
initials: string;
email: string;
};
type UserInfoTabProps = {
profile: TaskUser;
onProfileAvatarChange: () => void;
onProfileAvatarRemove: () => void;
onChangeUserInfo: (data: UserInfoData, done: () => void) => void;
};
const EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
const INITIALS_PATTERN = /^[a-zA-Z]{2,3}$/i;
const UserInfoTab: React.FC<UserInfoTabProps> = ({
profile,
onProfileAvatarRemove,
onProfileAvatarChange,
onChangeUserInfo,
}) => {
const [active, setActive] = useState(true);
const { register, handleSubmit, errors } = useForm<UserInfoData>();
const done = () => {
setActive(true);
};
return (
<>
<AvatarSettings
onProfileAvatarRemove={onProfileAvatarRemove}
onProfileAvatarChange={onProfileAvatarChange}
profile={profile.profileIcon}
/>
<form
onSubmit={handleSubmit(data => {
setActive(false);
onChangeUserInfo(data, done);
})}
>
<UserInfoInput
ref={register({ required: 'Full name is required' })}
name="full_name"
defaultValue={profile.fullName}
width="100%"
label="Name"
/>
{errors.full_name && <FormError>{errors.full_name.message}</FormError>}
<UserInfoInput
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
ref={register({
required: 'Initials is required',
pattern: { value: INITIALS_PATTERN, message: 'Intials must be between two to four characters' },
})}
name="initials"
width="100%"
label="Initials "
/>
{errors.initials && <FormError>{errors.initials.message}</FormError>}
<UserInfoInput disabled defaultValue={profile.username ?? ''} width="100%" label="Username " />
<UserInfoInput
width="100%"
name="email"
ref={register({
required: 'Email is required',
pattern: { value: EMAIL_PATTERN, message: 'Must be a valid email' },
})}
defaultValue={profile.email ?? ''}
label="Email"
/>
{errors.email && <FormError>{errors.email.message}</FormError>}
<UserInfoInput width="100%" name="bio" ref={register()} defaultValue={profile.bio ?? ''} label="Bio" />
{errors.bio && <FormError>{errors.bio.message}</FormError>}
<SettingActions>
<SaveButton disabled={!active} type="submit">
Save Change
</SaveButton>
</SettingActions>
</form>
</>
);
};
const Settings: React.FC<SettingsProps> = ({
onProfileAvatarRemove,
onProfileAvatarChange,
onChangeUserInfo,
onResetPassword,
profile,
}) => {
@ -405,7 +315,6 @@ const Settings: React.FC<SettingsProps> = ({
<TabNavContent>
{items.map((item, idx) => (
<NavItem
key={item.name}
onClick={(tab, top) => {
if ($tabNav && $tabNav.current) {
const pos = $tabNav.current.getBoundingClientRect();
@ -423,12 +332,23 @@ const Settings: React.FC<SettingsProps> = ({
</TabNav>
<TabContentWrapper>
<Tab tab={0} currentTab={currentTab}>
<UserInfoTab
onProfileAvatarChange={onProfileAvatarChange}
<AvatarSettings
onProfileAvatarRemove={onProfileAvatarRemove}
profile={profile}
onChangeUserInfo={onChangeUserInfo}
onProfileAvatarChange={onProfileAvatarChange}
profile={profile.profileIcon}
/>
<Input defaultValue={profile.fullName} width="100%" label="Name" />
<Input
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
width="100%"
label="Initials "
/>
<Input defaultValue={profile.username ?? ''} width="100%" label="Username " />
<Input width="100%" label="Email" />
<Input width="100%" label="Bio" />
<SettingActions>
<SaveButton>Save Change</SaveButton>
</SettingActions>
</Tab>
<Tab tab={1} currentTab={currentTab}>
<ResetPasswordTab onResetPassword={onResetPassword} />

View File

@ -1,5 +1,5 @@
import React, { useRef } from 'react';
import styled, { css } from 'styled-components';
import styled from 'styled-components';
import { DoubleChevronUp, Crown } from 'shared/icons';
export const AdminIcon = styled(DoubleChevronUp)`
@ -24,12 +24,7 @@ const TaskDetailAssignee = styled.div`
position: relative;
`;
export const Wrapper = styled.div<{
size: number | string;
bgColor: string | null;
backgroundURL: string | null;
hasClick: boolean;
}>`
export const Wrapper = styled.div<{ size: number | string; bgColor: string | null; backgroundURL: string | null }>`
width: ${props => props.size}px;
height: ${props => props.size}px;
border-radius: 9999px;
@ -42,60 +37,33 @@ export const Wrapper = styled.div<{
background-size: contain;
font-size: 14px;
font-weight: 400;
${props =>
props.hasClick &&
css`
&:hover {
opacity: 0.8;
}
`}
`;
type TaskAssigneeProps = {
size: number | string;
showRoleIcons?: boolean;
member: TaskUser;
invited?: boolean;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
onMemberProfile: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
className?: string;
};
const TaskAssignee: React.FC<TaskAssigneeProps> = ({
showRoleIcons,
member,
invited,
onMemberProfile,
size,
className,
}) => {
const TaskAssignee: React.FC<TaskAssigneeProps> = ({ showRoleIcons, member, onMemberProfile, size, className }) => {
const $memberRef = useRef<HTMLDivElement>(null);
let profileIcon: ProfileIcon = {
url: null,
bgColor: null,
initials: null,
};
if (member.profileIcon) {
profileIcon = member.profileIcon;
}
return (
<TaskDetailAssignee
className={className}
ref={$memberRef}
onClick={e => {
e.stopPropagation();
if (onMemberProfile) {
onMemberProfile($memberRef, member.id);
}
}}
key={member.id}
>
<Wrapper
hasClick={typeof onMemberProfile !== undefined}
backgroundURL={profileIcon.url ?? null}
bgColor={profileIcon.bgColor ?? null}
size={size}
>
{(!profileIcon.url && profileIcon.initials) ?? ''}
<Wrapper backgroundURL={member.profileIcon.url ?? null} bgColor={member.profileIcon.bgColor ?? null} size={size}>
{(!member.profileIcon.url && member.profileIcon.initials) ?? ''}
</Wrapper>
{showRoleIcons && member.role && member.role.code === 'admin' && <AdminIcon width={10} height={10} />}
{showRoleIcons && member.role && member.role.code === 'owner' && <OwnerIcon width={10} height={10} />}

View File

@ -1,365 +1,288 @@
import styled, { css } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea';
import styled from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea/lib';
import { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button';
import TaskAssignee from 'shared/components/TaskAssignee';
import { User, Trash, Paperclip } from 'shared/icons';
import Member from 'shared/components/Member';
export const Container = styled.div`
display: flex;
height: 100%;
`;
export const LeftSidebar = styled.div`
export const TaskHeader = styled.div`
padding: 21px 30px 0px;
margin-right: 70px;
display: flex;
flex-direction: column;
width: 300px;
background: #222740;
`;
export const MarkCompleteButton = styled.button<{ invert: boolean }>`
padding: 4px 8px;
export const TaskMeta = styled.div`
position: relative;
border: none;
cursor: pointer;
border-radius: ${props => props.theme.borderRadius.alternate};
font-size: 14px;
display: flex;
align-items: center;
background: transparent;
& span {
margin-left: 4px;
}
${props =>
props.invert
? css`
background: rgba(${props.theme.colors.success});
& svg {
fill: rgba(${props.theme.colors.text.secondary});
}
& span {
color: rgba(${props.theme.colors.text.secondary});
}
&:hover {
background: rgba(${props.theme.colors.success}, 0.8);
}
`
: css`
background: none;
border: 1px solid rgba(${props.theme.colors.text.secondary});
& svg {
fill: rgba(${props.theme.colors.text.secondary});
}
& span {
color: rgba(${props.theme.colors.text.secondary});
}
&:hover {
background: rgba(${props.theme.colors.success}, 0.08);
border: 1px solid rgba(${props.theme.colors.success});
}
&:hover svg {
fill: rgba(${props.theme.colors.success});
}
&:hover span {
color: rgba(${props.theme.colors.success});
}
`}
border-radius: 4px;
`;
export const LeftSidebarContent = styled.div`
padding-top: 32px;
display: flex;
flex-direction: column;
`;
export const LeftSidebarSection = styled.div`
display: flex;
flex-direction: column;
padding-left: 32px;
padding-right: 32px;
padding-bottom: 16px;
border-bottom: 1px solid #414561;
`;
export const SidebarTitle = styled.div`
font-size: 12px;
min-height: 24px;
margin-left: 8px;
color: rgba(${props => props.theme.colors.text.primary}, 0.75);
padding-top: 4px;
letter-spacing: 0.5px;
text-transform: uppercase;
`;
export const SidebarButton = styled.div`
export const TaskGroupLabel = styled.span`
color: #c2c6dc;
font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary});
min-height: 32px;
width: 100%;
`;
export const TaskGroupLabelName = styled.span`
color: #c2c6dc;
text-decoration: underline;
font-size: 14px;
`;
padding: 9px 8px 7px 8px;
border-color: transparent;
border-radius: 6px;
border-width: 1px;
border-style: solid;
export const TaskActions = styled.div`
position: absolute;
top: 0;
right: 0;
padding: 21px 18px 0px;
display: flex;
align-items: center;
`;
display: inline-block;
outline: 0;
export const TaskAction = styled.button`
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
cursor: pointer;
&:hover {
border-color: #414561;
}
padding: 0px 9px;
`;
export const SidebarButtonText = styled.span`
min-height: 16px;
flex: 1 1 auto;
export const TaskDetailsWrapper = styled.div`
display: flex;
justify-content: flex-start;
padding: 0px 16px 60px;
`;
export const ContentContainer = styled.div`
display: flex;
flex-direction: column;
export const TaskDetailsContent = styled.div`
flex: 1;
padding-top: 32px;
overflow: auto;
padding-right: 8px;
`;
export const HeaderContainer = styled.div`
flex: 0 0 auto;
padding: 0px 32px 0px 24px;
`;
export const HeaderInnerContainer = styled.div`
display: flex;
justify-content: space-between;
margin: 0 0 0 0;
padding: 0 0 0 4px;
`;
export const HeaderLeft = styled.div`
display: flex;
justify-content: flex-start;
export const TaskDetailsSidebar = styled.div`
width: 168px;
padding-left: 8px;
`;
export const TaskDetailsTitleWrapper = styled.div`
width: 100%;
margin: 8px 0 4px 0;
margin: 0 0 0 -8px;
display: inline-block;
`;
export const TaskDetailsTitle = styled(TextareaAutosize)`
padding: 9px 8px 7px 8px;
border-color: transparent;
border-radius: 6px;
border-width: 1px;
border-style: solid;
width: 100%;
color: #c2c6dc;
display: inline-block;
outline: 0;
line-height: 1.28;
resize: none;
box-shadow: transparent 0px 0px 0px 1px;
font-size: 24px;
font-weight: 700;
background: none;
&:hover {
border-color: #414561;
padding: 4px;
background: #262c49;
border-width: 1px;
border-style: solid;
border-color: transparent;
border-image: initial;
transition: background 0.1s ease 0s;
overflow-y: hidden;
width: 100%;
color: #c2c6dc;
&:focus {
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
background: ${mixin.darken('#262c49', 0.15)};
}
`;
export const TaskDetailsLabel = styled.div`
padding: 24px 0px 12px;
font-size: 15px;
font-weight: 600;
color: #c2c6dc;
`;
export const TaskDetailsAddDetailsButton = styled.div`
background: ${mixin.darken('#262c49', 0.15)};
box-shadow: none;
border: none;
border-radius: 3px;
display: block;
min-height: 56px;
padding: 8px 12px;
text-decoration: none;
font-size: 14px;
cursor: pointer;
color: #c2c6dc;
&:hover {
background: ${mixin.darken('#262c49', 0.25)};
box-shadow: none;
border: none;
}
`;
export const TaskDetailsEditorWrapper = styled.div`
display: block;
padding-bottom: 9px;
z-index: 50;
width: 100%;
`;
export const TaskDetailsEditor = styled(TextareaAutosize)`
width: 100%;
min-height: 108px;
color: #c2c6dc;
background: #262c49;
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
border-radius: 3px;
line-height: 20px;
padding: 8px 12px;
outline: none;
border: none;
&:focus {
border-color: rgba(${props => props.theme.colors.primary});
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
background: ${mixin.darken('#262c49', 0.05)};
}
`;
export const DueDateTitle = styled.div`
font-size: 12px;
min-height: 24px;
margin-left: 8px;
color: rgba(${props => props.theme.colors.text.primary}, 0.75);
padding-top: 8px;
letter-spacing: 0.5px;
text-transform: uppercase;
`;
export const AssignedUsersSection = styled.div`
padding-left: 32px;
padding-right: 32px;
padding-top: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #414561;
display: flex;
flex-direction: column;
`;
export const AssignUserIcon = styled.div`
export const TaskDetailsMarkdown = styled.div`
width: 100%;
cursor: pointer;
height: 32px;
width: 32px;
position: relative;
border-radius: 50%;
border: 1px dashed #414561;
margin-right: 8px;
display: flex;
flex-shrink: 0;
justify-content: center;
align-items: center;
&:hover {
border: 1px solid rgba(${props => props.theme.colors.text.secondary}, 0.75);
color: #c2c6dc;
h1 {
font-size: 24px;
font-weight: 600;
line-height: 28px;
margin: 0 0 12px;
}
&:hover svg {
fill: rgba(${props => props.theme.colors.text.secondary}, 0.75);
h2 {
font-weight: 600;
font-size: 20px;
line-height: 24px;
margin: 16px 0 8px;
}
`;
export const AssignUsersButton = styled.div`
cursor: pointer;
display: inline-flex;
flex: 1 1 auto;
height: 40px;
padding: 4px 16px 4px 8px;
margin-left: -1px;
border-radius: 6px;
align-items: center;
border: 1px solid transparent;
&:hover {
border: 1px solid ${mixin.darken('#414561', 0.15)};
p {
margin: 0 0 8px;
}
&:hover ${AssignUserIcon} {
border: 1px solid #414561;
strong {
font-weight: 700;
}
`;
export const AssignUserLabel = styled.span`
flex: 1 1 auto;
line-height: 15px;
color: rgba(${props => props.theme.colors.text.primary}, 0.75);
`;
export const ExtraActionsSection = styled.div`
padding-left: 32px;
padding-right: 32px;
padding-top: 24px;
display: flex;
flex-direction: column;
`;
export const ActionButtonsTitle = styled.h3`
color: rgba(${props => props.theme.colors.text.primary});
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
`;
export const ActionButton = styled(Button)`
margin-top: 8px;
margin-left: -10px;
padding: 8px 16px;
background: rgba(${props => props.theme.colors.bg.primary}, 0.5);
text-align: left;
transition: transform 0.2s ease;
& span {
justify-content: flex-start;
}
&:hover {
box-shadow: none;
transform: translateX(4px);
background: rgba(${props => props.theme.colors.bg.primary}, 0.75);
}
`;
export const HeaderRight = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
export const HeaderActionIcon = styled.div`
padding: 4px 4px 4px 4px;
margin: 0 4px 0 4px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
svg {
fill: rgba(${props => props.theme.colors.text.primary}, 0.75);
}
&:hover svg {
fill: rgba(${props => props.theme.colors.primary});
}
`;
export const EditorContainer = styled.div`
margin-left: 32px;
margin-right: 32px;
padding: 9px 8px 7px 8px;
border-color: transparent;
border-radius: 6px;
border-width: 1px;
border-style: solid;
outline: 0;
background: none;
ul {
list-style-type: disc;
margin: 8px 0;
}
ul.checkbox_list input[type='checkbox'] {
border-radius: 6px;
border-width: 1px;
border-style: solid;
border-color: #414561;
ul > li {
margin: 8px 8px 8px 24px;
list-style: disc;
}
p a {
color: rgba(${props => props.theme.colors.primary});
}
`;
export const InnerContentContainer = styled.div`
overflow: auto;
position: relative;
`;
export const DescriptionContainer = styled.div`
position: relative;
export const TaskDetailsControls = styled.div`
clear: both;
margin-top: 8px;
display: flex;
flex-direction: column;
`;
export const DescriptionActionButton = styled(Button)`
padding: 8px 16px;
export const ConfirmSave = styled(Button)`
padding: 6px 12px;
border-radius: 3px;
margin-right: 6px;
`;
export const Labels = styled.div`
export const CancelEdit = styled.div`
display: flex;
padding-left: 9px;
margin: 0 0 8px 0;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
cursor: pointer;
`;
export const MetaDetail = styled.div`
export const TaskDetailSectionTitle = styled.div`
text-transform: uppercase;
color: #c2c6dc;
font-size: 12.5px;
font-weight: 600;
margin: 24px 0px 5px;
`;
export const TaskDetailAssignees = styled.div`
display: flex;
flex-wrap: wrap;
align-items: center;
`;
export const TaskDetailAssignee = styled.div`
&:hover {
opacity: 0.8;
}
margin-right: 4px;
`;
export const ProfileIcon = styled.div`
width: 32px;
height: 32px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
background: rgb(115, 103, 240);
cursor: pointer;
`;
export const TaskDetailsAddMemberIcon = styled.div`
float: left;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
background: ${mixin.darken('#262c49', 0.15)};
cursor: pointer;
&:hover {
opacity: 0.8;
}
`;
export const TaskDetailLabels = styled.div`
display: flex;
align-items: center;
flex-wrap: wrap;
`;
export const TaskDetailLabel = styled.div<{ color: string }>`
&:hover {
opacity: 0.8;
}
background-color: ${props => props.color};
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
border-radius: 3px;
box-sizing: border-box;
display: block;
float: left;
margin: 0 16px 8px 0;
max-width: 100%;
font-weight: 600;
height: 32px;
line-height: 32px;
margin: 0 4px 4px 0;
min-width: 40px;
padding: 0 12px;
width: auto;
`;
export const MetaDetailTitle = styled.h3`
color: rgba(${props => props.theme.colors.text.primary});
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
margin-top: 16px;
text-transform: uppercase;
display: block;
line-height: 20px;
margin: 0 8px 4px 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
export const MetaDetailContent = styled.div`
display: flex;
`;
export const TaskDetailsAddLabel = styled.div`
border-radius: 3px;
background: ${mixin.darken('#262c49', 0.15)};
@ -384,231 +307,90 @@ export const TaskDetailsAddLabelIcon = styled.div`
}
`;
export const ChecklistSection = styled.div`
margin-top: 8px;
padding: 0 24px;
export const NoDueDateLabel = styled.span`
color: rgb(137, 147, 164);
font-size: 14px;
cursor: pointer;
`;
export const TaskDetailLabel = styled.div<{ color: string }>`
&:hover {
opacity: 0.8;
}
background-color: ${props => props.color};
color: #fff;
export const UnassignedLabel = styled.div`
color: rgb(137, 147, 164);
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
border-radius: 3px;
box-sizing: border-box;
display: block;
float: left;
font-weight: 600;
height: 32px;
line-height: 32px;
margin: 0 4px 0 0;
min-width: 40px;
padding: 0 12px;
width: auto;
`;
export const MemberList = styled.div`
display: flex;
align-items: center;
padding: 4px 16px 4px 8px;
margin-left: -1px;
`;
export const TaskMember = styled(TaskAssignee)`
margin-right: 4px;
`;
export const ActionButtonIcon = styled.div``;
export const EditorActions = styled.div`
display: flex;
align-items: center;
margin-left: 32px;
margin-right: 32px;
padding: 9px 8px 7px 8px;
`;
export const CancelIcon = styled.div`
width: 32px;
height: 32p;
display: flex;
align-items: center;
justify-content: center;
margin-left: 4px;
`;
export const TabBarSection = styled.div`
margin-top: 2px;
padding-left: 23px;
display: flex;
text-transform: uppercase;
min-height: 35px;
border-bottom: 1px solid #414561;
`;
export const TabBarItem = styled.div`
box-shadow: inset 0 -2px rgba(216, 93, 216);
padding: 12px 7px 14px 7px;
margin-bottom: -1px;
margin-right: 36px;
color: rgba(${props => props.theme.colors.text.primary});
`;
export const CommentContainer = styled.div`
flex: 0 0 auto;
margin-top: auto;
padding: 15px 26px;
background: #1f243e;
`;
export const CommentInnerWrapper = styled.div`
display: flex;
position: relative;
`;
export const CommentEditorContainer = styled.div`
flex: 1;
border-radius: 6px;
border: 1px solid #414561;
export const ActionButtons = styled.div`
display: flex;
flex-direction: column;
`;
export const CommentProfile = styled(TaskAssignee)`
margin-right: 8px;
position: relative;
top: 0;
padding-top: 3px;
align-items: normal;
`;
export const CommentTextArea = styled(TextareaAutosize)`
width: 100%;
line-height: 28px;
padding: 4px 6px;
border-radius: 6px;
export const ActionButtonsTitle = styled.h3`
color: rgba(${props => props.theme.colors.text.primary});
background: #1f243e;
border: none;
transition: max-height 200ms, height 200ms, min-height 200ms;
min-height: 36px;
max-height: 36px;
&:not(:focus) {
height: 36px;
}
&:focus {
min-height: 80px;
max-height: none;
line-height: 20px;
}
`;
export const CommentEditorActions = styled.div<{ visible: boolean }>`
display: ${props => (props.visible ? 'flex' : 'none')};
align-items: center;
padding: 5px 5px 5px 9px;
border-top: 1px solid #414561;
`;
export const CommentEditorActionIcon = styled.div`
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
`;
export const CommentEditorSaveButton = styled(Button)`
margin-left: auto;
padding: 8px 16px;
`;
export const ActivitySection = styled.div`
overflow-x: hidden;
padding: 8px 26px;
`;
export const ActivityItem = styled.div`
padding: 8px 0;
position: relative;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
`;
export const ActivityItemHeader = styled.div`
display: flex;
`;
export const ActivityItemHeaderUser = styled(TaskAssignee)`
margin-right: 4px;
`;
export const ActivityItemHeaderTitle = styled.div`
margin-left: 4px;
line-height: 18px;
display: flex;
align-items: center;
`;
export const ActivityItemHeaderTitleName = styled.span`
color: rgba(${props => props.theme.colors.text.primary});
font-weight: 500;
`;
export const ActivityItemTimestamp = styled.span<{ margin: number }>`
font-size: 12px;
color: rgba(${props => props.theme.colors.text.primary}, 0.65);
margin-left: ${props => props.margin}px;
font-weight: 500;
letter-spacing: 0.04em;
`;
export const ActivityItemDetails = styled.div`
margin-left: 32px;
`;
export const ActivityItemComment = styled.div`
display: inline-flex;
border-radius: 3px;
${mixin.boxShadowCard}
position: relative;
color: rgba(${props => props.theme.colors.text.primary});
padding: 8px 12px;
margin: 4px 0;
background-color: ${mixin.darken('#262c49', 0.1)};
`;
export const ActivityItemLog = styled.span`
margin-left: 2px;
color: rgba(${props => props.theme.colors.text.primary});
`;
export const ViewRawButton = styled.button`
border-radius: 3px;
padding: 8px 12px;
display: flex;
position: absolute;
right: 4px;
bottom: -24px;
cursor: pointer;
color: rgba(${props => props.theme.colors.text.primary}, 0.25);
export const ActionButton = styled(Button)`
margin-top: 8px;
padding: 6px 12px;
background: rgba(${props => props.theme.colors.bg.primary}, 0.4);
text-align: left;
&:hover {
color: rgba(${props => props.theme.colors.text.primary});
box-shadow: none;
background: rgba(${props => props.theme.colors.bg.primary}, 0.6);
}
`;
export const TaskDetailsEditor = styled(TextareaAutosize)`
min-height: 108px;
color: #c2c6dc;
background: #262c49;
border-radius: 3px;
line-height: 20px;
margin-left: 32px;
margin-right: 32px;
padding: 9px 8px 7px 8px;
outline: none;
border: none;
export const MetaDetails = styled.div`
margin-top: 8px;
display: flex;
`;
export const TaskDueDateButton = styled(Button)`
height: 32px;
padding: 6px 12px;
background: rgba(${props => props.theme.colors.bg.primary}, 0.4);
&:hover {
box-shadow: none;
background: rgba(${props => props.theme.colors.bg.primary}, 0.6);
}
`;
export const MetaDetail = styled.div`
display: block;
float: left;
margin: 0 16px 8px 0;
max-width: 100%;
`;
export const TaskDetailsSection = styled.div`
display: block;
`;
export const MetaDetailTitle = styled.h3`
color: rgba(${props => props.theme.colors.text.primary});
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
margin-top: 16px;
text-transform: uppercase;
display: block;
line-height: 20px;
margin: 0 8px 4px 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
export const TaskMember = styled(TaskAssignee)``;
export const MetaDetailContent = styled.div`
display: flex;
& ${TaskMember} {
margin-right: 4px;
}
`;

View File

@ -1,78 +1,70 @@
import React, { useState, useRef } from 'react';
import {
Plus,
User,
Trash,
Paperclip,
Clone,
Share,
Tags,
Checkmark,
CheckSquareOutline,
At,
Smile,
} from 'shared/icons';
import Editor from 'rich-markdown-editor';
import dark from 'shared/utils/editorTheme';
import React, { useState, useRef, useEffect } from 'react';
import { Bin, Cross, Plus } from 'shared/icons';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import ReactMarkdown from 'react-markdown';
import styled from 'styled-components';
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
import dayjs from 'dayjs';
import Task from 'shared/icons/Task';
import {
TaskDetailLabel,
CommentContainer,
MetaDetailContent,
isPositionChanged,
getSortedDraggables,
getNewDraggablePosition,
getAfterDropDraggableList,
} from 'shared/utils/draggables';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import TaskAssignee from 'shared/components/TaskAssignee';
import moment from 'moment';
import {
TaskMember,
NoDueDateLabel,
TaskDueDateButton,
UnassignedLabel,
TaskGroupLabel,
TaskGroupLabelName,
TaskDetailsSection,
TaskActions,
TaskDetailsAddLabel,
TaskDetailsAddLabelIcon,
TaskAction,
TaskMeta,
ActionButtons,
ActionButton,
AssignUserIcon,
AssignUserLabel,
AssignUsersButton,
AssignedUsersSection,
ViewRawButton,
DueDateTitle,
Container,
LeftSidebar,
ContentContainer,
LeftSidebarContent,
LeftSidebarSection,
SidebarTitle,
SidebarButton,
SidebarButtonText,
MarkCompleteButton,
HeaderContainer,
HeaderLeft,
HeaderInnerContainer,
ActionButtonsTitle,
TaskHeader,
ProfileIcon,
TaskDetailsContent,
TaskDetailsWrapper,
TaskDetailsSidebar,
TaskDetailsTitleWrapper,
TaskDetailsTitle,
ExtraActionsSection,
HeaderRight,
HeaderActionIcon,
EditorContainer,
InnerContentContainer,
DescriptionContainer,
Labels,
ChecklistSection,
MemberList,
TaskMember,
TabBarSection,
TabBarItem,
CommentTextArea,
CommentEditorContainer,
CommentEditorActions,
CommentEditorActionIcon,
CommentEditorSaveButton,
CommentProfile,
CommentInnerWrapper,
ActivitySection,
TaskDetailsLabel,
TaskDetailsAddDetailsButton,
TaskDetailsEditor,
TaskDetailsEditorWrapper,
TaskDetailsMarkdown,
TaskDetailsControls,
ConfirmSave,
CancelEdit,
TaskDetailSectionTitle,
TaskDetailLabel,
TaskDetailLabels,
TaskDetailAssignee,
TaskDetailAssignees,
TaskDetailsAddMemberIcon,
MetaDetails,
MetaDetail,
MetaDetailTitle,
MetaDetailContent,
} from './Styles';
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
import onDragEnd from './onDragEnd';
const ChecklistContainer = styled.div``;
type TaskContentProps = {
onEditContent: () => void;
description: string;
};
type TaskLabelProps = {
label: TaskLabel;
onClick: ($target: React.RefObject<HTMLElement>) => void;
@ -93,15 +85,62 @@ const TaskLabelItem: React.FC<TaskLabelProps> = ({ label, onClick }) => {
);
};
const TaskContent: React.FC<TaskContentProps> = ({ description, onEditContent }) => {
return description === '' ? (
<TaskDetailsAddDetailsButton onClick={onEditContent}>Add a more detailed description</TaskDetailsAddDetailsButton>
) : (
<TaskDetailsMarkdown onClick={onEditContent}>
<ReactMarkdown source={description} />
</TaskDetailsMarkdown>
);
};
type DetailsEditorProps = {
description: string;
onTaskDescriptionChange: (newDescription: string) => void;
onCancel: () => void;
};
const DetailsEditor: React.FC<DetailsEditorProps> = ({
description: initialDescription,
onTaskDescriptionChange,
onCancel,
}) => {
const [description, setDescription] = useState(initialDescription);
const $editorWrapperRef = useRef<HTMLDivElement>(null);
const $editorRef = useRef<HTMLTextAreaElement>(null);
const handleOutsideClick = () => {
onTaskDescriptionChange(description);
};
useEffect(() => {
if ($editorRef && $editorRef.current) {
$editorRef.current.focus();
$editorRef.current.select();
}
}, []);
useOnOutsideClick($editorWrapperRef, true, handleOutsideClick, null);
return (
<TaskDetailsEditorWrapper ref={$editorWrapperRef}>
<TaskDetailsEditor
ref={$editorRef}
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.currentTarget.value)}
/>
<TaskDetailsControls>
<ConfirmSave variant="relief" onClick={handleOutsideClick}>
Save
</ConfirmSave>
<CancelEdit onClick={onCancel}>
<Plus size={16} color="#c2c6dc" />
</CancelEdit>
</TaskDetailsControls>
</TaskDetailsEditorWrapper>
);
};
type TaskDetailsProps = {
task: Task;
me?: TaskUser | null;
onTaskNameChange: (task: Task, newName: string) => void;
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
onDeleteTask: (task: Task) => void;
@ -123,7 +162,6 @@ type TaskDetailsProps = {
};
const TaskDetails: React.FC<TaskDetailsProps> = ({
me,
task,
onDeleteChecklist,
onTaskNameChange,
@ -144,153 +182,163 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
onToggleChecklistItem,
onMemberProfile,
}) => {
const [editorOpen, setEditorOpen] = useState(false);
const [description, setDescription] = useState(task.description ?? '');
const [taskName, setTaskName] = useState(task.name);
const [editTaskDescription, setEditTaskDescription] = useState(() => {
if (task.description) {
if (task.description.trim() === '' || task.description.trim() === '\\') {
return true;
}
return false;
}
return true;
});
const [saveTimeout, setSaveTimeout] = useState<any>(null);
const [showRaw, setShowRaw] = useState(false);
const [showCommentActions, setShowCommentActions] = useState(false);
const taskDescriptionRef = useRef(task.description ?? '');
const $noMemberBtn = useRef<HTMLDivElement>(null);
const $addMemberBtn = useRef<HTMLDivElement>(null);
const $dueDateBtn = useRef<HTMLDivElement>(null);
const saveDescription = () => {
onTaskDescriptionChange(task, taskDescriptionRef.current);
const handleClick = () => {
setEditorOpen(!editorOpen);
};
return (
<Container>
<LeftSidebar>
<LeftSidebarContent>
<LeftSidebarSection>
<SidebarTitle>TASK GROUP</SidebarTitle>
<SidebarButton>
<SidebarButtonText>{task.taskGroup.name}</SidebarButtonText>
</SidebarButton>
<DueDateTitle>DUE DATE</DueDateTitle>
<SidebarButton
ref={$dueDateBtn}
onClick={() => {
onOpenDueDatePopop(task, $dueDateBtn);
}}
>
{task.dueDate ? (
<SidebarButtonText>{dayjs(task.dueDate).format('MMM D [at] h:mm A')}</SidebarButtonText>
) : (
<SidebarButtonText>No due date</SidebarButtonText>
)}
</SidebarButton>
</LeftSidebarSection>
<AssignedUsersSection>
<DueDateTitle>MEMBERS</DueDateTitle>
{task.assigned && task.assigned.length !== 0 ? (
<MemberList>
{task.assigned.map(m => (
<TaskMember
key={m.id}
member={m}
size={32}
onMemberProfile={$target => {
onMemberProfile($target, m.id);
}}
/>
))}
<AssignUserIcon
ref={$addMemberBtn}
onClick={() => {
onOpenAddMemberPopup(task, $addMemberBtn);
}}
>
<Plus width={16} height={16} />
</AssignUserIcon>
</MemberList>
) : (
<AssignUsersButton
ref={$noMemberBtn}
onClick={() => {
onOpenAddMemberPopup(task, $noMemberBtn);
}}
>
<AssignUserIcon>
<User width={16} height={16} />
</AssignUserIcon>
<AssignUserLabel>No members</AssignUserLabel>
</AssignUsersButton>
)}
</AssignedUsersSection>
<ExtraActionsSection>
<DueDateTitle>ACTIONS</DueDateTitle>
<ActionButton
onClick={$target => {
onOpenAddLabelPopup(task, $target);
}}
icon={<Tags width={12} height={12} />}
>
Labels
</ActionButton>
<ActionButton
onClick={$target => {
const handleDeleteTask = () => {
onDeleteTask(task);
};
const $title = useRef<HTMLTextAreaElement>(null);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
onTaskNameChange(task, taskName);
if ($title && $title.current) {
$title.current.blur();
}
}
};
const $unassignedRef = useRef<HTMLDivElement>(null);
const $addMemberRef = useRef<HTMLDivElement>(null);
const onUnassignedClick = () => {
onOpenAddMemberPopup(task, $unassignedRef);
};
const onAddMember = ($target: React.RefObject<HTMLElement>) => {
onOpenAddMemberPopup(task, $target);
};
const onAddChecklist = ($target: React.RefObject<HTMLElement>) => {
onOpenAddChecklistPopup(task, $target);
}}
icon={<CheckSquareOutline width={12} height={12} />}
>
Checklist
</ActionButton>
<ActionButton>Cover</ActionButton>
</ExtraActionsSection>
</LeftSidebarContent>
</LeftSidebar>
<ContentContainer>
<HeaderContainer>
<HeaderInnerContainer>
<HeaderLeft>
<MarkCompleteButton
invert={task.complete ?? false}
onClick={() => {
onToggleTaskComplete(task);
}}
>
<Checkmark width={8} height={8} />
<span>{task.complete ? 'Completed' : 'Mark complete'}</span>
</MarkCompleteButton>
</HeaderLeft>
<HeaderRight>
<HeaderActionIcon>
<Paperclip width={16} height={16} />
</HeaderActionIcon>
<HeaderActionIcon>
<Clone width={16} height={16} />
</HeaderActionIcon>
<HeaderActionIcon>
<Share width={16} height={16} />
</HeaderActionIcon>
<HeaderActionIcon onClick={() => onDeleteTask(task)}>
<Trash width={16} height={16} />
</HeaderActionIcon>
</HeaderRight>
</HeaderInnerContainer>
};
const $dueDateLabel = useRef<HTMLDivElement>(null);
const $addLabelRef = useRef<HTMLDivElement>(null);
const onAddLabel = ($target: React.RefObject<HTMLElement>) => {
onOpenAddLabelPopup(task, $target);
};
const onDragEnd = ({ draggableId, source, destination, type }: DropResult) => {
if (typeof destination === 'undefined') return;
if (!isPositionChanged(source, destination)) return;
const isChecklist = type === 'checklist';
const isSameChecklist = destination.droppableId === source.droppableId;
let droppedDraggable: DraggableElement | null = null;
let beforeDropDraggables: Array<DraggableElement> | null = null;
if (!task.checklists) return;
if (isChecklist) {
const droppedGroup = task.checklists.find(taskGroup => taskGroup.id === draggableId);
if (droppedGroup) {
droppedDraggable = {
id: draggableId,
position: droppedGroup.position,
};
beforeDropDraggables = getSortedDraggables(
task.checklists.map(checklist => {
return { id: checklist.id, position: checklist.position };
}),
);
if (droppedDraggable === null || beforeDropDraggables === null) {
throw new Error('before drop draggables is null');
}
const afterDropDraggables = getAfterDropDraggableList(
beforeDropDraggables,
droppedDraggable,
isChecklist,
isSameChecklist,
destination,
);
const newPosition = getNewDraggablePosition(afterDropDraggables, destination.index);
onChecklistDrop({ ...droppedGroup, position: newPosition });
} else {
throw new Error('task group can not be found');
}
} else {
const targetChecklist = task.checklists.findIndex(
checklist => checklist.items.findIndex(item => item.id === draggableId) !== -1,
);
const droppedChecklistItem = task.checklists[targetChecklist].items.find(item => item.id === draggableId);
if (droppedChecklistItem) {
droppedDraggable = {
id: draggableId,
position: droppedChecklistItem.position,
};
beforeDropDraggables = getSortedDraggables(
task.checklists[targetChecklist].items.map(item => {
return { id: item.id, position: item.position };
}),
);
if (droppedDraggable === null || beforeDropDraggables === null) {
throw new Error('before drop draggables is null');
}
const afterDropDraggables = getAfterDropDraggableList(
beforeDropDraggables,
droppedDraggable,
isChecklist,
isSameChecklist,
destination,
);
const newPosition = getNewDraggablePosition(afterDropDraggables, destination.index);
const newItem = {
...droppedChecklistItem,
position: newPosition,
};
onChecklistItemDrop(droppedChecklistItem.taskChecklistID, destination.droppableId, newItem);
}
}
};
return (
<>
<TaskActions>
<TaskAction onClick={handleDeleteTask}>
<Bin size={20} color="#c2c6dc" />
</TaskAction>
<TaskAction onClick={onCloseModal}>
<Cross width={16} height={16} />
</TaskAction>
</TaskActions>
<TaskHeader>
<TaskDetailsTitleWrapper>
<TaskDetailsTitle
ref={$title}
value={taskName}
onChange={e => {
setTaskName(e.currentTarget.value);
}}
onBlur={() => {
if (taskName !== task.name) {
onTaskNameChange(task, taskName);
}
}}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setTaskName(e.currentTarget.value)}
onKeyDown={onKeyDown}
/>
</TaskDetailsTitleWrapper>
<Labels>
<TaskMeta>
{task.taskGroup.name && (
<TaskGroupLabel>
{`in list ${(<TaskGroupLabelName>{task.taskGroup.name}</TaskGroupLabelName>)}`}
</TaskGroupLabel>
)}
</TaskMeta>
</TaskHeader>
<TaskDetailsWrapper>
<TaskDetailsContent>
<MetaDetails>
{task.assigned && task.assigned.length !== 0 && (
<MetaDetail>
<MetaDetailTitle>MEMBERS</MetaDetailTitle>
<MetaDetailContent>
{task.assigned &&
task.assigned.map(member => (
<TaskMember key={member.id} size={32} member={member} onMemberProfile={onMemberProfile} />
))}
<TaskDetailsAddMemberIcon ref={$addMemberRef} onClick={() => onAddMember($addMemberRef)}>
<Plus size={16} color="#c2c6dc" />
</TaskDetailsAddMemberIcon>
</MetaDetailContent>
</MetaDetail>
)}
{task.labels.length !== 0 && (
<MetaDetail>
<MetaDetailTitle>LABELS</MetaDetailTitle>
<MetaDetailContent>
{task.labels.map(label => {
return (
@ -303,46 +351,40 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
/>
);
})}
<TaskDetailsAddLabelIcon>
<Plus width={12} height={12} />
<TaskDetailsAddLabelIcon ref={$addLabelRef} onClick={() => onAddLabel($addLabelRef)}>
<Plus size={16} color="#c2c6dc" />
</TaskDetailsAddLabelIcon>
</MetaDetailContent>
</MetaDetail>
)}
</Labels>
</HeaderContainer>
<InnerContentContainer>
<DescriptionContainer>
{showRaw ? (
<TaskDetailsEditor value={taskDescriptionRef.current} />
) : (
<EditorContainer
onClick={e => {
if (!editTaskDescription) {
setEditTaskDescription(true);
}
{task.dueDate && (
<MetaDetail>
<MetaDetailTitle>DUE DATE</MetaDetailTitle>
<MetaDetailContent>
<TaskDueDateButton>{moment(task.dueDate).format('MMM D [at] h:mm A')}</TaskDueDateButton>
</MetaDetailContent>
</MetaDetail>
)}
</MetaDetails>
<TaskDetailsSection>
<TaskDetailsLabel>Description</TaskDetailsLabel>
{editorOpen ? (
<DetailsEditor
description={description}
onTaskDescriptionChange={newDescription => {
setEditorOpen(false);
setDescription(newDescription);
onTaskDescriptionChange(task, newDescription);
}}
>
<Editor
defaultValue={task.description ?? ''}
theme={dark}
readOnly={!editTaskDescription}
autoFocus
onChange={value => {
setSaveTimeout(() => {
clearTimeout(saveTimeout);
return setTimeout(saveDescription, 2000);
});
const text = value();
taskDescriptionRef.current = text;
onCancel={() => {
setEditorOpen(false);
}}
/>
</EditorContainer>
) : (
<TaskContent description={description} onEditContent={handleClick} />
)}
<ViewRawButton onClick={() => setShowRaw(!showRaw)}>{showRaw ? 'Show editor' : 'Show raw'}</ViewRawButton>
</DescriptionContainer>
<ChecklistSection>
<DragDropContext onDragEnd={result => onDragEnd(result, task, onChecklistDrop, onChecklistItemDrop)}>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable direction="vertical" type="checklist" droppableId="root">
{dropProvided => (
<ChecklistContainer {...dropProvided.droppableProps} ref={dropProvided.innerRef}>
@ -421,54 +463,32 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
)}
</Droppable>
</DragDropContext>
</ChecklistSection>
<TabBarSection>
<TabBarItem>Activity</TabBarItem>
</TabBarSection>
<ActivitySection />
</InnerContentContainer>
<CommentContainer>
{me && (
<CommentInnerWrapper>
<CommentProfile
member={me}
size={32}
onMemberProfile={$target => {
onMemberProfile($target, me.id);
}}
/>
<CommentEditorContainer>
<CommentTextArea
disabled
placeholder="Write a comment..."
onFocus={() => {
setShowCommentActions(true);
}}
onBlur={() => {
setShowCommentActions(false);
}}
/>
<CommentEditorActions visible={showCommentActions}>
<CommentEditorActionIcon>
<Paperclip width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<At width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<Smile width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<Task width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorSaveButton>Save</CommentEditorSaveButton>
</CommentEditorActions>
</CommentEditorContainer>
</CommentInnerWrapper>
)}
</CommentContainer>
</ContentContainer>
</Container>
</TaskDetailsSection>
</TaskDetailsContent>
<TaskDetailsSidebar>
<ActionButtons>
<ActionButtonsTitle>ADD TO CARD</ActionButtonsTitle>
<ActionButton justifyTextContent="flex-start" onClick={() => onToggleTaskComplete(task)}>
{task.complete ? 'Mark Incomplete' : 'Mark Complete'}
</ActionButton>
<ActionButton justifyTextContent="flex-start" onClick={$target => onAddMember($target)}>
Members
</ActionButton>
<ActionButton justifyTextContent="flex-start" onClick={$target => onAddLabel($target)}>
Labels
</ActionButton>
<ActionButton justifyTextContent="flex-start" onClick={$target => onAddChecklist($target)}>
Checklist
</ActionButton>
<ActionButton justifyTextContent="flex-start" onClick={$target => onOpenDueDatePopop(task, $target)}>
Due Date
</ActionButton>
<ActionButton justifyTextContent="flex-start">Attachment</ActionButton>
<ActionButton justifyTextContent="flex-start">Cover</ActionButton>
</ActionButtons>
</TaskDetailsSidebar>
</TaskDetailsWrapper>
</>
);
};

View File

@ -1,90 +0,0 @@
import {
getSortedDraggables,
isPositionChanged,
getNewDraggablePosition,
getAfterDropDraggableList,
} from 'shared/utils/draggables';
import { DropResult } from 'react-beautiful-dnd';
type OnChecklistDropFn = (checklist: TaskChecklist) => void;
type OnChecklistItemDropFn = (prevChecklistID: string, checklistID: string, checklistItem: TaskChecklistItem) => void;
const onDragEnd = (
{ draggableId, source, destination, type }: DropResult,
task: Task,
onChecklistDrop: OnChecklistDropFn,
onChecklistItemDrop: OnChecklistItemDropFn,
) => {
if (typeof destination === 'undefined') return;
if (!isPositionChanged(source, destination)) return;
const isChecklist = type === 'checklist';
const isSameChecklist = destination.droppableId === source.droppableId;
let droppedDraggable: DraggableElement | null = null;
let beforeDropDraggables: Array<DraggableElement> | null = null;
if (!task.checklists) return;
if (isChecklist) {
const droppedGroup = task.checklists.find(taskGroup => taskGroup.id === draggableId);
if (droppedGroup) {
droppedDraggable = {
id: draggableId,
position: droppedGroup.position,
};
beforeDropDraggables = getSortedDraggables(
task.checklists.map(checklist => {
return { id: checklist.id, position: checklist.position };
}),
);
if (droppedDraggable === null || beforeDropDraggables === null) {
throw new Error('before drop draggables is null');
}
const afterDropDraggables = getAfterDropDraggableList(
beforeDropDraggables,
droppedDraggable,
isChecklist,
isSameChecklist,
destination,
);
const newPosition = getNewDraggablePosition(afterDropDraggables, destination.index);
onChecklistDrop({ ...droppedGroup, position: newPosition });
} else {
throw new Error('task group can not be found');
}
} else {
const targetChecklist = task.checklists.findIndex(
checklist => checklist.items.findIndex(item => item.id === draggableId) !== -1,
);
const droppedChecklistItem = task.checklists[targetChecklist].items.find(item => item.id === draggableId);
if (droppedChecklistItem) {
droppedDraggable = {
id: draggableId,
position: droppedChecklistItem.position,
};
beforeDropDraggables = getSortedDraggables(
task.checklists[targetChecklist].items.map(item => {
return { id: item.id, position: item.position };
}),
);
if (droppedDraggable === null || beforeDropDraggables === null) {
throw new Error('before drop draggables is null');
}
const afterDropDraggables = getAfterDropDraggableList(
beforeDropDraggables,
droppedDraggable,
isChecklist,
isSameChecklist,
destination,
);
const newPosition = getNewDraggablePosition(afterDropDraggables, destination.index);
const newItem = {
...droppedChecklistItem,
position: newPosition,
};
onChecklistItemDrop(droppedChecklistItem.taskChecklistID, destination.droppableId, newItem);
}
}
};
export default onDragEnd;

View File

@ -5,7 +5,6 @@ import Button from 'shared/components/Button';
import { Taskcafe } from 'shared/icons';
import { NavLink, Link } from 'react-router-dom';
import TaskAssignee from 'shared/components/TaskAssignee';
import { useRef } from 'react';
export const ProjectMember = styled(TaskAssignee)<{ zIndex: number }>`
z-index: ${props => props.zIndex};
@ -66,7 +65,7 @@ export const ProfileNameWrapper = styled.div`
line-height: 1.25;
`;
export const IconContainerWrapper = styled.div<{ disabled?: boolean }>`
export const IconContainer = styled.div<{ disabled?: boolean }>`
margin-right: 20px;
cursor: pointer;
${props =>
@ -87,10 +86,7 @@ export const ProfileNameSecondary = styled.small`
color: #c2c6dc;
`;
export const ProfileIcon = styled.div<{
bgColor: string | null;
backgroundURL: string | null;
}>`
export const ProfileIcon = styled.div<{ bgColor: string | null; backgroundURL: string | null }>`
width: 40px;
height: 40px;
border-radius: 9999px;
@ -251,8 +247,8 @@ export const NavSeparator = styled.div`
export const LogoContainer = styled(Link)`
display: block;
left: 50%;
right: 50%;
position: absolute;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;

View File

@ -1,17 +1,19 @@
import React, { useRef, useState, useEffect } from 'react';
import { Home, Star, Bell, AngleDown, BarChart, CheckCircle, ListUnordered } from 'shared/icons';
import { Home, Star, Bell, AngleDown, BarChart, CheckCircle } from 'shared/icons';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import ProfileIcon from 'shared/components/ProfileIcon';
import { usePopup } from 'shared/components/PopupMenu';
import TaskAssignee from 'shared/components/TaskAssignee';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { RoleCode } from 'shared/generated/graphql';
import NOOP from 'shared/utils/noop';
import MiniProfile from 'shared/components/MiniProfile';
import {
TaskcafeLogo,
TaskcafeTitle,
ProjectFinder,
LogoContainer,
NavSeparator,
IconContainerWrapper,
IconContainer,
ProjectNameTextarea,
InviteButton,
GlobalActions,
@ -30,29 +32,6 @@ import {
ProjectMember,
ProjectMembers,
} from './Styles';
import { useHistory } from 'react-router';
type IconContainerProps = {
disabled?: boolean;
onClick?: ($target: React.RefObject<HTMLElement>) => void;
};
const IconContainer: React.FC<IconContainerProps> = ({ onClick, disabled = false, children }) => {
const $container = useRef<HTMLDivElement>(null);
return (
<IconContainerWrapper
ref={$container}
disabled={disabled}
onClick={() => {
if (onClick) {
onClick($container);
}
}}
>
{children}
</IconContainerWrapper>
);
};
const HomeDashboard = styled(Home)``;
@ -166,7 +145,7 @@ type NavBarProps = {
onFavorite?: () => void;
onProfileClick: ($target: React.RefObject<HTMLElement>) => void;
onSaveName?: (name: string) => void;
onNotificationClick: ($target: React.RefObject<HTMLElement>) => void;
onNotificationClick: () => void;
canEditProjectName?: boolean;
canInviteUser?: boolean;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
@ -174,11 +153,8 @@ type NavBarProps = {
user: TaskUser | null;
onOpenSettings: ($target: React.RefObject<HTMLElement>) => void;
projectMembers?: Array<TaskUser> | null;
projectInvitedMembers?: Array<InvitedUser> | null;
onRemoveFromBoard?: (userID: string) => void;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
onInvitedMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, email: string) => void;
};
const NavBar: React.FC<NavBarProps> = ({
@ -188,12 +164,10 @@ const NavBar: React.FC<NavBarProps> = ({
onChangeProjectOwner,
currentTab,
onMemberProfile,
onInvitedMemberProfile,
canEditProjectName = false,
onOpenProjectFinder,
onFavorite,
onSetTab,
projectInvitedMembers,
onChangeRole,
name,
onRemoveFromBoard,
@ -210,7 +184,6 @@ const NavBar: React.FC<NavBarProps> = ({
onProfileClick($target);
}
};
const history = useHistory();
const { showPopup } = usePopup();
return (
<NavbarWrapper>
@ -252,38 +225,19 @@ const NavBar: React.FC<NavBarProps> = ({
<TaskcafeTitle>Taskcafé</TaskcafeTitle>
</LogoContainer>
<GlobalActions>
{projectMembers && projectInvitedMembers && onMemberProfile && onInvitedMemberProfile && (
{projectMembers && onMemberProfile && (
<>
<ProjectMembers>
{projectMembers.map((member, idx) => (
<ProjectMember
showRoleIcons
zIndex={projectMembers.length - idx + projectInvitedMembers.length}
zIndex={projectMembers.length - idx}
key={member.id}
size={28}
member={member}
onMemberProfile={onMemberProfile}
/>
))}
{projectInvitedMembers.map((member, idx) => (
<ProjectMember
showRoleIcons
zIndex={projectInvitedMembers.length - idx}
key={member.email}
size={28}
invited
member={{
id: member.email,
fullName: member.email,
profileIcon: {
url: null,
initials: member.email.charAt(0),
bgColor: '#fff',
},
}}
onMemberProfile={onInvitedMemberProfile}
/>
))}
{canInviteUser && (
<InviteButton
onClick={$target => {
@ -303,19 +257,16 @@ const NavBar: React.FC<NavBarProps> = ({
<ProjectFinder onClick={onOpenProjectFinder} variant="gradient">
Projects
</ProjectFinder>
<IconContainer onClick={() => onDashboardClick()}>
<IconContainer onClick={onDashboardClick}>
<HomeDashboard width={20} height={20} />
</IconContainer>
<IconContainer disabled onClick={NOOP}>
<IconContainer disabled>
<CheckCircle width={20} height={20} />
</IconContainer>
<IconContainer onClick={() => history.push('/outline')}>
<ListUnordered width={20} height={20} />
</IconContainer>
<IconContainer disabled onClick={onNotificationClick}>
<IconContainer onClick={onNotificationClick}>
<Bell color="#c2c6dc" size={20} />
</IconContainer>
<IconContainer disabled onClick={NOOP}>
<IconContainer disabled>
<BarChart width={20} height={20} />
</IconContainer>

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
mutation createProject($teamID: UUID, $name: String!) {
createProject(input: {teamID: $teamID, name: $name}) {
mutation createProject($teamID: UUID!, $userID: UUID!, $name: String!) {
createProject(input: {teamID: $teamID, userID: $userID, name: $name}) {
id
name
team {

View File

@ -1,4 +1,4 @@
mutation createTaskGroup( $projectID: UUID!, $name: String!, $position: Float! ) {
mutation createTaskGroup( $projectID: String!, $name: String!, $position: Float! ) {
createTaskGroup(
input: { projectID: $projectID, name: $name, position: $position }
) {

View File

@ -1,4 +1,4 @@
mutation deleteTask($taskID: UUID!) {
mutation deleteTask($taskID: String!) {
deleteTask(input: { taskID: $taskID }) {
taskID
}

View File

@ -22,10 +22,6 @@ query findProject($projectID: UUID!) {
bgColor
}
}
invitedMembers {
email
invitedOn
}
labels {
id
createdDate

View File

@ -8,7 +8,6 @@ query findTask($taskID: UUID!) {
complete
taskGroup {
id
name
}
badges {
checklist {
@ -53,15 +52,4 @@ query findTask($taskID: UUID!) {
}
}
}
me {
user {
id
fullName
profileIcon {
initials
bgColor
url
}
}
}
}

View File

@ -7,7 +7,6 @@ const TASK_FRAGMENT = gql`
description
dueDate
complete
completedAt
position
badges {
checklist {

View File

@ -3,9 +3,6 @@ query me {
user {
id
fullName
username
email
bio
profileIcon {
initials
bgColor

View File

@ -0,0 +1,25 @@
import gql from 'graphql-tag';
export const CREATE_PROJECT_MEMBER_MUTATION = gql`
mutation createProjectMember($projectID: UUID!, $userID: UUID!) {
createProjectMember(input: { projectID: $projectID, userID: $userID }) {
ok
member {
id
fullName
profileIcon {
url
initials
bgColor
}
username
role {
code
name
}
}
}
}
`;
export default CREATE_PROJECT_MEMBER_MUTATION;

View File

@ -1,13 +0,0 @@
import gql from 'graphql-tag';
export const DELETE_PROJECT_INVITED_MEMBER_MUTATION = gql`
mutation deleteInvitedProjectMember($projectID: UUID!, $email: String!) {
deleteInvitedProjectMember(input: { projectID: $projectID, email: $email }) {
invitedMember {
email
}
}
}
`;
export default DELETE_PROJECT_INVITED_MEMBER_MUTATION;

View File

@ -1,29 +0,0 @@
import gql from 'graphql-tag';
export const INVITE_PROJECT_MEMBERS_MUTATION = gql`
mutation inviteProjectMembers($projectID: UUID!, $members: [MemberInvite!]!) {
inviteProjectMembers(input: { projectID: $projectID, members: $members }) {
ok
invitedMembers {
email
invitedOn
}
members {
id
fullName
profileIcon {
url
initials
bgColor
}
username
role {
code
name
}
}
}
}
`;
export default INVITE_PROJECT_MEMBERS_MUTATION;

View File

@ -2,7 +2,7 @@ import gql from 'graphql-tag';
import TASK_FRAGMENT from '../fragments/task';
const CREATE_TASK_MUTATION = gql`
mutation createTask($taskGroupID: UUID!, $name: String!, $position: Float!) {
mutation createTask($taskGroupID: String!, $name: String!, $position: Float!) {
createTask(input: { taskGroupID: $taskGroupID, name: $name, position: $position }) {
...TaskFields
}

View File

@ -1,11 +1,11 @@
import gql from 'graphql-tag';
const UPDATE_TASK_CHECKLIST_ITEM_LOCATION_MUTATION = gql`
mutation updateTaskChecklistItemLocation($taskChecklistID: UUID!, $taskChecklistItemID: UUID!, $position: Float!) {
mutation updateTaskChecklistItemLocation($checklistID: UUID!, $checklistItemID: UUID!, $position: Float!) {
updateTaskChecklistItemLocation(
input: { taskChecklistID: $taskChecklistID, taskChecklistItemID: $taskChecklistItemID, position: $position }
input: { checklistID: $checklistID, checklistItemID: $checklistItemID, position: $position }
) {
taskChecklistID
checklistID
prevChecklistID
checklistItem {
id

View File

@ -1,8 +1,8 @@
import gql from 'graphql-tag';
const UPDATE_TASK_CHECKLIST_LOCATION_MUTATION = gql`
mutation updateTaskChecklistLocation($taskChecklistID: UUID!, $position: Float!) {
updateTaskChecklistLocation(input: { taskChecklistID: $taskChecklistID, position: $position }) {
mutation updateTaskChecklistLocation($checklistID: UUID!, $position: Float!) {
updateTaskChecklistLocation(input: { checklistID: $checklistID, position: $position }) {
checklist {
id
position

View File

@ -1,10 +0,0 @@
import gql from 'graphql-tag';
const DELETE_TASK_GROUP_TASKS_MUTATION = gql`
mutation deleteTaskGroupTasks($taskGroupID: UUID!) {
deleteTaskGroupTasks(input: { taskGroupID: $taskGroupID }) {
tasks
taskGroupID
}
}
`;

Some files were not shown because too many files have changed in this diff Show More