30 Commits

Author SHA1 Message Date
03dafe9b7b fix: remove font awesome library 2020-09-11 19:58:42 -05:00
12a767947a fix: duplicate schema migration 2020-09-11 19:29:41 -05:00
40557ba79f feat: add view raw markdown button to task details 2020-09-11 16:21:46 -05:00
e4d1e21304 docs(README): re-add screenshot 2020-09-11 15:11:56 -05:00
f7c6ee470e fix: task label margin issue with task title 2020-09-11 14:54:22 -05:00
227ce5966d fix: top navbar logo was not always centered 2020-09-11 14:43:46 -05:00
aa5e1c0661 fix: flickering when transitioning to some pages 2020-09-11 14:41:21 -05:00
b603081691 fix: task labels wrapper extending farther than it should 2020-09-11 14:36:41 -05:00
e76ea9da63 fix: show correct task group in task details 2020-09-11 14:34:57 -05:00
923d7f7372 feat: add user profile settings tab 2020-09-11 14:26:02 -05:00
009d717d80 fix: uploading avatar image failing due to invalid UUID key
fixes #55
2020-09-11 13:57:02 -05:00
4272fefa28 feat: implement task group actions
- allow sorting specifc task groups
- duplicate task group
- delete all tasks in task group
2020-09-10 23:58:10 -05:00
25f5cad557 chore: switch eslint to lint changed files intead of whole project 2020-09-10 22:35:16 -05:00
cb655347be docs: add pull request template 2020-09-10 16:01:25 -05:00
03cf245828 docs(CONTRIBUTING): add note about asking in discord 2020-09-10 15:57:53 -05:00
09d73fdbce docs(README): update source build instructions 2020-09-10 15:53:47 -05:00
0caa803d27 feat: add notification UI
showPopup was also refactored to be better
2020-09-10 15:31:04 -05:00
feea209507 docs(README): update taskcafe logo design & readme content 2020-09-02 21:11:18 -05:00
e57033655a docs(FUNDING): add maintainer donation links 2020-09-02 21:04:40 -05:00
31526a2575 docs(CHANGELOG): add changelog document 2020-09-02 20:40:56 -05:00
0a1bdc19f3 fix: remove cors middleware
fixes #51
2020-09-02 20:30:38 -05:00
771d598c04 feat: add task details 2020-09-02 20:27:43 -05:00
a9a1576f46 fix: update margin on filter chips 2020-09-02 20:27:22 -05:00
9541ae70e0 docs(CONTRIBUTING): add notice about creating issue before starting work on PR 2020-09-02 15:32:37 -05:00
66583bb4fb feat: add task sorting & filtering
adds filtering by task status (completion date, incomplete, completion)
adds filtering by task metadata (task name, labels, members, due date)
adds sorting by task name, labels, members, and due date
2020-08-28 23:32:17 -07:00
47782d6d86 fix: due date manager now sends the correct new due date 2020-08-28 20:59:45 -05:00
4988176220 fix: add retry with backoff to initial database connection 2020-08-28 14:22:24 -05:00
dd50baa05a refactor: add logging to CreateTask resolver 2020-08-23 17:52:45 -05:00
46e724e731 feat: add pre commit hook to lint frontend & fix warnings 2020-08-23 17:29:06 -05:00
8ce19a1ceb refactor: update project name in tmuxinator 2020-08-23 17:29:06 -05:00
144 changed files with 9319 additions and 2059 deletions

3
.github/FUNDING.yml vendored Normal file
View File

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

28
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

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

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

19
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,19 @@
* **Please check if the PR fulfills these requirements**
- [ ] You have read the contribution guidelines [guidelines](https://github.com/JordanKnott/taskcafe/blob/master/CONTRIBUTING.md)
- [ ] The commit message follows our [guidelines](https://github.com/JordanKnott/taskcafe/blob/master/CONTRIBUTING.md#git-commit-message-style)
- [ ] Docs have been added / updated (for bug fixes / features)
* **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...)
* **What is the current behavior?** (You can also link to an open issue here)
* **What is the new behavior (if this is a feature change)?**
* **Does this PR introduce a breaking change?** (What changes might users need to make in their application due to this PR?)
* **Other information**:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 18 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:export-ydpi="96"
inkscape:export-xdpi="96"
inkscape:export-filename="/home/jordan/Projects/project-citadel/.github/taskcafe-full.png"
viewBox="0 0 1100 350"
version="1.1"
id="svg951"
sodipodi:docname="taskcafe-full.svg"
width="1100"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
height="350"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)">
width="1100"
sodipodi:docname="taskcafe-full.svg"
id="svg951"
version="1.1"
viewBox="0 0 1100 350"
inkscape:export-filename="/home/jordan/Projects/project-citadel/.github/taskcafe-full.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<metadata
id="metadata957">
<rdf:RDF>
@ -25,81 +25,70 @@
<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
effect="bspline"
only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true"
id="path-effect2633"
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
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-effect2614"
effect="bspline" />
<inkscape:path-effect
only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect2500"
effect="bspline" />
<inkscape:path-effect
only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect2491"
effect="bspline" />
<inkscape:path-effect
only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect1706"
effect="bspline" />
<inkscape:path-effect
only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect1697"
effect="bspline" />
<inkscape:path-effect
effect="bspline"
id="path-effect917"
is_visible="true"
lpeversion="1"
weight="33.333333"
@ -117,11 +106,11 @@
weight="33.333333"
lpeversion="1"
is_visible="true"
id="path-effect950"
id="path-effect917"
effect="bspline" />
<inkscape:path-effect
effect="bspline"
id="path-effect969"
id="path-effect950"
is_visible="true"
lpeversion="1"
weight="33.333333"
@ -130,131 +119,150 @@
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-effect969"
effect="bspline" />
</defs>
<sodipodi:namedview
inkscape:document-rotation="0"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1032"
id="namedview953"
showgrid="false"
units="px"
inkscape:zoom="1.2109091"
inkscape:cx="550"
inkscape:cy="241.06607"
inkscape:window-x="0"
inkscape:window-y="-18"
inkscape:current-layer="g2585"
inkscape:window-maximized="1"
inkscape:current-layer="g2585" />
inkscape:window-y="-18"
inkscape:window-x="0"
inkscape:cy="250.95495"
inkscape:cx="534.60693"
inkscape:zoom="0.85624204"
units="px"
showgrid="false"
id="namedview953"
inkscape:window-height="1032"
inkscape:window-width="1920"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
guidetolerance="10"
gridtolerance="10"
objecttolerance="10"
borderopacity="1"
bordercolor="#666666"
pagecolor="#ffffff"
inkscape:document-rotation="0" />
<g
id="g974"
transform="matrix(7.6408653,0,0,7.6408653,40.593299,-53.251254)">
transform="matrix(7.6408653,0,0,7.6408653,40.593299,-53.251254)"
id="g974">
<g
id="g1721"
transform="matrix(0.21947908,0,0,0.21947908,-1.6240521,6.1742219)">
transform="matrix(0.21947908,0,0,0.21947908,-1.6240521,6.1742219)"
id="g1721">
<g
transform="translate(0,-133.87521)"
id="g2454">
id="g2454"
transform="translate(0,-133.87521)">
<g
transform="translate(-35.365658,-44.89936)"
id="g2554">
id="g2554"
transform="translate(-35.365658,-44.89936)">
<g
transform="translate(0,107.63572)"
id="g2433">
id="g2433"
transform="translate(0,107.63572)">
<g
transform="translate(0.82337254,1.4684449)"
id="g2439">
id="g2439"
transform="translate(0.82337254,1.4684449)">
<g
id="g2472">
<g
transform="translate(0,-15.084391)"
id="g2585">
id="g2585"
transform="translate(0,-15.084391)">
<g
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" />
transform="translate(1.0665725)"
id="g4610">
<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)" />
id="g2560"
transform="translate(-28.409706,-8.3958791)">
<text
xml:space="preserve"
style="font-size:124.328px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.690712"
x="196.89775"
y="235.17853"
id="text852"><tspan
sodipodi:role="line"
id="tspan850"
x="196.89775"
y="235.17853"
style="font-size:124.328px;fill:#10163a;fill-opacity:1;stroke-width:0.690712">Taskcafé</tspan></text>
<text
xml:space="preserve"
style="font-size:22.6594px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.596299"
x="223.15549"
y="261.83273"
id="text856"><tspan
sodipodi:role="line"
id="tspan854"
x="223.15549"
y="261.83273"
style="font-size:22.6594px;fill:#262c49;fill-opacity:1;stroke-width:0.596299">An open source project management tool</tspan></text>
</g>
<g
id="g4591"
transform="matrix(0.63214637,0,0,0.63214637,34.070751,71.061726)">
<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)">
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="g2505"
transform="translate(0.09191978,50.168306)">
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.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" />
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"
d="m 93.574758,186.094 c -13.47814,0 -24.40427,10.92613 -24.40427,24.40428 0,13.47814 10.92613,24.40427 24.40427,24.40427 13.478152,0 24.404282,-10.92613 24.404282,-24.40427 0,-13.47815 -10.92613,-24.40428 -24.404282,-24.40428 z m 0,4.72341 c 10.876832,0 19.680872,8.80236 19.680872,19.68087 0,10.87683 -8.80236,19.68086 -19.680872,19.68086 -10.876819,0 -19.680863,-8.80236 -19.680863,-19.68086 0,-10.87684 8.802364,-19.68087 19.680863,-19.68087 m 13.796692,12.81883 -2.21764,-2.23554 c -0.45925,-0.463 -1.20693,-0.46604 -1.66993,-0.007 l -13.909644,13.79786 -5.883794,-5.93153 c -0.459258,-0.46298 -1.206929,-0.46603 -1.669926,-0.007 l -2.235645,2.21764 c -0.462989,0.45926 -0.466044,1.20693 -0.0068,1.67002 l 8.933241,9.00557 c 0.459258,0.46299 1.206929,0.46605 1.669926,0.007 l 16.983502,-16.84721 c 0.4629,-0.45935 0.46585,-1.20702 0.007,-1.67002 z"
style="fill:#7367f0;fill-opacity:1;stroke-width:0.0984039" />
</g>
</g>
<g
id="g2560"
transform="translate(0,15.33842)">
<text
xml:space="preserve"
style="font-size:124.328px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.690712"
x="196.89775"
y="235.17853"
id="text852"><tspan
sodipodi:role="line"
id="tspan850"
x="196.89775"
y="235.17853"
style="font-size:124.328px;fill:#10163a;fill-opacity:1;stroke-width:0.690712">Taskcafé</tspan></text>
<text
xml:space="preserve"
style="font-size:22.6594px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.596299"
x="223.15549"
y="261.83273"
id="text856"><tspan
sodipodi:role="line"
id="tspan854"
x="223.15549"
y="261.83273"
style="font-size:22.6594px;fill:#262c49;fill-opacity:1;stroke-width:0.596299">An open source project management tool</tspan></text>
</g>
<path
style="fill:#7367f0;fill-opacity:1;stroke-width:0.0984039"
d="m 93.574758,186.094 c -13.47814,0 -24.40427,10.92613 -24.40427,24.40428 0,13.47814 10.92613,24.40427 24.40427,24.40427 13.478152,0 24.404282,-10.92613 24.404282,-24.40427 0,-13.47815 -10.92613,-24.40428 -24.404282,-24.40428 z m 0,4.72341 c 10.876832,0 19.680872,8.80236 19.680872,19.68087 0,10.87683 -8.80236,19.68086 -19.680872,19.68086 -10.876819,0 -19.680863,-8.80236 -19.680863,-19.68086 0,-10.87684 8.802364,-19.68087 19.680863,-19.68087 m 13.796692,12.81883 -2.21764,-2.23554 c -0.45925,-0.463 -1.20693,-0.46604 -1.66993,-0.007 l -13.909644,13.79786 -5.883794,-5.93153 c -0.459258,-0.46298 -1.206929,-0.46603 -1.669926,-0.007 l -2.235645,2.21764 c -0.462989,0.45926 -0.466044,1.20693 -0.0068,1.67002 l 8.933241,9.00557 c 0.459258,0.46299 1.206929,0.46605 1.669926,0.007 l 16.983502,-16.84721 c 0.4629,-0.45935 0.46585,-1.20702 0.007,-1.67002 z"
id="path2782" />
</g>
</g>
</g>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,4 +1,12 @@
repos:
- repo: local
hooks:
- id: eslint
name: eslint
entry: scripts/lint.sh
language: system
files: \.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx
types: [file]
- hooks:
- id: check-yaml
- id: end-of-file-fixer

View File

@ -1,14 +1,12 @@
name: citadel
name: taskcafe
root: .
on_project_start: docker start test-db
windows:
- services:
root: ./
panes:
- api:
- go run cmd/citadel/main.go web
- go run cmd/taskcafe/main.go web
- yarn:
- cd frontend
- yarn start
@ -19,8 +17,8 @@ windows:
- api/editor:
root: ./
panes:
- vim cmd/citadel/main.go
- vim cmd/taskcafe/main.go
- database:
root: ./
panes:
- pgcli postgres://postgres:test@localhost:5432/citadel
- pgcli postgres://taskcafe:taskcafe_test@localhost:5432/taskcafe

27
CHANGELOG.md Normal file
View File

@ -0,0 +1,27 @@
# 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,8 +4,13 @@ Thanks for wanting to contribute to Taskcafe!
### Where do I go from here?
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.
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!
### Fork & create a branch

View File

@ -1,24 +1,35 @@
![Taskcafe](./.github/taskcafe-full.png)
<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" />
</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>
</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>
[![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
**Please note that this project is still in active development. Some options may not work yet! For updates on development, join the Discord server**
![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
@ -81,6 +92,7 @@ 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.
@ -89,6 +101,8 @@ 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

@ -30,6 +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",
"react/prop-types": 0,
"react/jsx-props-no-spreading": "off",
"no-param-reassign": "off",

View File

@ -6,14 +6,6 @@
"@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",
@ -28,6 +20,7 @@
"@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",
@ -59,6 +52,9 @@
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.0",
"react-select": "^3.1.0",
"rich-markdown-editor": "^10.6.5",
"react-timeago": "^4.4.0",
"react-toastify": "^6.0.8",
"styled-components": "^5.0.1",
"typescript": "~3.7.2"
},
@ -71,7 +67,8 @@
"storybook": "start-storybook -p 9009 -s public",
"build-storybook": "build-storybook -s public",
"generate": "graphql-codegen",
"lint": "eslint --ext js,ts,tsx src"
"lint": "eslint --ext js,ts,tsx src",
"tsc": "tsc"
},
"eslintConfig": {
"extends": "react-app"
@ -94,10 +91,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

@ -1,4 +1,4 @@
import React, { useEffect, useState, useContext } from 'react';
import React, { useEffect } from 'react';
import Admin from 'shared/components/Admin';
import Select from 'shared/components/Select';
import GlobalTopNavbar from 'App/TopNavbar';
@ -16,8 +16,9 @@ import { useForm, Controller } from 'react-hook-form';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer';
import updateApolloCache from 'shared/utils/cache';
import UserContext, { useCurrentUser } from 'App/context';
import { useCurrentUser } from 'App/context';
import { Redirect } from 'react-router';
import NOOP from 'shared/utils/noop';
const DeleteUserWrapper = styled.div`
display: flex;
@ -37,6 +38,7 @@ const DeleteUserButton = styled(Button)`
type DeleteUserPopupProps = {
onDeleteUser: () => void;
};
const DeleteUserPopup: React.FC<DeleteUserPopupProps> = ({ onDeleteUser }) => {
return (
<DeleteUserWrapper>
@ -47,10 +49,12 @@ const DeleteUserPopup: React.FC<DeleteUserPopupProps> = ({ onDeleteUser }) => {
</DeleteUserWrapper>
);
};
type RoleCodeOption = {
label: string;
value: string;
};
type CreateUserData = {
email: string;
username: string;
@ -65,6 +69,7 @@ const CreateUserForm = styled.form`
flex-direction: column;
margin: 0 12px;
`;
const CreateUserButton = styled(Button)`
margin-top: 8px;
padding: 6px 12px;
@ -85,7 +90,7 @@ type AddUserPopupProps = {
};
const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
const { register, handleSubmit, errors, setValue, control } = useForm<CreateUserData>();
const { register, handleSubmit, errors, control } = useForm<CreateUserData>();
const createUser = (data: CreateUserData) => {
onAddUser(data);
@ -115,7 +120,7 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
control={control}
name="roleCode"
rules={{ required: 'Role is required' }}
render={({ onChange, onBlur, value }) => (
render={({ onChange, value }) => (
<Select
label="Role"
value={value}
@ -166,7 +171,7 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
const AdminRoute = () => {
useEffect(() => {
document.title = 'Taskcafé | Admin';
document.title = 'Admin | Taskcafé';
}, []);
const { loading, data } = useUsersQuery();
const { showPopup, hidePopup } = usePopup();
@ -198,21 +203,21 @@ const AdminRoute = () => {
},
});
if (loading) {
return <GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />;
return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />;
}
if (data && user) {
if (user.roles.org != 'admin') {
if (user.roles.org !== 'admin') {
return <Redirect to="/" />;
}
return (
<>
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />
<GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
<Admin
initialTab={0}
users={data.users}
canInviteUser={user.roles.org == 'admin'}
onInviteUser={() => {}}
onUpdateUserPassword={(user, password) => {
canInviteUser={user.roles.org === 'admin'}
onInviteUser={NOOP}
onUpdateUserPassword={() => {
hidePopup();
}}
onDeleteUser={(userID, newOwnerID) => {
@ -224,8 +229,8 @@ const AdminRoute = () => {
$target,
<Popup tab={0} title="Add member" onClose={() => hidePopup()}>
<AddUserPopup
onAddUser={user => {
const { roleCode, ...userData } = user;
onAddUser={u => {
const { roleCode, ...userData } = u;
createUser({ variables: { ...userData, roleCode: roleCode.value } });
hidePopup();
}}

View File

@ -1,5 +1,5 @@
import React from 'react';
import {Router, Switch, Route} from 'react-router-dom';
import { Switch, Route } from 'react-router-dom';
import * as H from 'history';
import Dashboard from 'Dashboard';
@ -20,11 +20,12 @@ const MainContent = styled.div`
flex-direction: column;
flex-grow: 1;
`;
type RoutesProps = {
history: H.History;
};
const Routes = ({history}: RoutesProps) => (
const Routes: React.FC<RoutesProps> = () => (
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/install" component={Install} />

View File

@ -1,4 +1,4 @@
import { createGlobalStyle, DefaultTheme } from 'styled-components';
import { DefaultTheme } from 'styled-components';
const theme: DefaultTheme = {
borderRadius: {
@ -25,4 +25,4 @@ const theme: DefaultTheme = {
},
};
export { theme };
export default theme;

View File

@ -1,13 +1,13 @@
import React, { useState, useContext, useEffect } from 'react';
import React from 'react';
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
import styled from 'styled-components/macro';
import DropdownMenu, { ProfileMenu } from 'shared/components/DropdownMenu';
import { ProfileMenu } from 'shared/components/DropdownMenu';
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { useHistory } from 'react-router';
import { UserContext, PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import {
RoleCode,
useMeQuery,
useTopNavbarQuery,
useDeleteProjectMutation,
useGetProjectsQuery,
GetProjectsDocument,
@ -18,6 +18,8 @@ import produce from 'immer';
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;
@ -130,7 +132,7 @@ const ProjectFinder = () => {
return <span>loading</span>;
}
if (data) {
const { projects, teams, organizations } = data;
const { projects, teams } = data;
const projectTeams = teams.map(team => {
return {
id: team.id,
@ -196,7 +198,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, proje
<Popup title={null} tab={0}>
<ProjectSettings
onDeleteProject={() => {
setTab(1, 300);
setTab(1, { width: 300 });
}}
/>
</Popup>
@ -238,7 +240,6 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
currentTab,
onSetTab,
menuType,
projectID,
teamID,
onChangeProjectOwner,
onChangeRole,
@ -248,19 +249,18 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
onInviteUser,
onSaveProjectName,
onRemoveFromBoard,
nameOnly,
}) => {
const { user, setUserRoles, setUser } = useCurrentUser();
const { loading, data } = useMeQuery({
onCompleted: data => {
const { loading, data } = useTopNavbarQuery({
onCompleted: response => {
if (user && user.roles) {
setUserRoles({
org: user.roles.org,
teams: data.me.teamRoles.reduce((map, obj) => {
teams: response.me.teamRoles.reduce((map, obj) => {
map.set(obj.teamID, obj.roleCode);
return map;
}, new Map<string, string>()),
projects: data.me.projectRoles.reduce((map, obj) => {
projects: response.me.projectRoles.reduce((map, obj) => {
map.set(obj.projectID, obj.roleCode);
return map;
}, new Map<string, string>()),
@ -268,7 +268,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
}
},
});
const { showPopup, hidePopup, setTab } = usePopup();
const { showPopup, hidePopup } = usePopup();
const history = useHistory();
const onLogout = () => {
fetch('/auth/logout', {
@ -301,13 +301,31 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
}}
/>
</Popup>,
195,
{ width: 195 },
);
};
const onOpenSettings = ($target: React.RefObject<HTMLElement>) => {
if (popupContent) {
showPopup($target, popupContent, 185);
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' },
);
}
};
@ -367,7 +385,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
onInviteUser={onInviteUser}
onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner}
onNotificationClick={() => {}}
onNotificationClick={onNotificationClick}
onSetTab={onSetTab}
onRemoveFromBoard={onRemoveFromBoard}
onDashboardClick={() => {

View File

@ -3,14 +3,48 @@ 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';
import BaseStyles from './BaseStyles';
import { theme } from './ThemeStyles';
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;
@ -72,6 +106,18 @@ 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

@ -1,13 +1,10 @@
import React, { useState, useEffect, useContext } from 'react';
import { useForm } from 'react-hook-form';
import { useHistory } from 'react-router';
import { setAccessToken } from 'shared/utils/accessToken';
import Login from 'shared/components/Login';
import { Container, LoginWrapper } from './Styles';
import UserContext, { PermissionLevel, PermissionObjectType } from 'App/context';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import Login from 'shared/components/Login';
import UserContext from 'App/context';
import { Container, LoginWrapper } from './Styles';
const Auth = () => {
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);

View File

@ -1,18 +1,13 @@
import React, { useEffect, useContext } from 'react';
import axios from 'axios';
import Register from 'shared/components/Register';
import { Container, LoginWrapper } from './Styles';
import { useCreateUserAccountMutation, useMeQuery, MeDocument, MeQuery } from 'shared/generated/graphql';
import { useHistory } from 'react-router';
import { getAccessToken, setAccessToken } from 'shared/utils/accessToken';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import { useApolloClient } from '@apollo/react-hooks';
import UserContext, { PermissionLevel, PermissionObjectType } from 'App/context';
import UserContext from 'App/context';
import jwtDecode from 'jwt-decode';
import { Container, LoginWrapper } from './Styles';
const Install = () => {
const client = useApolloClient();
const history = useHistory();
const { setUser } = useContext(UserContext);
useEffect(() => {
@ -63,7 +58,7 @@ const Install = () => {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.data;
const { accessToken, isInstalled } = response;
const { accessToken: newToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken);
const currentUser = {
id: claims.userId,

View File

@ -1,12 +1,22 @@
import React, { useRef, useEffect } from 'react';
import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import { Link } from 'react-router-dom';
import { getAccessToken } from 'shared/utils/accessToken';
import Settings from 'shared/components/Settings';
import { useMeQuery, useClearProfileAvatarMutation, useUpdateUserPasswordMutation } from 'shared/generated/graphql';
import {
useMeQuery,
useClearProfileAvatarMutation,
useUpdateUserPasswordMutation,
useUpdateUserInfoMutation,
MeQuery,
MeDocument,
} 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;
@ -18,6 +28,7 @@ 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(() => {
@ -54,7 +65,7 @@ const Projects = () => {
}
}}
/>
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />
<GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
{!loading && data && (
<Settings
profile={data.me.user}
@ -65,6 +76,14 @@ 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

@ -0,0 +1,324 @@
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 moment from 'moment';
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

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

@ -0,0 +1,80 @@
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>
</ActionsList>
);
};
export default SortPopup;

View File

@ -1,52 +1,122 @@
import React, { useState, useRef, useContext, useEffect } from 'react';
import { MENU_TYPES } from 'shared/components/TopNavbar';
import React, { useState, useRef } from 'react';
import updateApolloCache from 'shared/utils/cache';
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
import LabelManagerEditor from '../LabelManagerEditor';
import styled, { css } from 'styled-components/macro';
import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom';
import { useRouteMatch, useHistory } from 'react-router-dom';
import {
useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation,
useDeleteProjectMemberMutation,
useSetTaskCompleteMutation,
useToggleTaskLabelMutation,
useUpdateProjectNameMutation,
useFindProjectQuery,
useSortTaskGroupMutation,
useUpdateTaskGroupNameMutation,
useUpdateTaskNameMutation,
useUpdateProjectLabelMutation,
useCreateTaskMutation,
useDeleteProjectLabelMutation,
useDeleteTaskMutation,
useUpdateTaskLocationMutation,
useUpdateTaskGroupLocationMutation,
useCreateTaskGroupMutation,
useDeleteTaskGroupMutation,
useUpdateTaskDescriptionMutation,
useAssignTaskMutation,
DeleteTaskDocument,
FindProjectDocument,
useCreateProjectLabelMutation,
useUnassignTaskMutation,
useUpdateTaskDueDateMutation,
FindProjectQuery,
useUsersQuery,
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 from 'shared/components/Lists';
import SimpleLists, {
TaskStatus,
TaskSince,
TaskStatusFilter,
TaskMeta,
TaskMetaMatch,
TaskMetaFilters,
} from 'shared/components/Lists';
import { TaskSorting, TaskSortingType, TaskSortingDirection, sortTasks } from 'shared/utils/sorting';
import produce from 'immer';
import MiniProfile from 'shared/components/MiniProfile';
import DueDateManager from 'shared/components/DueDateManager';
import UserContext, { useCurrentUser } from 'App/context';
import LabelManager from 'shared/components/PopupMenu/LabelManager';
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
import EmptyBoard from 'shared/components/EmptyBoard';
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;
@ -61,14 +131,14 @@ const ProjectActions = styled.div`
align-items: center;
`;
const ProjectAction = styled.div<{ disabled?: boolean }>`
const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
cursor: pointer;
display: flex;
align-items: center;
font-size: 15px;
color: rgba(${props => props.theme.colors.text.primary});
&:not(:last-child) {
&:not(:last-of-type) {
margin-right: 16px;
}
@ -88,6 +158,25 @@ 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;
@ -113,18 +202,18 @@ export const BoardLoading = () => {
<>
<ProjectBar>
<ProjectActions>
<ProjectAction disabled>
<ProjectAction>
<CheckCircle width={13} height={13} />
<ProjectActionText>All Tasks</ProjectActionText>
</ProjectAction>
<ProjectAction disabled>
<Filter width={13} height={13} />
<ProjectActionText>Filter</ProjectActionText>
</ProjectAction>
<ProjectAction disabled>
<ProjectAction>
<Sort width={13} height={13} />
<ProjectActionText>Sort</ProjectActionText>
</ProjectAction>
<ProjectAction>
<Filter width={13} height={13} />
<ProjectActionText>Filter</ProjectActionText>
</ProjectAction>
</ProjectActions>
<ProjectActions>
<ProjectAction>
@ -146,18 +235,43 @@ 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 { user } = useCurrentUser();
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>(
@ -210,6 +324,36 @@ 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();
@ -240,6 +384,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
);
},
});
const { user } = useCurrentUser();
const [deleteTask] = useDeleteTaskMutation();
const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => {
@ -269,6 +414,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
id: `${Math.round(Math.random() * -1000000)}`,
name,
complete: false,
completedAt: null,
taskGroup: {
__typename: 'TaskGroup',
id: taskGroup.id,
@ -305,15 +451,19 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
if (loading) {
return <BoardLoading />;
}
if (data) {
const getTaskStatusFilterLabel = (filter: TaskStatusFilter) => {
if (filter.status === TaskStatus.COMPLETE) {
return 'Complete';
}
if (filter.status === TaskStatus.INCOMPLETE) {
return 'Incomplete';
}
return 'All Tasks';
};
if (data && user) {
labelsRef.current = data.findProject.labels;
membersRef.current = data.findProject.members;
const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => {
if ($target && $target.current) {
const pos = $target.current.getBoundingClientRect();
const height = 120;
if (window.innerHeight - pos.bottom < height) {
}
}
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
const currentTask = taskGroup ? taskGroup.tasks.find(t => t.id === taskID) : null;
if (currentTask) {
@ -336,23 +486,84 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<>
<ProjectBar>
<ProjectActions>
<ProjectAction disabled>
<ProjectAction
onClick={target => {
showPopup(
target,
<Popup tab={0} title={null}>
<FilterStatus
filter={taskStatusFilter}
onChangeTaskStatusFilter={filter => {
setTaskStatusFilter(filter);
hidePopup();
}}
/>
</Popup>,
{ width: 185 },
);
}}
>
<CheckCircle width={13} height={13} />
<ProjectActionText>All Tasks</ProjectActionText>
<ProjectActionText>{getTaskStatusFilterLabel(taskStatusFilter)}</ProjectActionText>
</ProjectAction>
<ProjectAction disabled>
<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 },
);
}}
>
<Filter width={13} height={13} />
<ProjectActionText>Filter</ProjectActionText>
</ProjectAction>
<ProjectAction disabled>
<Sort width={13} height={13} />
<ProjectActionText>Sort</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;
}
}),
);
})}
</ProjectActions>
<ProjectActions>
<ProjectAction
ref={$labelsRef}
onClick={() => {
onClick={$labelsRef => {
showPopup(
$labelsRef,
<LabelManagerEditor
@ -381,7 +592,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onTaskClick={task => {
history.push(`${match.url}/c/${task.id}`);
}}
onCardLabelClick={onCardLabelClick ?? (() => {})}
onCardLabelClick={onCardLabelClick ?? NOOP}
cardLabelVariant={cardLabelVariant ?? 'large'}
onTaskDrop={(droppedTask, previousTaskGroupID) => {
updateTaskLocation({
@ -425,9 +636,12 @@ 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) => {
onCardMemberClick={($targetRef, _taskID, memberID) => {
const member = data.findProject.members.find(m => m.id === memberID);
if (member) {
showPopup(
@ -449,15 +663,44 @@ 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}
onArchiveTaskGroup={tgID => {
deleteTaskGroup({ variables: { taskGroupID: tgID } });
<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();
}}
/>
</Popup>,
}
}}
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();
}}
/>,
);
}}
/>
@ -486,7 +729,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
</Popup>,
);
}}
onCardMemberClick={($targetRef, taskID, memberID) => {
onCardMemberClick={($targetRef, _taskID, memberID) => {
const member = data.findProject.members.find(m => m.id === memberID);
if (member) {
showPopup(
@ -516,8 +759,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
/>,
);
}}
onArchiveCard={(_listId: string, cardId: string) =>
deleteTask({
onArchiveCard={(_listId: string, cardId: string) => {
return deleteTask({
variables: { taskID: cardId },
update: client => {
updateApolloCache<FindProjectQuery>(
@ -533,8 +776,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
{ projectID },
);
},
})
}
});
}}
onOpenDueDatePopup={($targetRef, task) => {
showPopup(
$targetRef,
@ -549,7 +792,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup();
}}
onCancel={() => {}}
onCancel={NOOP}
/>
</Popup>,
);

View File

@ -1,7 +1,7 @@
import React, { useState, useContext, useEffect } from 'react';
import React, { useState } from 'react';
import Modal from 'shared/components/Modal';
import TaskDetails from 'shared/components/TaskDetails';
import PopupMenu, { Popup, usePopup } from 'shared/components/PopupMenu';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import MemberManager from 'shared/components/MemberManager';
import { useRouteMatch, useHistory } from 'react-router';
import {
@ -22,7 +22,7 @@ import {
FindTaskDocument,
FindTaskQuery,
} from 'shared/generated/graphql';
import UserContext, { useCurrentUser } from 'App/context';
import { useCurrentUser } from 'App/context';
import MiniProfile from 'shared/components/MiniProfile';
import DueDateManager from 'shared/components/DueDateManager';
import produce from 'immer';
@ -31,6 +31,7 @@ import Button from 'shared/components/Button';
import Input from 'shared/components/Input';
import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache';
import NOOP from 'shared/utils/noop';
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
const total = checklists.reduce((prev: any, next: any) => {
@ -75,15 +76,12 @@ const CreateChecklistInput = styled(Input)`
margin-bottom: 8px;
`;
const InputError = styled.span`
color: rgba(${props => props.theme.colors.danger});
font-size: 12px;
`;
type CreateChecklistPopupProps = {
onCreateChecklist: (data: CreateChecklistData) => void;
};
const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => {
const { register, handleSubmit, errors } = useForm<CreateChecklistData>();
const { register, handleSubmit } = useForm<CreateChecklistData>();
const createUser = (data: CreateChecklistData) => {
onCreateChecklist(data);
};
@ -132,9 +130,6 @@ const Details: React.FC<DetailsProps> = ({
const { user } = useCurrentUser();
const { showPopup, hidePopup } = usePopup();
const history = useHistory();
const match = useRouteMatch();
const [currentMemberTask, setCurrentMemberTask] = useState('');
const [memberPopupData, setMemberPopupData] = useState(initialMemberPopupState);
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
update: (client, response) => {
@ -148,7 +143,7 @@ const Details: React.FC<DetailsProps> = ({
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID);
if (oldIdx > -1 && newIdx > -1) {
const item = cache.findTask.checklists[oldIdx].items.find(item => item.id === checklistItem.id);
const item = cache.findTask.checklists[oldIdx].items.find(i => i.id === checklistItem.id);
if (item) {
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
i => i.id !== checklistItem.id,
@ -295,21 +290,22 @@ const Details: React.FC<DetailsProps> = ({
},
});
if (loading) {
return <div>loading</div>;
return null;
}
if (!data) {
return <div>loading</div>;
return null;
}
return (
<>
<Modal
width={768}
width={1070}
onClose={() => {
history.push(projectURL);
}}
renderContent={() => {
return (
<TaskDetails
me={data.me.user}
task={data.findTask}
onChecklistDrop={checklist => {
updateTaskChecklistLocation({
@ -398,7 +394,7 @@ const Details: React.FC<DetailsProps> = ({
if (member) {
showPopup(
$targetRef,
<Popup title={null} onClose={() => {}} tab={0}>
<Popup title={null} onClose={NOOP} tab={0}>
<MiniProfile
user={member}
bio="None"
@ -412,19 +408,19 @@ const Details: React.FC<DetailsProps> = ({
);
}
}}
onOpenAddMemberPopup={(task, $targetRef) => {
onOpenAddMemberPopup={(_task, $targetRef) => {
showPopup(
$targetRef,
<Popup title="Members" tab={0} onClose={() => {}}>
<Popup title="Members" tab={0} onClose={NOOP}>
<MemberManager
availableMembers={availableMembers}
activeMembers={data.findTask.assigned}
onMemberChange={(member, isActive) => {
if (user) {
if (isActive) {
assignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
assignTask({ variables: { taskID: data.findTask.id, userID: member.id } });
} else {
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
unassignTask({ variables: { taskID: data.findTask.id, userID: member.id } });
}
}
}}
@ -486,7 +482,7 @@ const Details: React.FC<DetailsProps> = ({
showPopup(
$targetRef,
<Popup
title={'Change Due Date'}
title="Change Due Date"
tab={0}
onClose={() => {
hidePopup();
@ -502,9 +498,10 @@ const Details: React.FC<DetailsProps> = ({
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup();
}}
onCancel={() => {}}
onCancel={NOOP}
/>
</Popup>,
{ showDiamond: false, targetPadding: '0' },
);
}}
/>

View File

@ -4,7 +4,6 @@ import updateApolloCache from 'shared/utils/cache';
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
import styled from 'styled-components/macro';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import LabelManagerEditor from './LabelManagerEditor';
import {
useParams,
Route,
@ -36,9 +35,11 @@ import produce from 'immer';
import UserContext, { useCurrentUser } from 'App/context';
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 Board, { BoardLoading } from './Board';
import Details from './Details';
import EmptyBoard from 'shared/components/EmptyBoard';
import LabelManagerEditor from './LabelManagerEditor';
const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant';
@ -124,6 +125,7 @@ const Project = () => {
const match = useRouteMatch();
const [updateTaskDescription] = useUpdateTaskDescriptionMutation();
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => {
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
@ -190,7 +192,6 @@ const Project = () => {
const { showPopup, hidePopup } = usePopup();
const $labelsRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<Array<ProjectLabel>>([]);
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
useEffect(() => {
if (data) {
document.title = `${data.findProject.name} | Taskcafé`;
@ -199,7 +200,7 @@ const Project = () => {
if (loading) {
return (
<>
<GlobalTopNavbar onSaveProjectName={projectName => {}} name="" projectID={null} />
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
<BoardLoading />
</>
);
@ -261,7 +262,7 @@ const Project = () => {
path={`${match.path}/board/c/:taskID`}
render={(routeProps: RouteComponentProps<TaskRouteProps>) => (
<Details
refreshCache={() => {}}
refreshCache={NOOP}
availableMembers={data.findProject.members}
projectURL={`${match.url}/board`}
taskID={routeProps.match.params.taskID}
@ -269,7 +270,17 @@ const Project = () => {
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
}}
onTaskDescriptionChange={(updatedTask, newDescription) => {
updateTaskDescription({ variables: { taskID: updatedTask.id, description: newDescription } });
updateTaskDescription({
variables: { taskID: updatedTask.id, description: newDescription },
optimisticResponse: {
__typename: 'Mutation',
updateTaskDescription: {
__typename: 'Task',
id: updatedTask.id,
description: newDescription,
},
},
});
}}
onDeleteTask={deletedTask => {
deleteTask({ variables: { taskID: deletedTask.id } });

View File

@ -1,4 +1,4 @@
import React, { useState, useContext, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import Empty from 'shared/undraw/Empty';
@ -10,16 +10,17 @@ import {
GetProjectsQuery,
} from 'shared/generated/graphql';
import ProjectGridItem, { AddProjectItem } from 'shared/components/ProjectGridItem';
import { Link } from 'react-router-dom';
import NewProject from 'shared/components/NewProject';
import UserContext, { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import Button from 'shared/components/Button';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useForm } from 'react-hook-form';
import Input from 'shared/components/Input';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import NOOP from 'shared/utils/noop';
const EmptyStateContent = styled.div`
display: flex;
justy-content: center;
@ -37,21 +38,26 @@ const EmptyStatePrompt = styled.span`
font-size: 16px;
margin-top: 8px;
`;
const EmptyState = styled(Empty)`
display: block;
margin: 0 auto;
`;
const CreateTeamButton = styled(Button)`
width: 100%;
`;
type CreateTeamData = { teamName: string };
type CreateTeamFormProps = {
onCreateTeam: (teamName: string) => void;
};
const CreateTeamFormContainer = styled.form``;
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
const { register, handleSubmit, errors } = useForm<CreateTeamData>();
const { register, handleSubmit } = useForm<CreateTeamData>();
const createTeam = (data: CreateTeamData) => {
onCreateTeam(data.teamName);
};
@ -186,6 +192,7 @@ const SectionActions = styled.div`
const SectionAction = styled(Button)`
padding: 6px 12px;
`;
const SectionActionLink = styled(Link)`
margin-right: 8px;
`;
@ -201,12 +208,14 @@ const ProjectsContainer = styled.div`
max-width: 825px;
min-width: 288px;
`;
const ProjectGrid = styled.div`
max-width: 780px;
display: grid;
grid-template-columns: 240px 240px 240px;
gap: 20px 10px;
`;
const AddTeamButton = styled(Button)`
padding: 6px 12px;
position: absolute;
@ -217,13 +226,12 @@ const AddTeamButton = styled(Button)`
const CreateFirstTeam = styled(Button)`
margin-top: 8px;
`;
type ShowNewProject = {
open: boolean;
initialTeamID: null | string;
};
const ProjectLink = styled(Link)``;
const Projects = () => {
const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' });
@ -241,7 +249,7 @@ const Projects = () => {
});
const [showNewProject, setShowNewProject] = useState<ShowNewProject>({ open: false, initialTeamID: null });
const { user, setUser } = useCurrentUser();
const { user } = useCurrentUser();
const [createTeam] = useCreateTeamMutation({
update: (client, createData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
@ -252,11 +260,7 @@ const Projects = () => {
},
});
if (loading) {
return (
<>
<span>loading</span>
</>
);
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
}
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
@ -267,7 +271,7 @@ const Projects = () => {
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
})
.map(team => {
return {
@ -278,13 +282,13 @@ const Projects = () => {
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
}),
};
});
return (
<>
<GlobalTopNavbar onSaveProjectName={() => {}} projectID={null} name={null} />
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<Wrapper>
<ProjectsContainer>
{user.roles.org === 'admin' && (

View File

@ -1,9 +1,9 @@
import React, { useState, useContext } from 'react';
import React, { useState } from 'react';
import Input from 'shared/components/Input';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import Button from 'shared/components/Button';
import UserContext, { useCurrentUser, PermissionLevel, PermissionObjectType } from 'App/context';
import { useCurrentUser, PermissionLevel, PermissionObjectType } from 'App/context';
import Select from 'shared/components/Select';
import {
useGetTeamQuery,
@ -13,8 +13,6 @@ import {
useUpdateTeamMemberRoleMutation,
GetTeamQuery,
GetTeamDocument,
MeDocument,
MeQuery,
} from 'shared/generated/graphql';
import { UserPlus, Checkmark } from 'shared/icons';
import styled, { css } from 'styled-components/macro';
@ -22,6 +20,7 @@ import { usePopup, Popup } from 'shared/components/PopupMenu';
import TaskAssignee from 'shared/components/TaskAssignee';
import Member from 'shared/components/Member';
import ControlledInput from 'shared/components/ControlledInput';
import NOOP from 'shared/utils/noop';
const MemberListWrapper = styled.div`
flex: 1 1;
@ -129,6 +128,7 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
}
`}
`;
export const Content = styled.div`
padding: 0 12px 12px;
`;
@ -160,6 +160,7 @@ export const RemoveMemberButton = styled(Button)`
padding: 6px 12px;
width: 100%;
`;
type TeamRoleManagerPopupProps = {
currentUserID: string;
subject: User;
@ -253,7 +254,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
{subject.role && subject.role.code === 'owner' && (
<>
<Separator />
<WarningText>You can't change roles because there must be an owner.</WarningText>
<WarningText>You can not change roles because there must be an owner.</WarningText>
</>
)}
</MiniProfileActions>
@ -510,7 +511,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
<MemberList>
{data.findTeam.members.map(member => (
<MemberListItem>
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} />
<MemberProfile showRoleIcons size={32} onMemberProfile={NOOP} member={member} />
<MemberListItemDetails>
<MemberItemName>{member.fullName}</MemberItemName>
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>

View File

@ -1,24 +1,22 @@
import React, { useState, useContext, useEffect } from 'react';
import styled, { css } from 'styled-components/macro';
import { MENU_TYPES } from 'shared/components/TopNavbar';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import updateApolloCache from 'shared/utils/cache';
import { Route, Switch, useRouteMatch, Redirect } from 'react-router';
import Members from './Members';
import Projects from './Projects';
import { Route, Switch, useRouteMatch, Redirect, useParams, useHistory } from 'react-router';
import {
useGetTeamQuery,
useDeleteTeamMutation,
GetProjectsDocument,
GetProjectsQuery,
} from 'shared/generated/graphql';
import { useParams, useHistory, useLocation } from 'react-router';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { History } from 'history';
import produce from 'immer';
import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import UserContext, { PermissionObjectType, PermissionLevel, useCurrentUser } from 'App/context';
import { PermissionObjectType, PermissionLevel, useCurrentUser } from 'App/context';
import NOOP from 'shared/utils/noop';
import Members from './Members';
import Projects from './Projects';
const OuterWrapper = styled.div`
display: flex;
@ -59,7 +57,7 @@ export const TeamPopup: React.FC<TeamPopupProps> = ({ history, name, teamID }) =
<Popup title={null} tab={0}>
<TeamSettings
onDeleteTeam={() => {
setTab(1, 340);
setTab(1, { width: 340 });
}}
/>
</Popup>
@ -87,18 +85,30 @@ type TeamsRouteProps = {
const Teams = () => {
const { teamID } = useParams<TeamsRouteProps>();
const history = useHistory();
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
const { loading, data } = useGetTeamQuery({
variables: { teamID },
onCompleted: resp => {
document.title = `${resp.findTeam.name} | Taskcafé`;
},
});
const { user } = useCurrentUser();
const [currentTab, setCurrentTab] = useState(0);
const match = useRouteMatch();
useEffect(() => {
document.title = 'Teams | Taskcafé';
}, []);
if (loading) {
return (
<>
<span>loading</span>
</>
<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}
/>
);
}
if (data && user) {
@ -117,7 +127,7 @@ const Teams = () => {
setCurrentTab(tab);
}}
popupContent={<TeamPopup history={history} name={data.findTeam.name} teamID={teamID} />}
onSaveProjectName={() => {}}
onSaveProjectName={NOOP}
projectID={null}
name={data.findTeam.name}
/>

View File

@ -8,6 +8,7 @@ import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error';
import { enableMapSet } from 'immer';
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import moment from 'moment';
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
import cache from './App/cache';
import App from './App';
@ -15,6 +16,13 @@ import App from './App';
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
enableMapSet();
moment.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 = [];
@ -81,7 +89,7 @@ const errorLink = onError(({ graphQLErrors, networkError, operation, forward })
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`);
console.log(`[Network error]: ${networkError}`); // eslint-disable-line no-console
}
return undefined;
});
@ -122,12 +130,13 @@ const client = new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
graphQLErrors.forEach(
({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`), // eslint-disable-line no-console
);
}
if (networkError) {
console.log(`[Network error]: ${networkError}`);
console.log(`[Network error]: ${networkError}`); // eslint-disable-line no-console
}
}),
errorLink,

View File

@ -16,11 +16,12 @@ import {
} from './Styles';
type NameEditorProps = {
buttonLabel?: string;
onSave: (listName: string) => void;
onCancel: () => void;
};
const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCancel, buttonLabel = 'Save' }) => {
const $editorRef = useRef<HTMLTextAreaElement>(null);
const [listName, setListName] = useState('');
useEffect(() => {
@ -28,6 +29,11 @@ const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
$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();
@ -60,7 +66,7 @@ const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
}
}}
>
Save
{buttonLabel}
</AddListButton>
<CancelAdd onClick={() => onCancel()}>
<Cross width={16} height={16} />
@ -98,7 +104,7 @@ const AddList: React.FC<AddListProps> = ({ onSave }) => {
) : (
<Placeholder>
<AddIconWrapper>
<Plus size={12} color="#c2c6dc" />
<Plus width={12} height={12} />
</AddIconWrapper>
Add another list
</Placeholder>

View File

@ -1,10 +1,10 @@
import React, { useRef } from 'react';
import Admin from '.';
import { theme } from 'App/ThemeStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { action } from '@storybook/addon-actions';
import theme from 'App/ThemeStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import Admin from '.';
export default {
component: Admin,

View File

@ -1,15 +1,13 @@
import React, { useState, useRef } from 'react';
import { UserPlus, Checkmark } from 'shared/icons';
import styled, { css } from 'styled-components';
import TaskAssignee from 'shared/components/TaskAssignee';
import Select from 'shared/components/Select';
import { User, Plus, Lock, Pencil, Trash } from 'shared/icons';
import { User, UserPlus, Checkmark } from 'shared/icons';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { RoleCode, useUpdateUserRoleMutation } from 'shared/generated/graphql';
import Input from 'shared/components/Input';
import Member from 'shared/components/Member';
import Button from 'shared/components/Button';
import NOOP from 'shared/utils/noop';
export const RoleCheckmark = styled(Checkmark)`
padding-left: 4px;
@ -69,6 +67,7 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
}
`}
`;
export const Content = styled.div`
padding: 0 12px 12px;
`;
@ -100,6 +99,7 @@ export const RemoveMemberButton = styled(Button)`
padding: 6px 12px;
width: 100%;
`;
type TeamRoleManagerPopupProps = {
user: User;
users: Array<User>;
@ -120,7 +120,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
onChangeRole,
}) => {
const { hidePopup, setTab } = usePopup();
const [userPass, setUserPass] = useState({ pass: '', passConfirm: '' });
const [userPass] = useState({ pass: '', passConfirm: '' });
const [deleteUser, setDeleteUser] = useState<{ label: string; value: string } | null>(null);
const hasOwned = user.owned.projects.length !== 0 || user.owned.teams.length !== 0;
return (
@ -195,7 +195,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
{user.role && user.role.code === 'owner' && (
<>
<Separator />
<WarningText>You can't change roles because there must be an owner.</WarningText>
<WarningText>You can not change roles because there must be an owner.</WarningText>
</>
)}
</MiniProfileActions>
@ -209,7 +209,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
<>
<DeleteDescription>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} teams.`}</DeleteDescription>
<DeleteDescription>
Choose a new user to take over ownership of this user's teams & projects.
Choose a new user to take over ownership of the users teams & projects.
</DeleteDescription>
<UserSelect
onChange={v => setDeleteUser(v)}
@ -239,7 +239,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
</DeleteDescription>
<DeleteDescription>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} teams.`}</DeleteDescription>
<UserSelect onChange={() => {}} value={null} options={users.map(u => ({ label: u.fullName, value: u.id }))} />
<UserSelect onChange={NOOP} value={null} options={users.map(u => ({ label: u.fullName, value: u.id }))} />
<UserPassConfirmButton
onClick={() => {
// onDeleteUser();
@ -253,7 +253,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
<Content>
<DeleteDescription>
You can either set the user's new password directly or send the user an email allowing them to reset their
You can either set the users new password directly or send the user an email allowing them to reset their
own password.
</DeleteDescription>
<UserPassBar>
@ -291,6 +291,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
</>
);
};
const UserSelect = styled(Select)`
margin: 8px 0;
padding: 8px 0;
@ -299,6 +300,7 @@ const UserSelect = styled(Select)`
const NewUserPassInput = styled(Input)`
margin: 8px 0;
`;
const InviteMemberButton = styled(Button)`
padding: 7px 12px;
`;
@ -307,6 +309,7 @@ const UserPassBar = styled.div`
display: flex;
padding-top: 8px;
`;
const UserPassConfirmButton = styled(Button)`
width: 100%;
padding: 7px 12px;
@ -397,104 +400,6 @@ const MemberListWrapper = styled.div`
flex: 1 1;
`;
const Root = styled.div`
.ag-theme-material {
--ag-foreground-color: #c2c6dc;
--ag-secondary-foreground-color: #c2c6dc;
--ag-background-color: transparent;
--ag-header-background-color: transparent;
--ag-header-foreground-color: #c2c6dc;
--ag-border-color: #414561;
--ag-row-hover-color: #262c49;
--ag-header-cell-hover-background-color: #262c49;
--ag-checkbox-unchecked-color: #c2c6dc;
--ag-checkbox-indeterminate-color: rgba(115, 103, 240);
--ag-selected-row-background-color: #262c49;
--ag-material-primary-color: rgba(115, 103, 240);
--ag-material-accent-color: rgba(115, 103, 240);
}
.ag-theme-material ::-webkit-scrollbar {
width: 12px;
}
.ag-theme-material ::-webkit-scrollbar-track {
background: #262c49;
border-radius: 20px;
}
.ag-theme-material ::-webkit-scrollbar-thumb {
background: #7367f0;
border-radius: 20px;
}
.ag-header-cell-text {
color: #fff;
font-weight: 700;
}
`;
const Header = styled.div`
border-bottom: 1px solid #e2e2e2;
flex-direction: row;
box-sizing: border-box;
display: flex;
white-space: nowrap;
width: 100%;
overflow: hidden;
background: transparent;
border-bottom-color: #414561;
color: #fff;
height: 112px;
min-height: 112px;
`;
const EditUserIcon = styled(Pencil)``;
const LockUserIcon = styled(Lock)``;
const DeleteUserIcon = styled(Trash)``;
type ActionButtonProps = {
onClick: ($target: React.RefObject<HTMLElement>) => void;
};
const ActionButtonWrapper = styled.div`
margin-right: 8px;
cursor: pointer;
display: inline-flex;
`;
const ActionButton: React.FC<ActionButtonProps> = ({ onClick, children }) => {
const $wrapper = useRef<HTMLDivElement>(null);
return (
<ActionButtonWrapper onClick={() => onClick($wrapper)} ref={$wrapper}>
{children}
</ActionButtonWrapper>
);
};
const ActionButtons = (params: any) => {
return (
<>
<ActionButton onClick={() => {}}>
<EditUserIcon width={16} height={16} />
</ActionButton>
<ActionButton onClick={$target => params.onDeleteUser($target, params.value)}>
<DeleteUserIcon width={16} height={16} />
</ActionButton>
</>
);
};
const Wrapper = styled.div`
background: #eff2f7;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
`;
const Container = styled.div`
padding: 2.2rem;
display: flex;
@ -525,6 +430,7 @@ const TabNavItem = styled.li`
display: block;
position: relative;
`;
const TabNavItemButton = styled.button<{ active: boolean }>`
cursor: pointer;
display: flex;
@ -545,6 +451,10 @@ 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;
@ -607,7 +517,7 @@ const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
}}
>
<TabNavItemButton active={active}>
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
<TabItemUser width={14} height={14} active={active} />
<TabNavItemSpan>{name}</TabNavItemSpan>
</TabNavItemButton>
</TabNavItem>
@ -637,7 +547,7 @@ const Admin: React.FC<AdminProps> = ({
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
const [currentTop, setTop] = useState(initialTab * 48);
const [currentTab, setTab] = useState(initialTab);
const { showPopup, hidePopup } = usePopup();
const { showPopup } = usePopup();
const $tabNav = useRef<HTMLDivElement>(null);
const [updateUserRole] = useUpdateUserRoleMutation();
@ -647,6 +557,7 @@ 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();
@ -690,7 +601,7 @@ const Admin: React.FC<AdminProps> = ({
const projectTotal = member.owned.projects.length + member.member.projects.length;
return (
<MemberListItem>
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} />
<MemberProfile showRoleIcons size={32} onMemberProfile={NOOP} member={member} />
<MemberListItemDetails>
<MemberItemName>{member.fullName}</MemberItemName>
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>

View File

@ -2,7 +2,7 @@ import React from 'react';
import { action } from '@storybook/addon-actions';
import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles';
import theme from 'App/ThemeStyles';
import styled, { ThemeProvider } from 'styled-components';
import Button from '.';

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 }>`
const Text = styled.span<{ fontSize: string; justifyTextContent: string; hasIcon?: boolean }>`
position: relative;
display: flex;
align-items: center;
@ -9,6 +9,11 @@ const Text = styled.span<{ fontSize: string; justifyTextContent: string }>`
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 }>`
@ -18,6 +23,8 @@ 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 &&
@ -34,16 +41,28 @@ const Filled = styled(Base)`
box-shadow: 0 8px 25px -8px rgba(${props => props.theme.colors[props.color]});
}
`;
const Outline = styled(Base)`
const Outline = styled(Base)<{ invert: boolean }>`
border: 1px solid rgba(${props => props.theme.colors[props.color]});
background: transparent;
& ${Text} {
color: rgba(${props => props.theme.colors[props.color]});
}
&:hover {
background: rgba(${props => props.theme.colors[props.color]}, 0.08);
}
${props =>
props.invert
? css`
background: rgba(${props.theme.colors[props.color]});
& ${Text} {
color: rgba(${props.theme.colors.text.secondary});
}
&:hover {
background: rgba(${props.theme.colors[props.color]}, 0.8);
}
`
: css`
& ${Text} {
color: rgba(${props.theme.colors[props.color]});
}
&:hover {
background: rgba(${props.theme.colors[props.color]}, 0.08);
}
`}
`;
const Flat = styled(Base)`
@ -110,6 +129,8 @@ type ButtonProps = {
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;
@ -118,10 +139,12 @@ type ButtonProps = {
const Button: React.FC<ButtonProps> = ({
disabled = false,
fontSize = '14px',
invert = false,
color = 'primary',
variant = 'filled',
type = 'button',
justifyTextContent = 'center',
icon,
onClick,
className,
children,
@ -136,7 +159,8 @@ const Button: React.FC<ButtonProps> = ({
case 'filled':
return (
<Filled ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}>
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}>
{icon && icon}
<Text hasIcon={typeof icon !== 'undefined'} justifyTextContent={justifyTextContent} fontSize={fontSize}>
{children}
</Text>
</Filled>
@ -145,6 +169,7 @@ const Button: React.FC<ButtonProps> = ({
return (
<Outline
ref={$button}
invert={invert}
type={type}
onClick={handleClick}
className={className}

View File

@ -1,9 +1,7 @@
import styled, { css, keyframes } from 'styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mixin } from 'shared/utils/styles';
import TextareaAutosize from 'react-autosize-textarea';
import { CheckCircle, CheckSquareOutline } from 'shared/icons';
import { RefObject } from 'react';
import { CheckCircle, CheckSquareOutline, Clock } from 'shared/icons';
import TaskAssignee from 'shared/components/TaskAssignee';
export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
@ -20,7 +18,9 @@ export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'no
stroke: rgba(${props.theme.colors.success});
`}
`;
export const ClockIcon = styled(FontAwesomeIcon)``;
export const ClockIcon = styled(Clock)<{ color: string }>`
fill: ${props => props.color};
`;
export const EditorTextarea = styled(TextareaAutosize)`
overflow: hidden;
@ -147,6 +147,11 @@ 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'
@ -178,8 +183,6 @@ 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,7 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons';
import { Pencil, Eye, List } from 'shared/icons';
import {
EditorTextarea,
CardMember,
@ -20,11 +18,11 @@ import {
ListCardLabels,
ListCardLabel,
ListCardLabelText,
ListCardLabelsWrapper,
ListCardOperation,
CardTitle,
CardMembers,
} from './Styles';
import { CheckSquare } from 'shared/icons';
type DueDate = {
isPastDue: boolean;
@ -155,39 +153,42 @@ const Card = React.forwardRef(
}
}}
>
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
<Pencil width={8} height={8} />
</ListCardOperation>
)}
<ListCardDetails complete={complete ?? false}>
<ListCardLabels
toggleLabels={toggleLabels}
toggleDirection={toggleDirection}
onClick={e => {
e.stopPropagation();
if (onCardLabelClick) {
onCardLabelClick();
}
}}
>
{labels &&
labels
.slice()
.sort((a, b) => a.labelColor.position - b.labelColor.position)
.map(label => (
<ListCardLabel
onAnimationEnd={() => {
if (setToggleLabels) {
setToggleLabels(false);
}
}}
variant={labelVariant ?? 'large'}
color={label.labelColor.colorHex}
key={label.id}
>
<ListCardLabelText>{label.name}</ListCardLabelText>
</ListCardLabel>
))}
</ListCardLabels>
{labels && labels.length !== 0 && (
<ListCardLabelsWrapper>
<ListCardLabels
toggleLabels={toggleLabels}
toggleDirection={toggleDirection}
onClick={e => {
e.stopPropagation();
if (onCardLabelClick) {
onCardLabelClick();
}
}}
>
{labels
.slice()
.sort((a, b) => a.labelColor.position - b.labelColor.position)
.map(label => (
<ListCardLabel
onAnimationEnd={() => {
if (setToggleLabels) {
setToggleLabels(false);
}
}}
variant={labelVariant ?? 'large'}
color={label.labelColor.colorHex}
key={label.id}
>
<ListCardLabelText>{label.name}</ListCardLabelText>
</ListCardLabel>
))}
</ListCardLabels>
</ListCardLabelsWrapper>
)}
{editable ? (
<EditorContent>
{complete && <CompleteIcon width={16} height={16} />}
@ -209,24 +210,24 @@ const Card = React.forwardRef(
) : (
<CardTitle>
{complete && <CompleteIcon width={16} height={16} />}
{`${title}${position ? ' - ' + position : ''}`}
{`${title}${position ? ` - ${position}` : ''}`}
</CardTitle>
)}
<ListCardBadges>
{watched && (
<ListCardBadge>
<FontAwesomeIcon color="#6b778c" icon={faEye} size="xs" />
<Eye width={8} height={8} />
</ListCardBadge>
)}
{dueDate && (
<DueDateCardBadge isPastDue={dueDate.isPastDue}>
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} icon={faClock} size="xs" />
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} width={8} height={8} />
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
</DueDateCardBadge>
)}
{description && (
<DescriptionBadge>
<FontAwesomeIcon color="#6b778c" icon={faList} size="xs" />
<List width={8} height={8} />
</DescriptionBadge>
)}
{checklists && (
@ -236,9 +237,9 @@ const Card = React.forwardRef(
width={8}
height={8}
/>
<ListCardBadgeText
color={checklists.complete === checklists.total ? 'success' : 'normal'}
>{`${checklists.complete}/${checklists.total}`}</ListCardBadgeText>
<ListCardBadgeText color={checklists.complete === checklists.total ? 'success' : 'normal'}>
{`${checklists.complete}/${checklists.total}`}
</ListCardBadgeText>
</ListCardBadge>
)}
</ListCardBadges>

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 CancelIcon = styled(FontAwesomeIcon)`
export const CancelIconWrapper = styled.div`
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,
CancelIcon,
CancelIconWrapper,
AddCardButton,
ComposerControls,
ComposerControlsSaveSection,
@ -52,7 +52,9 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
>
Add Card
</AddCardButton>
<CancelIcon onClick={onClose} icon={faTimes} color="#42526e" />
<CancelIconWrapper onClick={() => onClose()}>
<Cross width={12} height={12} />
</CancelIconWrapper>
</ComposerControlsSaveSection>
<ComposerControlsActionsSection />
</ComposerControls>

View File

@ -2,9 +2,10 @@ import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles';
import theme from 'App/ThemeStyles';
import produce from 'immer';
import styled, { ThemeProvider } from 'styled-components';
import NOOP from 'shared/utils/noop';
import Checklist, { ChecklistItem } from '.';
export default {
@ -132,7 +133,7 @@ export const Default = () => {
}}
onToggleItem={onToggleItem}
>
{items.map((item, idx) => (
{items.map(item => (
<ChecklistItem
key={item.id}
wrapperProps={{}}
@ -141,9 +142,9 @@ export const Default = () => {
itemID={item.id}
name={item.name}
complete={item.complete}
onDeleteItem={() => {}}
onChangeName={() => {}}
onToggleItem={() => {}}
onDeleteItem={NOOP}
onChangeName={NOOP}
onToggleItem={NOOP}
/>
))}
</Checklist>

View File

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

View File

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

@ -1,11 +1,11 @@
import React from 'react';
import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles';
import theme from 'App/ThemeStyles';
import styled, { ThemeProvider } from 'styled-components';
import { User } from 'shared/icons';
import Input from '.';
import { User } from 'shared/icons';
export default {
component: Input,
@ -35,7 +35,7 @@ export const Default = () => {
<Wrapper>
<Input label="Label placeholder" />
<Input width="100%" placeholder="Placeholder" />
<Input icon={<User size={20} />} width="100%" placeholder="Placeholder" />
<Input icon={<User width={20} height={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 size={16} color="#c2c6dc" />
<User width={16} height={16} />
<ActionTitle>Profile</ActionTitle>
</ActionItem>
<Separator />
@ -54,7 +54,7 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ showAdminConsole, onAdminCons
</>
)}
<ActionItem onClick={onProfile}>
<User size={16} color="#c2c6dc" />
<User width={16} height={16} />
<ActionTitle>Profile</ActionTitle>
</ActionItem>
<ActionsList>

View File

@ -1,8 +1,8 @@
import React, { useRef } from 'react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles';
import theme from 'App/ThemeStyles';
import styled, { ThemeProvider } from 'styled-components';
import { Popup } from '../PopupMenu';
import DueDateManager from '.';

View File

@ -2,22 +2,13 @@ import React, { useState, useEffect, forwardRef } from 'react';
import moment from 'moment';
import styled from 'styled-components';
import DatePicker from 'react-datepicker';
import { Cross } from 'shared/icons';
import _ from 'lodash';
import {
Wrapper,
ActionWrapper,
RemoveDueDate,
DueDateInput,
DueDatePickerWrapper,
ConfirmAddDueDate,
CancelDueDate,
} from './Styles';
import 'react-datepicker/dist/react-datepicker.css';
import { getYear, getMonth } from 'date-fns';
import { useForm, Controller } from 'react-hook-form';
import NOOP from 'shared/utils/noop';
import { Wrapper, ActionWrapper, RemoveDueDate, DueDateInput, DueDatePickerWrapper, ConfirmAddDueDate } from './Styles';
type DueDateManagerProps = {
task: Task;
@ -120,17 +111,13 @@ const HeaderActions = styled.div`
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
const now = moment();
const [textStartDate, setTextStartDate] = useState(now.format('YYYY-MM-DD'));
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
const [startDate, setStartDate] = useState(new Date());
useEffect(() => {
setTextStartDate(moment(startDate).format('YYYY-MM-DD'));
}, [startDate]);
const [textEndTime, setTextEndTime] = useState(now.format('h:mm A'));
const [endTime, setEndTime] = useState(now.toDate());
useEffect(() => {
setTextEndTime(moment(endTime).format('h:mm A'));
}, [endTime]);
const newDate = moment(startDate).format('YYYY-MM-DD');
setValue('endDate', newDate);
}, [startDate]);
const years = _.range(2010, getYear(new Date()) + 10, 1);
const months = [
@ -147,9 +134,8 @@ 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 = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A');
const newDate = moment(`${data.endDate} ${moment(data.endTime).format('h:mm A')}`, 'YYYY-MM-DD h:mm A');
if (newDate.isValid()) {
onDueDateChange(task, newDate.toDate());
}
@ -158,11 +144,12 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
return (
<DueDateInput
id="endTime"
value={value}
name="endTime"
ref={$ref}
width="100%"
variant="alternate"
label="Date"
label="Time"
onClick={onClick}
/>
);
@ -177,7 +164,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
width="100%"
variant="alternate"
label="Date"
defaultValue={textStartDate}
defaultValue={now.format('YYYY-MM-DD')}
ref={register({
required: 'End date is required.',
})}
@ -186,6 +173,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
<FormField>
<Controller
control={control}
defaultValue={now.toDate()}
name="endTime"
render={({ onChange, onBlur, value }) => (
<DatePicker
@ -220,7 +208,10 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
</HeaderButton>
<HeaderSelectLabel>
{months[date.getMonth()]}
<HeaderSelect value={getYear(date)} onChange={({ target: { value } }) => changeYear(parseInt(value))}>
<HeaderSelect
value={getYear(date)}
onChange={({ target: { value } }) => changeYear(parseInt(value, 10))}
>
{years.map(option => (
<option key={option} value={option}>
{option}
@ -250,12 +241,14 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
selected={startDate}
inline
onChange={date => {
setStartDate(date ?? new Date());
if (date) {
setStartDate(date);
}
}}
/>
</DueDatePickerWrapper>
<ActionWrapper>
<ConfirmAddDueDate type="submit" onClick={() => {}}>
<ConfirmAddDueDate type="submit" onClick={NOOP}>
Save
</ConfirmAddDueDate>
<RemoveDueDate

View File

@ -1,11 +1,11 @@
import React from 'react';
import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles';
import theme from 'App/ThemeStyles';
import styled, { ThemeProvider } from 'styled-components';
import { User } from 'shared/icons';
import Input from '.';
import { User } from 'shared/icons';
export default {
component: Input,
@ -35,7 +35,7 @@ export const Default = () => {
<Wrapper>
<Input label="Label placeholder" />
<Input width="100%" placeholder="Placeholder" />
<Input icon={<User size={20} />} width="100%" placeholder="Placeholder" />
<Input icon={<User width={20} height={20} />} width="100%" placeholder="Placeholder" />
</Wrapper>
</ThemeProvider>
</>

View File

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

View File

@ -3,6 +3,7 @@ import { action } from '@storybook/addon-actions';
import Card from 'shared/components/Card';
import CardComposer from 'shared/components/CardComposer';
import LabelColors from 'shared/constants/labelColors';
import NOOP from 'shared/utils/noop';
import List, { ListCards } from '.';
export default {
@ -60,7 +61,7 @@ export const Default = () => {
onExtraMenuOpen={action('extra menu open')}
>
<ListCards>
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen={false} />
<CardComposer onClose={NOOP} onCreateCard={NOOP} isOpen={false} />
</ListCards>
</List>
);
@ -77,7 +78,7 @@ export const WithCardComposer = () => {
onExtraMenuOpen={action('extra menu open')}
>
<ListCards>
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen />
<CardComposer onClose={NOOP} onCreateCard={NOOP} isOpen />
</ListCards>
</List>
);
@ -108,7 +109,7 @@ export const WithCard = () => {
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen={false} />
<CardComposer onClose={NOOP} onCreateCard={NOOP} isOpen={false} />
</ListCards>
</List>
);
@ -138,7 +139,7 @@ export const WithCardAndComposer = () => {
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen />
<CardComposer onClose={NOOP} onCreateCard={NOOP} isOpen />
</ListCards>
</List>
);

View File

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

View File

@ -1,18 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import ListActions from '.';
export default {
component: ListActions,
title: 'ListActions',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
export const Default = () => {
return <ListActions taskGroupID="1" onArchiveTaskGroup={action('on archive task group')} />;
};

View File

@ -1,50 +1,100 @@
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, onArchiveTaskGroup }) => {
const LabelManager: React.FC<Props> = ({
taskGroupID,
onDeleteTaskGroupTasks,
onDuplicateTaskGroup,
onArchiveTaskGroup,
onSortTaskGroup,
}) => {
const { setTab } = usePopup();
return (
<InnerContent>
<ListActionsWrapper>
<ListActionItemWrapper>
<ListActionItem>Add card...</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper>
<ListActionItem>Copy List...</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper>
<ListActionItem>Move card...</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper>
<ListActionItem>Watch</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
<ListSeparator />
<ListActionsWrapper>
<ListActionItemWrapper>
<ListActionItem>Sort By...</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
<ListSeparator />
<ListActionsWrapper>
<ListActionItemWrapper>
<ListActionItem>Move All Cards in This List...</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper>
<ListActionItem>Archive All Cards in This List...</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
<ListSeparator />
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => onArchiveTaskGroup(taskGroupID)}>
<ListActionItem>Archive This List</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
</InnerContent>
<>
<Popup tab={0} title={null}>
<InnerContent>
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => setTab(1)}>
<ListActionItem>Duplicate</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper onClick={() => setTab(2)}>
<ListActionItem>Sort</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
<ListSeparator />
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => onDeleteTaskGroupTasks()}>
<ListActionItem>Delete All Tasks</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
<ListSeparator />
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => onArchiveTaskGroup(taskGroupID)}>
<ListActionItem>Delete</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,10 +4,8 @@ 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;
@ -35,10 +33,9 @@ export const BoardWrapper = styled.div`
user-select: none;
white-space: nowrap;
margin-bottom: 8px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 8px;
padding-bottom: 4px;
position: absolute;
top: 0;
right: 0;

View File

@ -11,8 +11,131 @@ import {
getAfterDropDraggableList,
} from 'shared/utils/draggables';
import moment from 'moment';
import { TaskSorting, TaskSortingType, TaskSortingDirection, sortTasks } from 'shared/utils/sorting';
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 | moment.Moment | 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 = moment(task.completedAt);
const REFERENCE = moment(); // fixed just for testing, use moment();
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, 'days')
.startOf('day');
return completedAt.isSameOrAfter(YESTERDAY, 'd');
case TaskSince.ONE_WEEK:
const ONE_WEEK = REFERENCE.clone()
.subtract(7, 'days')
.startOf('day');
return completedAt.isSameOrAfter(ONE_WEEK, 'd');
case TaskSince.TWO_WEEKS:
const TWO_WEEKS = REFERENCE.clone()
.subtract(14, 'days')
.startOf('day');
return completedAt.isSameOrAfter(TWO_WEEKS, 'd');
case TaskSince.THREE_WEEKS:
const THREE_WEEKS = REFERENCE.clone()
.subtract(21, 'days')
.startOf('day');
return completedAt.isSameOrAfter(THREE_WEEKS, 'd');
default:
return true;
}
}
return false;
}
interface SimpleProps {
taskGroups: Array<TaskGroup>;
@ -28,8 +151,29 @@ 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,
@ -43,6 +187,9 @@ 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;
@ -81,7 +228,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
position: newPosition,
});
} else {
throw { error: 'task group can not be found' };
throw new Error('task group can not be found');
}
} else {
const curTaskGroup = taskGroups.findIndex(
@ -164,10 +311,18 @@ 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}>
<Draggable
key={task.id}
draggableId={task.id}
index={taskIndex}
isDragDisabled={taskSorting.type !== TaskSortingType.NONE}
>
{taskProvided => {
return (
<Card

View File

@ -0,0 +1,132 @@
import { TaskMetaFilters, DueDateFilterType } from 'shared/components/Lists';
import moment from 'moment';
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 = moment(task.dueDate);
const today = moment();
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, 'days')
.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, 'days')
.startOf('day');
end = today
.clone()
.weekday(6)
.add(7, 'days')
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
case DueDateFilterType.ONE_WEEK:
start = today.clone().startOf('day');
end = today
.clone()
.add(7, 'days')
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
case DueDateFilterType.TWO_WEEKS:
start = today.clone().startOf('day');
end = today
.clone()
.add(14, 'days')
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
case DueDateFilterType.THREE_WEEKS:
start = today.clone().startOf('day');
end = today
.clone()
.add(21, 'days')
.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

@ -53,7 +53,7 @@ const Login = ({ onSubmit }: LoginProps) => {
ref={register({ required: 'Username is required' })}
/>
<FormIcon>
<User color="#c2c6dc" size={20} />
<User width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.username && <FormError>{errors.username.message}</FormError>}

View File

@ -2,6 +2,7 @@ import React, { useRef } from 'react';
import styled from 'styled-components';
import TaskAssignee from 'shared/components/TaskAssignee';
import { Checkmark } from 'shared/icons';
import NOOP from 'shared/utils/noop';
const CardCheckmark = styled(Checkmark)`
position: absolute;
@ -43,6 +44,7 @@ type MemberProps = {
showName?: boolean;
className?: string;
showCheckmark?: boolean;
size?: number;
};
const CardMemberWrapper = styled.div<{ ref: any }>`
@ -62,6 +64,7 @@ const Member: React.FC<MemberProps> = ({
showName,
showCheckmark = false,
className,
size = 28,
}) => {
const $targetRef = useRef<HTMLDivElement>();
return (
@ -76,7 +79,7 @@ const Member: React.FC<MemberProps> = ({
}
}}
>
<TaskAssignee onMemberProfile={() => {}} size={28} member={member} />
<TaskAssignee onMemberProfile={NOOP} size={32} member={member} />
{showName && <CardMemberName>{member.fullName}</CardMemberName>}
{showCheckmark && <CardCheckmark width={12} height={12} />}
</CardMemberWrapper>

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { Checkmark } from 'shared/icons';
import Member from 'shared/components/Member';
import {
MemberName,
ProfileIcon,
@ -12,8 +13,6 @@ import {
BoardMemberListItemContent,
ActiveIconWrapper,
} from './Styles';
import { Checkmark } from 'shared/icons';
import Member from 'shared/components/Member';
type MemberManagerProps = {
availableMembers: Array<TaskUser>;

View File

@ -160,7 +160,7 @@ const MiniProfile: React.FC<MiniProfileProps> = ({
{user.role && user.role.code === 'owner' && (
<>
<Separator />
<WarningText>You can't change roles because there must be an owner.</WarningText>
<WarningText>You can not change roles because there must be an owner.</WarningText>
</>
)}
</MiniProfileActions>

View File

@ -15,18 +15,20 @@ 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 }>`
display: inline-block;
export const StyledModal = styled.div<{ width: number; height: number }>`
position: relative;
margin: 48px 0 80px;
width: 100%;
width: ${props => props.width}px;
height: ${props => props.height}px;
left: 0;
right: 0;
top: 48px;
bottom: 16px;
margin: auto;
background: #262c49;
max-width: ${props => props.width}px;
vertical-align: middle;
border-radius: 3px;
border-radius: 6px;
${mixin.boxShadowMedium}
`;

View File

@ -1,9 +1,10 @@
import React, { useRef } from 'react';
import React, { useRef, useEffect, useState } 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
@ -14,21 +15,50 @@ type ModalProps = {
renderContent: () => JSX.Element;
};
const Modal: React.FC<ModalProps> = ({ width, onClose: tellParentToClose, renderContent }) => {
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 $modalRef = useRef<HTMLDivElement>(null);
const $clickableOverlayRef = useRef<HTMLDivElement>(null);
const [_width, height] = useWindowSize();
useOnOutsideClick($modalRef, true, tellParentToClose, $clickableOverlayRef);
useOnEscapeKeyDown(true, tellParentToClose);
return ReactDOM.createPortal(
return (
<ScrollOverlay>
<ClickableOverlay ref={$clickableOverlayRef}>
<StyledModal width={width} ref={$modalRef}>
<StyledModal width={width} height={getAdjustedHeight(height)} ref={$modalRef}>
{renderContent()}
<CloseIcon onClick={() => tellParentToClose()} width={20} height={20} />
</StyledModal>
</ClickableOverlay>
</ScrollOverlay>,
</ScrollOverlay>
);
};
const Modal: React.FC<ModalProps> = ({ width, onClose: tellParentToClose, renderContent }) => {
return ReactDOM.createPortal(
<InnerModal width={width} onClose={tellParentToClose} renderContent={renderContent} />,
$root,
);
};

View File

@ -1,9 +1,8 @@
import React, { useState, useRef, createRef } from 'react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import styled from 'styled-components';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import NOOP from 'shared/utils/noop';
import NewProject from '.';
export default {
@ -26,7 +25,7 @@ export const Default = () => {
initialTeamID={null}
onCreateProject={action('create project')}
teams={[{ name: 'General', id: 'general', createdAt: '' }]}
onClose={() => {}}
onClose={NOOP}
/>
</>
);

View File

@ -4,6 +4,19 @@ import { mixin } from 'shared/utils/styles';
import Select from 'react-select';
import { ArrowLeft, Cross } from 'shared/icons';
function getBackgroundColor(isDisabled: boolean, isSelected: boolean, isFocused: boolean) {
if (isDisabled) {
return null;
}
if (isSelected) {
return mixin.darken('#262c49', 0.25);
}
if (isFocused) {
return mixin.darken('#262c49', 0.15);
}
return null;
}
const Overlay = styled.div`
z-index: 10000;
background: #262c49;
@ -149,14 +162,8 @@ const colourStyles = {
option: (styles: any, { data, isDisabled, isFocused, isSelected }: any) => {
return {
...styles,
backgroundColor: isDisabled
? null
: isSelected
? mixin.darken('#262c49', 0.25)
: isFocused
? mixin.darken('#262c49', 0.15)
: null,
color: isDisabled ? '#ccc' : isSelected ? '#fff' : '#c2c6dc',
backgroundColor: getBackgroundColor(isDisabled, isSelected, isFocused),
color: isDisabled ? '#ccc' : isSelected ? '#fff' : '#c2c6dc', // eslint-disable-line
cursor: isDisabled ? 'not-allowed' : 'default',
':active': {
...styles[':active'],

View File

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

@ -1,381 +0,0 @@
import React, { useState, useRef, createRef } from 'react';
import { action } from '@storybook/addon-actions';
import LabelColors from 'shared/constants/labelColors';
import LabelManager from 'shared/components/PopupMenu/LabelManager';
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
import ListActions from 'shared/components/ListActions';
import MemberManager from 'shared/components/MemberManager';
import DueDateManager from 'shared/components/DueDateManager';
import MiniProfile from 'shared/components/MiniProfile';
import styled from 'styled-components';
import produce from 'immer';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import PopupMenu, { PopupProvider, usePopup, Popup } from '.';
export default {
component: PopupMenu,
title: 'PopupMenu',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const labelData: Array<ProjectLabel> = [
{
id: 'development',
name: 'Development',
createdDate: new Date().toString(),
labelColor: {
id: '1',
colorHex: LabelColors.BLUE,
name: 'blue',
position: 1,
},
},
];
const OpenLabelBtn = styled.span``;
type TabProps = {
tab: number;
};
const LabelManagerEditor = () => {
const [labels, setLabels] = useState(labelData);
const [currentLabel, setCurrentLabel] = useState('');
const { setTab } = usePopup();
return (
<>
<Popup title="Labels" tab={0} onClose={action('on close')}>
<LabelManager
labels={labels}
onLabelCreate={() => {
setTab(2);
}}
onLabelEdit={labelId => {
setCurrentLabel(labelId);
setTab(1);
}}
onLabelToggle={labelId => {
setLabels(
produce(labels, draftState => {
const idx = labels.findIndex(label => label.id === labelId);
if (idx !== -1) {
draftState[idx] = { ...draftState[idx] };
}
}),
);
}}
/>
</Popup>
<Popup onClose={action('on close')} title="Edit label" tab={1}>
<LabelEditor
labelColors={[{ id: '1', colorHex: '#c2c6dc', position: 1, name: 'gray' }]}
label={labels.find(label => label.id === currentLabel) ?? null}
onLabelEdit={(_labelId, name, color) => {
setLabels(
produce(labels, draftState => {
const idx = labels.findIndex(label => label.id === currentLabel);
if (idx !== -1) {
draftState[idx] = {
...draftState[idx],
name,
labelColor: {
...draftState[idx].labelColor,
name: color.name ?? '',
colorHex: color.colorHex,
},
};
}
}),
);
setTab(0);
}}
/>
</Popup>
<Popup onClose={action('on close')} title="Create new label" tab={2}>
<LabelEditor
label={null}
labelColors={[{ id: '1', colorHex: '#c2c6dc', position: 1, name: 'gray' }]}
onLabelEdit={(_labelId, name, color) => {
setLabels([
...labels,
{
id: name,
name,
createdDate: new Date().toString(),
labelColor: {
id: color.id,
colorHex: color.colorHex,
name: color.name ?? '',
position: 1,
},
},
]);
setTab(0);
}}
/>
</Popup>
</>
);
};
const OpenLabelsButton = () => {
const $buttonRef = createRef<HTMLButtonElement>();
const [currentLabel, setCurrentLabel] = useState('');
const [labels, setLabels] = useState(labelData);
const { showPopup, setTab } = usePopup();
return (
<OpenLabelBtn
ref={$buttonRef}
onClick={() => {
showPopup($buttonRef, <LabelManagerEditor />);
}}
>
Open
</OpenLabelBtn>
);
};
export const LabelsPopup = () => {
const [isPopupOpen, setPopupOpen] = useState(false);
return (
<PopupProvider>
<OpenLabelsButton />
</PopupProvider>
);
};
export const LabelsLabelEditor = () => {
const [isPopupOpen, setPopupOpen] = useState(false);
return (
<>
{isPopupOpen && (
<PopupMenu
onPrevious={action('on previous')}
title="Change Label"
top={10}
onClose={() => setPopupOpen(false)}
left={10}
>
<LabelEditor
label={labelData[0]}
onLabelEdit={action('label edit')}
labelColors={[{ id: '1', colorHex: '#c2c6dc', position: 1, name: 'gray' }]}
/>
</PopupMenu>
)}
<button type="submit" onClick={() => setPopupOpen(true)}>
Open
</button>
</>
);
};
const initalState = { left: 0, top: 0, isOpen: false };
export const ListActionsPopup = () => {
const $buttonRef = useRef<HTMLButtonElement>(null);
const [popupData, setPopupData] = useState(initalState);
return (
<>
{popupData.isOpen && (
<PopupMenu
title="List Actions"
top={popupData.top}
onClose={() => setPopupData(initalState)}
left={popupData.left}
>
<ListActions taskGroupID="1" onArchiveTaskGroup={action('archive task group')} />
</PopupMenu>
)}
<button
ref={$buttonRef}
type="submit"
onClick={() => {
if ($buttonRef && $buttonRef.current) {
const pos = $buttonRef.current.getBoundingClientRect();
setPopupData({
isOpen: true,
left: pos.left,
top: pos.top + pos.height + 10,
});
}
}}
>
Open
</button>
</>
);
};
export const MemberManagerPopup = () => {
const $buttonRef = useRef<HTMLButtonElement>(null);
const [popupData, setPopupData] = useState(initalState);
return (
<>
<NormalizeStyles />
<BaseStyles />
{popupData.isOpen && (
<PopupMenu title="Members" top={popupData.top} onClose={() => setPopupData(initalState)} left={popupData.left}>
<MemberManager
availableMembers={[
{
id: '1',
fullName: 'Jordan Knott',
profileIcon: { bgColor: null, url: null, initials: null },
},
]}
activeMembers={[]}
onMemberChange={action('member change')}
/>
</PopupMenu>
)}
<span
ref={$buttonRef}
onClick={() => {
if ($buttonRef && $buttonRef.current) {
const pos = $buttonRef.current.getBoundingClientRect();
setPopupData({
isOpen: true,
left: pos.left,
top: pos.top + pos.height + 10,
});
}
}}
>
Open
</span>
</>
);
};
export const DueDateManagerPopup = () => {
const $buttonRef = useRef<HTMLButtonElement>(null);
const [popupData, setPopupData] = useState(initalState);
return (
<>
<NormalizeStyles />
<BaseStyles />
{popupData.isOpen && (
<PopupMenu title="Due Date" top={popupData.top} onClose={() => setPopupData(initalState)} left={popupData.left}>
<DueDateManager
onRemoveDueDate={action('remove due date')}
task={{
id: '1',
taskGroup: { name: 'General', id: '1', position: 1 },
name: 'Hello, world',
position: 1,
labels: [
{
id: 'soft-skills',
assignedDate: new Date().toString(),
projectLabel: {
createdDate: new Date().toString(),
id: 'label-soft-skills',
name: 'Soft Skills',
labelColor: {
id: '1',
name: 'white',
colorHex: '#fff',
position: 1,
},
},
},
],
description: 'hello!',
assigned: [
{
id: '1',
profileIcon: { bgColor: null, url: null, initials: null },
fullName: 'Jordan Knott',
},
],
}}
onCancel={action('cancel')}
onDueDateChange={action('due date change')}
/>
</PopupMenu>
)}
<span
style={{
width: '60px',
textAlign: 'center',
margin: '25px auto',
cursor: 'pointer',
}}
ref={$buttonRef}
onClick={() => {
if ($buttonRef && $buttonRef.current) {
const pos = $buttonRef.current.getBoundingClientRect();
setPopupData({
isOpen: true,
left: pos.left,
top: pos.top + pos.height + 10,
});
}
}}
>
Open
</span>
</>
);
};
export const MiniProfilePopup = () => {
const $buttonRef = useRef<HTMLButtonElement>(null);
const [popupData, setPopupData] = useState(initalState);
return (
<>
<NormalizeStyles />
<BaseStyles />
{popupData.isOpen && (
<PopupMenu
noHeader
title="Due Date"
top={popupData.top}
onClose={() => setPopupData(initalState)}
left={popupData.left}
>
<MiniProfile
user={{
id: '1',
fullName: 'Jordan Knott',
username: 'jordanthedev',
profileIcon: { url: null, bgColor: '#000', initials: 'JK' },
}}
bio="Stuff and things"
onRemoveFromTask={action('mini profile')}
/>
</PopupMenu>
)}
<span
style={{
width: '60px',
textAlign: 'center',
margin: '25px auto',
cursor: 'pointer',
color: '#fff',
background: '#f00',
}}
ref={$buttonRef}
onClick={() => {
if ($buttonRef && $buttonRef.current) {
const pos = $buttonRef.current.getBoundingClientRect();
setPopupData({
isOpen: true,
left: pos.left,
top: pos.top + pos.height + 10,
});
}
}}
>
Open
</span>
</>
);
};

View File

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

View File

@ -2,6 +2,7 @@ import React, { useRef, createContext, RefObject, useState, useContext, useEffec
import { Cross, AngleLeft } from 'shared/icons';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { createPortal } from 'react-dom';
import NOOP from 'shared/utils/noop';
import produce from 'immer';
import {
Container,
@ -14,9 +15,37 @@ 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, width?: string | number) => void;
setTab: (newTab: number, width?: number | string) => void;
show: (target: RefObject<HTMLElement>, content: JSX.Element, options?: PopupOptions) => void;
setTab: (newTab: number, options?: PopupOptions) => void;
getCurrentTab: () => number;
hide: () => void;
};
@ -25,23 +54,44 @@ 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 }) => {
const PopupContainer: React.FC<PopupContainerProps> = ({
width,
top,
left,
onClose,
children,
invert,
invertY,
targetPadding,
}) => {
const $containerRef = useRef<HTMLDivElement>(null);
const [currentTop, setCurrentTop] = useState(top);
useOnOutsideClick($containerRef, true, onClose, null);
return (
<Container width={width ?? 316} left={left} top={currentTop} ref={$containerRef} invert={invert} invertY={invertY}>
<Container
targetPadding={targetPadding}
width={width ?? 316}
left={left}
top={currentTop}
ref={$containerRef}
invert={invert}
invertY={invertY}
>
{children}
</Container>
);
@ -52,10 +102,10 @@ PopupContainer.defaultProps = {
};
const PopupContext = createContext<PopupContextState>({
show: () => {},
setTab: () => {},
show: NOOP,
setTab: NOOP,
getCurrentTab: () => 0,
hide: () => {},
hide: NOOP,
});
export const usePopup = () => {
@ -72,13 +122,28 @@ type PopupState = {
currentTab: number;
previousTab: number;
content: JSX.Element | null;
width?: string | number;
options: PopupOptionsInternal | null;
};
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,
@ -88,11 +153,12 @@ 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, width?: number | string) => {
const show = (target: RefObject<HTMLElement>, content: JSX.Element, options?: PopupOptions) => {
if (target && target.current) {
const bounds = target.current.getBoundingClientRect();
let top = bounds.top + bounds.height;
@ -101,6 +167,7 @@ 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,
@ -111,7 +178,7 @@ export const PopupProvider: React.FC = ({ children }) => {
currentTab: 0,
previousTab: 0,
content,
width: width ?? 316,
options: popupOptions,
});
} else {
setState({
@ -123,7 +190,7 @@ export const PopupProvider: React.FC = ({ children }) => {
currentTab: 0,
previousTab: 0,
content,
width: width ?? 316,
options: popupOptions,
});
}
}
@ -138,20 +205,21 @@ 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, width?: number | string) => {
const newWidth = width ?? currentState.width;
setState((prevState: PopupState) => {
return {
...prevState,
previousTab: currentState.currentTab,
currentTab: newTab,
width: newWidth,
};
});
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 getCurrentTab = () => {
@ -162,17 +230,26 @@ 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.width ?? 316}
width={currentState.options.width}
>
{currentState.content}
<ContainerDiamond invertY={currentState.invertY} invert={currentState.invert} />
{currentState.options.showDiamond && (
<ContainerDiamond
color={currentState.options.diamondColor}
borders={currentState.options.borders}
invertY={currentState.invertY}
invert={currentState.invert}
/>
)}
</PopupContainer>,
portalTarget,
)}
@ -196,8 +273,16 @@ const PopupMenu: React.FC<Props> = ({ width, title, top, left, onClose, noHeader
useOnOutsideClick($containerRef, true, onClose, null);
return (
<Container invertY={false} width={width ?? 316} invert={false} left={left} top={top} ref={$containerRef}>
<Wrapper>
<Container
targetPadding="10px"
invertY={false}
width={width ?? 316}
invert={false}
left={left}
top={top}
ref={$containerRef}
>
<Wrapper padding borders>
{onPrevious && (
<PreviousButton onClick={onPrevious}>
<AngleLeft color="#c2c6dc" />
@ -221,7 +306,7 @@ const PopupMenu: React.FC<Props> = ({ width, title, top, left, onClose, noHeader
);
};
export const Popup: React.FC<PopupProps> = ({ title, onClose, tab, children }) => {
export const Popup: React.FC<PopupProps> = ({ borders = true, padding = true, title, onClose, tab, children }) => {
const { getCurrentTab, setTab } = usePopup();
if (getCurrentTab() !== tab) {
return null;
@ -229,7 +314,7 @@ export const Popup: React.FC<PopupProps> = ({ title, onClose, tab, children }) =
return (
<>
<Wrapper>
<Wrapper borders={borders} padding={padding}>
{tab > 0 && (
<PreviousButton
onClick={() => {

View File

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

View File

@ -5,6 +5,7 @@ import CardComposer from 'shared/components/CardComposer';
import LabelColors from 'shared/constants/labelColors';
import List, { ListCards } from 'shared/components/List';
import QuickCardEditor from 'shared/components/QuickCardEditor';
import NOOP from 'shared/utils/noop';
export default {
component: QuickCardEditor,
@ -71,7 +72,7 @@ export const Default = () => {
isComposerOpen={false}
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
onExtraMenuOpen={(taskGroupID, $targetRef) => {}}
onExtraMenuOpen={NOOP}
>
<ListCards>
<Card
@ -81,7 +82,7 @@ export const Default = () => {
ref={$cardRef}
title={task.name}
onClick={action('on click')}
onContextMenu={e => {
onContextMenu={() => {
setTarget($cardRef);
setEditorOpen(true);
}}
@ -90,7 +91,7 @@ export const Default = () => {
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen={false} />
<CardComposer onClose={NOOP} onCreateCard={NOOP} isOpen={false} />
</ListCards>
</List>
</>

View File

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

View File

@ -3,6 +3,19 @@ import Select from 'react-select';
import styled from 'styled-components';
import { mixin } from 'shared/utils/styles';
function getBackgroundColor(isDisabled: boolean, isSelected: boolean, isFocused: boolean) {
if (isDisabled) {
return null;
}
if (isSelected) {
return mixin.darken('#262c49', 0.25);
}
if (isFocused) {
return mixin.darken('#262c49', 0.15);
}
return null;
}
const colourStyles = {
control: (styles: any, data: any) => {
return {
@ -43,14 +56,8 @@ const colourStyles = {
option: (styles: any, { data, isDisabled, isFocused, isSelected }: any) => {
return {
...styles,
backgroundColor: isDisabled
? null
: isSelected
? mixin.darken('#262c49', 0.25)
: isFocused
? mixin.darken('#262c49', 0.15)
: null,
color: isDisabled ? '#ccc' : isSelected ? '#fff' : '#c2c6dc',
backgroundColor: getBackgroundColor(isDisabled, isSelected, isFocused),
color: isDisabled ? '#ccc' : isSelected ? '#fff' : '#c2c6dc', // eslint-disable-line
cursor: isDisabled ? 'not-allowed' : 'default',
':active': {
...styles[':active'],

View File

@ -27,6 +27,7 @@ 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,6 +10,11 @@ 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});
@ -218,7 +223,7 @@ const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
}}
>
<TabNavItemButton active={active}>
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
<User width={20} height={20} />
<TabNavItemSpan>{name}</TabNavItemSpan>
</TabNavItemButton>
</TabNavItem>
@ -240,6 +245,7 @@ const SaveButton = styled(Button)`
type SettingsProps = {
onProfileAvatarChange: () => void;
onProfileAvatarRemove: () => void;
onChangeUserInfo: (data: UserInfoData, done: () => void) => void;
onResetPassword: (password: string, done: () => void) => void;
profile: TaskUser;
};
@ -300,9 +306,93 @@ 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,
}) => {
@ -315,6 +405,7 @@ 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();
@ -332,23 +423,12 @@ const Settings: React.FC<SettingsProps> = ({
</TabNav>
<TabContentWrapper>
<Tab tab={0} currentTab={currentTab}>
<AvatarSettings
onProfileAvatarRemove={onProfileAvatarRemove}
<UserInfoTab
onProfileAvatarChange={onProfileAvatarChange}
profile={profile.profileIcon}
onProfileAvatarRemove={onProfileAvatarRemove}
profile={profile}
onChangeUserInfo={onChangeUserInfo}
/>
<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,288 +1,365 @@
import styled from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea/lib';
import styled, { css } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea';
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 TaskHeader = styled.div`
padding: 21px 30px 0px;
margin-right: 70px;
export const Container = styled.div`
display: flex;
height: 100%;
`;
export const LeftSidebar = styled.div`
display: flex;
flex-direction: column;
width: 300px;
background: #222740;
`;
export const MarkCompleteButton = styled.button<{ invert: boolean }>`
padding: 4px 8px;
position: relative;
border: none;
cursor: pointer;
border-radius: ${props => props.theme.borderRadius.alternate};
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});
}
`}
`;
export const LeftSidebarContent = styled.div`
padding-top: 32px;
display: flex;
flex-direction: column;
`;
export const TaskMeta = styled.div`
position: relative;
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`
font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary});
min-height: 32px;
width: 100%;
padding: 9px 8px 7px 8px;
border-color: transparent;
border-radius: 6px;
border-width: 1px;
border-style: solid;
display: inline-block;
outline: 0;
cursor: pointer;
font-size: 14px;
&:hover {
border-color: #414561;
}
`;
export const SidebarButtonText = styled.span`
min-height: 16px;
flex: 1 1 auto;
display: flex;
align-items: center;
border-radius: 4px;
justify-content: flex-start;
`;
export const TaskGroupLabel = styled.span`
color: #c2c6dc;
font-size: 14px;
`;
export const TaskGroupLabelName = styled.span`
color: #c2c6dc;
text-decoration: underline;
font-size: 14px;
`;
export const TaskActions = styled.div`
position: absolute;
top: 0;
right: 0;
padding: 21px 18px 0px;
export const ContentContainer = styled.div`
display: flex;
align-items: center;
`;
export const TaskAction = styled.button`
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
cursor: pointer;
padding: 0px 9px;
`;
export const TaskDetailsWrapper = styled.div`
display: flex;
padding: 0px 16px 60px;
`;
export const TaskDetailsContent = styled.div`
flex-direction: column;
flex: 1;
padding-right: 8px;
padding-top: 32px;
overflow: auto;
`;
export const TaskDetailsSidebar = styled.div`
width: 168px;
padding-left: 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 TaskDetailsTitleWrapper = styled.div`
width: 100%;
margin: 0 0 0 -8px;
margin: 8px 0 4px 0;
display: inline-block;
`;
export const TaskDetailsTitle = styled(TextareaAutosize)`
line-height: 1.28;
resize: none;
box-shadow: transparent 0px 0px 0px 1px;
font-size: 24px;
font-weight: 700;
padding: 4px;
background: #262c49;
padding: 9px 8px 7px 8px;
border-color: transparent;
border-radius: 6px;
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)};
}
`;
display: inline-block;
outline: 0;
font-size: 24px;
font-weight: 700;
background: none;
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;
border-color: #414561;
}
`;
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 {
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
background: ${mixin.darken('#262c49', 0.05)};
border-color: rgba(${props => props.theme.colors.primary});
}
`;
export const TaskDetailsMarkdown = styled.div`
width: 100%;
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`
cursor: pointer;
color: #c2c6dc;
h1 {
font-size: 24px;
font-weight: 600;
line-height: 28px;
margin: 0 0 12px;
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);
}
h2 {
font-weight: 600;
font-size: 20px;
line-height: 24px;
margin: 16px 0 8px;
&:hover svg {
fill: rgba(${props => props.theme.colors.text.secondary}, 0.75);
}
`;
p {
margin: 0 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)};
}
&: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 {
margin: 8px 0;
list-style-type: disc;
}
ul > li {
margin: 8px 8px 8px 24px;
list-style: disc;
}
p a {
color: rgba(${props => props.theme.colors.primary});
ul.checkbox_list input[type='checkbox'] {
border-radius: 6px;
border-width: 1px;
border-style: solid;
border-color: #414561;
}
`;
export const TaskDetailsControls = styled.div`
clear: both;
margin-top: 8px;
export const InnerContentContainer = styled.div`
overflow: auto;
position: relative;
`;
export const DescriptionContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
`;
export const ConfirmSave = styled(Button)`
padding: 6px 12px;
border-radius: 3px;
margin-right: 6px;
export const DescriptionActionButton = styled(Button)`
padding: 8px 16px;
`;
export const CancelEdit = styled.div`
export const Labels = styled.div`
display: flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
cursor: pointer;
padding-left: 9px;
margin: 0 0 8px 0;
`;
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;
export const MetaDetail = styled.div`
display: block;
float: left;
font-weight: 600;
height: 32px;
line-height: 32px;
margin: 0 4px 4px 0;
min-width: 40px;
padding: 0 12px;
width: auto;
margin: 0 16px 8px 0;
max-width: 100%;
`;
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)};
@ -307,90 +384,231 @@ export const TaskDetailsAddLabelIcon = styled.div`
}
`;
export const NoDueDateLabel = styled.span`
color: rgb(137, 147, 164);
font-size: 14px;
cursor: pointer;
export const ChecklistSection = styled.div`
margin-top: 8px;
padding: 0 24px;
`;
export const UnassignedLabel = styled.div`
color: rgb(137, 147, 164);
font-size: 14px;
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;
font-weight: 600;
height: 32px;
line-height: 32px;
margin: 0 4px 0 0;
min-width: 40px;
padding: 0 12px;
width: auto;
`;
export const ActionButtons = styled.div`
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;
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 CommentProfile = styled(TaskAssignee)`
margin-right: 8px;
position: relative;
top: 0;
padding-top: 3px;
align-items: normal;
`;
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 {
box-shadow: none;
background: rgba(${props => props.theme.colors.bg.primary}, 0.6);
export const CommentTextArea = styled(TextareaAutosize)`
width: 100%;
line-height: 28px;
padding: 4px 6px;
border-radius: 6px;
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 MetaDetails = styled.div`
margin-top: 8px;
display: flex;
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 TaskDueDateButton = styled(Button)`
export const CommentEditorActionIcon = styled.div`
width: 32px;
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;
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;
`;
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);
&:hover {
color: rgba(${props => props.theme.colors.text.primary});
}
`;
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;
`;

View File

@ -1,69 +1,77 @@
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 React, { useState, useRef } from 'react';
import {
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,
ActionButtonsTitle,
TaskHeader,
ProfileIcon,
TaskDetailsContent,
TaskDetailsWrapper,
TaskDetailsSidebar,
TaskDetailsTitleWrapper,
TaskDetailsTitle,
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';
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 styled from 'styled-components';
const ChecklistContainer = styled.div``;
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
import moment from 'moment';
type TaskContentProps = {
onEditContent: () => void;
description: string;
};
import Task from 'shared/icons/Task';
import {
TaskDetailLabel,
CommentContainer,
MetaDetailContent,
TaskDetailsAddLabelIcon,
ActionButton,
AssignUserIcon,
AssignUserLabel,
AssignUsersButton,
AssignedUsersSection,
ViewRawButton,
DueDateTitle,
Container,
LeftSidebar,
ContentContainer,
LeftSidebarContent,
LeftSidebarSection,
SidebarTitle,
SidebarButton,
SidebarButtonText,
MarkCompleteButton,
HeaderContainer,
HeaderLeft,
HeaderInnerContainer,
TaskDetailsTitleWrapper,
TaskDetailsTitle,
ExtraActionsSection,
HeaderRight,
HeaderActionIcon,
EditorContainer,
InnerContentContainer,
DescriptionContainer,
Labels,
ChecklistSection,
MemberList,
TaskMember,
TabBarSection,
TabBarItem,
CommentTextArea,
CommentEditorContainer,
CommentEditorActions,
CommentEditorActionIcon,
CommentEditorSaveButton,
CommentProfile,
CommentInnerWrapper,
ActivitySection,
TaskDetailsEditor,
} from './Styles';
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
import onDragEnd from './onDragEnd';
const ChecklistContainer = styled.div``;
type TaskLabelProps = {
label: TaskLabel;
@ -85,62 +93,15 @@ 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;
@ -162,6 +123,7 @@ type TaskDetailsProps = {
};
const TaskDetails: React.FC<TaskDetailsProps> = ({
me,
task,
onDeleteChecklist,
onTaskNameChange,
@ -182,209 +144,205 @@ 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 handleClick = () => {
setEditorOpen(!editorOpen);
};
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 [editTaskDescription, setEditTaskDescription] = useState(() => {
if (task.description) {
if (task.description.trim() === '' || task.description.trim() === '\\') {
return true;
}
return false;
}
};
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);
};
const $dueDateLabel = useRef<HTMLDivElement>(null);
const $addLabelRef = useRef<HTMLDivElement>(null);
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 onAddLabel = ($target: React.RefObject<HTMLElement>) => {
onOpenAddLabelPopup(task, $target);
const saveDescription = () => {
onTaskDescriptionChange(task, taskDescriptionRef.current);
};
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 { 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: React.ChangeEvent<HTMLTextAreaElement>) => setTaskName(e.currentTarget.value)}
onKeyDown={onKeyDown}
/>
</TaskDetailsTitleWrapper>
<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 (
<TaskLabelItem
key={label.projectLabel.id}
label={label}
onClick={$target => {
onOpenAddLabelPopup(task, $target);
}}
/>
);
})}
<TaskDetailsAddLabelIcon ref={$addLabelRef} onClick={() => onAddLabel($addLabelRef)}>
<Plus size={16} color="#c2c6dc" />
</TaskDetailsAddLabelIcon>
</MetaDetailContent>
</MetaDetail>
)}
{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);
}}
onCancel={() => {
setEditorOpen(false);
}}
/>
<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>{moment(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>
) : (
<TaskContent description={description} onEditContent={handleClick} />
<AssignUsersButton
ref={$noMemberBtn}
onClick={() => {
onOpenAddMemberPopup(task, $noMemberBtn);
}}
>
<AssignUserIcon>
<User width={16} height={16} />
</AssignUserIcon>
<AssignUserLabel>No members</AssignUserLabel>
</AssignUsersButton>
)}
<DragDropContext onDragEnd={onDragEnd}>
</AssignedUsersSection>
<ExtraActionsSection>
<DueDateTitle>ACTIONS</DueDateTitle>
<ActionButton
onClick={$target => {
onOpenAddLabelPopup(task, $target);
}}
icon={<Tags width={12} height={12} />}
>
Labels
</ActionButton>
<ActionButton
onClick={$target => {
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>
<TaskDetailsTitleWrapper>
<TaskDetailsTitle
value={taskName}
onChange={e => {
setTaskName(e.currentTarget.value);
}}
onBlur={() => {
if (taskName !== task.name) {
onTaskNameChange(task, taskName);
}
}}
/>
</TaskDetailsTitleWrapper>
<Labels>
{task.labels.length !== 0 && (
<MetaDetailContent>
{task.labels.map(label => {
return (
<TaskLabelItem
key={label.projectLabel.id}
label={label}
onClick={$target => {
onOpenAddLabelPopup(task, $target);
}}
/>
);
})}
<TaskDetailsAddLabelIcon>
<Plus width={12} height={12} />
</TaskDetailsAddLabelIcon>
</MetaDetailContent>
)}
</Labels>
</HeaderContainer>
<InnerContentContainer>
<DescriptionContainer>
{showRaw ? (
<TaskDetailsEditor value={taskDescriptionRef.current} />
) : (
<EditorContainer
onClick={e => {
if (!editTaskDescription) {
setEditTaskDescription(true);
}
}}
>
<Editor
defaultValue={task.description ?? ''}
theme={dark}
readOnly={!editTaskDescription}
autoFocus
onChange={value => {
setSaveTimeout(() => {
clearTimeout(saveTimeout);
return setTimeout(saveDescription, 2000);
});
const text = value();
taskDescriptionRef.current = text;
}}
/>
</EditorContainer>
)}
<ViewRawButton onClick={() => setShowRaw(!showRaw)}>{showRaw ? 'Show editor' : 'Show raw'}</ViewRawButton>
</DescriptionContainer>
<ChecklistSection>
<DragDropContext onDragEnd={result => onDragEnd(result, task, onChecklistDrop, onChecklistItemDrop)}>
<Droppable direction="vertical" type="checklist" droppableId="root">
{dropProvided => (
<ChecklistContainer {...dropProvided.droppableProps} ref={dropProvided.innerRef}>
@ -442,9 +400,9 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
complete={item.complete}
onDeleteItem={onDeleteItem}
onChangeName={onChangeItemName}
onToggleItem={(itemID, complete) =>
onToggleChecklistItem(item.id, complete)
}
onToggleItem={(itemID, complete) => {
onToggleChecklistItem(item.id, complete);
}}
/>
)}
</Draggable>
@ -463,32 +421,54 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
)}
</Droppable>
</DragDropContext>
</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>
</>
</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>
);
};

View File

@ -0,0 +1,90 @@
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,6 +5,8 @@ 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};
position: relative;
@ -64,7 +66,7 @@ export const ProfileNameWrapper = styled.div`
line-height: 1.25;
`;
export const IconContainer = styled.div<{ disabled?: boolean }>`
export const IconContainerWrapper = styled.div<{ disabled?: boolean }>`
margin-right: 20px;
cursor: pointer;
${props =>
@ -85,7 +87,10 @@ 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;
@ -246,8 +251,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

@ -2,17 +2,16 @@ import React, { useRef, useState, useEffect } from 'react';
import { Home, Star, Bell, AngleDown, BarChart, CheckCircle } from 'shared/icons';
import styled from 'styled-components';
import ProfileIcon from 'shared/components/ProfileIcon';
import TaskAssignee from 'shared/components/TaskAssignee';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { usePopup } from 'shared/components/PopupMenu';
import { RoleCode } from 'shared/generated/graphql';
import MiniProfile from 'shared/components/MiniProfile';
import NOOP from 'shared/utils/noop';
import {
TaskcafeLogo,
TaskcafeTitle,
ProjectFinder,
LogoContainer,
NavSeparator,
IconContainer,
IconContainerWrapper,
ProjectNameTextarea,
InviteButton,
GlobalActions,
@ -31,7 +30,28 @@ import {
ProjectMember,
ProjectMembers,
} from './Styles';
import { Link } from 'react-router-dom';
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)``;
@ -145,7 +165,7 @@ type NavBarProps = {
onFavorite?: () => void;
onProfileClick: ($target: React.RefObject<HTMLElement>) => void;
onSaveName?: (name: string) => void;
onNotificationClick: () => void;
onNotificationClick: ($target: React.RefObject<HTMLElement>) => void;
canEditProjectName?: boolean;
canInviteUser?: boolean;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
@ -257,16 +277,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>
<IconContainer disabled onClick={NOOP}>
<CheckCircle width={20} height={20} />
</IconContainer>
<IconContainer onClick={onNotificationClick}>
<IconContainer disabled onClick={onNotificationClick}>
<Bell color="#c2c6dc" size={20} />
</IconContainer>
<IconContainer disabled>
<IconContainer disabled onClick={NOOP}>
<BarChart width={20} height={20} />
</IconContainer>

View File

@ -105,6 +105,7 @@ export type UserAccount = {
createdAt: Scalars['Time'];
fullName: Scalars['String'];
initials: Scalars['String'];
bio: Scalars['String'];
role: Role;
username: Scalars['String'];
profileIcon: ProfileIcon;
@ -162,6 +163,7 @@ export type Task = {
description?: Maybe<Scalars['String']>;
dueDate?: Maybe<Scalars['Time']>;
complete: Scalars['Boolean'];
completedAt?: Maybe<Scalars['Time']>;
assigned: Array<Member>;
labels: Array<TaskLabel>;
checklists: Array<TaskChecklist>;
@ -212,22 +214,18 @@ export enum ObjectType {
export type Query = {
__typename?: 'Query';
organizations: Array<Organization>;
users: Array<UserAccount>;
findUser: UserAccount;
findProject: Project;
findTask: Task;
projects: Array<Project>;
findTeam: Team;
teams: Array<Team>;
findUser: UserAccount;
labelColors: Array<LabelColor>;
taskGroups: Array<TaskGroup>;
me: MePayload;
};
export type QueryFindUserArgs = {
input: FindUser;
notifications: Array<Notification>;
organizations: Array<Organization>;
projects: Array<Project>;
taskGroups: Array<TaskGroup>;
teams: Array<Team>;
users: Array<UserAccount>;
};
@ -241,13 +239,18 @@ export type QueryFindTaskArgs = {
};
export type QueryProjectsArgs = {
input?: Maybe<ProjectsFilter>;
export type QueryFindTeamArgs = {
input: FindTeam;
};
export type QueryFindTeamArgs = {
input: FindTeam;
export type QueryFindUserArgs = {
input: FindUser;
};
export type QueryProjectsArgs = {
input?: Maybe<ProjectsFilter>;
};
export type Mutation = {
@ -273,13 +276,16 @@ export type Mutation = {
deleteTaskChecklist: DeleteTaskChecklistPayload;
deleteTaskChecklistItem: DeleteTaskChecklistItemPayload;
deleteTaskGroup: DeleteTaskGroupPayload;
deleteTaskGroupTasks: DeleteTaskGroupTasksPayload;
deleteTeam: DeleteTeamPayload;
deleteTeamMember: DeleteTeamMemberPayload;
deleteUserAccount: DeleteUserAccountPayload;
duplicateTaskGroup: DuplicateTaskGroupPayload;
logoutUser: Scalars['Boolean'];
removeTaskLabel: Task;
setTaskChecklistItemComplete: TaskChecklistItem;
setTaskComplete: Task;
sortTaskGroup: SortTaskGroupPayload;
toggleTaskLabel: ToggleTaskLabelPayload;
unassignTask: Task;
updateProjectLabel: ProjectLabel;
@ -298,6 +304,7 @@ export type Mutation = {
updateTaskLocation: UpdateTaskLocationPayload;
updateTaskName: Task;
updateTeamMemberRole: UpdateTeamMemberRolePayload;
updateUserInfo: UpdateUserInfoPayload;
updateUserPassword: UpdateUserPasswordPayload;
updateUserRole: UpdateUserRolePayload;
};
@ -403,6 +410,11 @@ export type MutationDeleteTaskGroupArgs = {
};
export type MutationDeleteTaskGroupTasksArgs = {
input: DeleteTaskGroupTasks;
};
export type MutationDeleteTeamArgs = {
input: DeleteTeam;
};
@ -418,6 +430,11 @@ export type MutationDeleteUserAccountArgs = {
};
export type MutationDuplicateTaskGroupArgs = {
input: DuplicateTaskGroup;
};
export type MutationLogoutUserArgs = {
input: LogoutUser;
};
@ -438,6 +455,11 @@ export type MutationSetTaskCompleteArgs = {
};
export type MutationSortTaskGroupArgs = {
input: SortTaskGroup;
};
export type MutationToggleTaskLabelArgs = {
input: ToggleTaskLabelInput;
};
@ -528,6 +550,11 @@ export type MutationUpdateTeamMemberRoleArgs = {
};
export type MutationUpdateUserInfoArgs = {
input: UpdateUserInfo;
};
export type MutationUpdateUserPasswordArgs = {
input: UpdateUserPassword;
};
@ -576,6 +603,42 @@ export type FindTeam = {
teamID: Scalars['UUID'];
};
export enum EntityType {
Task = 'TASK'
}
export enum ActorType {
User = 'USER'
}
export enum ActionType {
TaskMemberAdded = 'TASK_MEMBER_ADDED'
}
export type NotificationActor = {
__typename?: 'NotificationActor';
id: Scalars['UUID'];
type: ActorType;
name: Scalars['String'];
};
export type NotificationEntity = {
__typename?: 'NotificationEntity';
id: Scalars['UUID'];
type: EntityType;
name: Scalars['String'];
};
export type Notification = {
__typename?: 'Notification';
id: Scalars['ID'];
entity: NotificationEntity;
actionType: ActionType;
actor: NotificationActor;
read: Scalars['Boolean'];
createdAt: Scalars['Time'];
};
export type NewProject = {
userID: Scalars['UUID'];
teamID: Scalars['UUID'];
@ -785,6 +848,44 @@ export type DeleteTaskChecklistPayload = {
taskChecklist: TaskChecklist;
};
export type DeleteTaskGroupTasks = {
taskGroupID: Scalars['UUID'];
};
export type DeleteTaskGroupTasksPayload = {
__typename?: 'DeleteTaskGroupTasksPayload';
taskGroupID: Scalars['UUID'];
tasks: Array<Scalars['UUID']>;
};
export type TaskPositionUpdate = {
taskID: Scalars['UUID'];
position: Scalars['Float'];
};
export type SortTaskGroupPayload = {
__typename?: 'SortTaskGroupPayload';
taskGroupID: Scalars['UUID'];
tasks: Array<Task>;
};
export type SortTaskGroup = {
taskGroupID: Scalars['UUID'];
tasks: Array<TaskPositionUpdate>;
};
export type DuplicateTaskGroup = {
projectID: Scalars['UUID'];
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
position: Scalars['Float'];
};
export type DuplicateTaskGroupPayload = {
__typename?: 'DuplicateTaskGroupPayload';
taskGroup: TaskGroup;
};
export type NewTaskGroupLocation = {
taskGroupID: Scalars['UUID'];
position: Scalars['Float'];
@ -885,6 +986,18 @@ export type UpdateTeamMemberRolePayload = {
member: Member;
};
export type UpdateUserInfoPayload = {
__typename?: 'UpdateUserInfoPayload';
user: UserAccount;
};
export type UpdateUserInfo = {
name: Scalars['String'];
initials: Scalars['String'];
email: Scalars['String'];
bio: Scalars['String'];
};
export type UpdateUserPassword = {
userID: Scalars['UUID'];
password: Scalars['String'];
@ -1151,7 +1264,7 @@ export type FindTaskQuery = (
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'position' | 'complete'>
& { taskGroup: (
{ __typename?: 'TaskGroup' }
& Pick<TaskGroup, 'id'>
& Pick<TaskGroup, 'id' | 'name'>
), badges: (
{ __typename?: 'TaskBadges' }
& { checklist?: Maybe<(
@ -1184,12 +1297,22 @@ export type FindTaskQuery = (
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
) }
)> }
), me: (
{ __typename?: 'MePayload' }
& { user: (
{ __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'fullName'>
& { profileIcon: (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'initials' | 'bgColor' | 'url'>
) }
) }
) }
);
export type TaskFieldsFragment = (
{ __typename?: 'Task' }
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'complete' | 'position'>
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'complete' | 'completedAt' | 'position'>
& { badges: (
{ __typename?: 'TaskBadges' }
& { checklist?: Maybe<(
@ -1250,7 +1373,7 @@ export type MeQuery = (
{ __typename?: 'MePayload' }
& { user: (
{ __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'fullName'>
& Pick<UserAccount, 'id' | 'fullName' | 'username' | 'email' | 'bio'>
& { profileIcon: (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'initials' | 'bgColor' | 'url'>
@ -1527,6 +1650,60 @@ export type UpdateTaskChecklistNameMutation = (
) }
);
export type DeleteTaskGroupTasksMutationVariables = {
taskGroupID: Scalars['UUID'];
};
export type DeleteTaskGroupTasksMutation = (
{ __typename?: 'Mutation' }
& { deleteTaskGroupTasks: (
{ __typename?: 'DeleteTaskGroupTasksPayload' }
& Pick<DeleteTaskGroupTasksPayload, 'tasks' | 'taskGroupID'>
) }
);
export type DuplicateTaskGroupMutationVariables = {
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
position: Scalars['Float'];
projectID: Scalars['UUID'];
};
export type DuplicateTaskGroupMutation = (
{ __typename?: 'Mutation' }
& { duplicateTaskGroup: (
{ __typename?: 'DuplicateTaskGroupPayload' }
& { taskGroup: (
{ __typename?: 'TaskGroup' }
& Pick<TaskGroup, 'id' | 'name' | 'position'>
& { tasks: Array<(
{ __typename?: 'Task' }
& TaskFieldsFragment
)> }
) }
) }
);
export type SortTaskGroupMutationVariables = {
tasks: Array<TaskPositionUpdate>;
taskGroupID: Scalars['UUID'];
};
export type SortTaskGroupMutation = (
{ __typename?: 'Mutation' }
& { sortTaskGroup: (
{ __typename?: 'SortTaskGroupPayload' }
& Pick<SortTaskGroupPayload, 'taskGroupID'>
& { tasks: Array<(
{ __typename?: 'Task' }
& Pick<Task, 'id' | 'position'>
)> }
) }
);
export type UpdateTaskGroupNameMutationVariables = {
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
@ -1744,6 +1921,40 @@ export type ToggleTaskLabelMutation = (
) }
);
export type TopNavbarQueryVariables = {};
export type TopNavbarQuery = (
{ __typename?: 'Query' }
& { notifications: Array<(
{ __typename?: 'Notification' }
& Pick<Notification, 'createdAt' | 'read' | 'id' | 'actionType'>
& { entity: (
{ __typename?: 'NotificationEntity' }
& Pick<NotificationEntity, 'id' | 'type' | 'name'>
), actor: (
{ __typename?: 'NotificationActor' }
& Pick<NotificationActor, 'id' | 'type' | 'name'>
) }
)>, me: (
{ __typename?: 'MePayload' }
& { user: (
{ __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'fullName'>
& { profileIcon: (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'initials' | 'bgColor' | 'url'>
) }
), teamRoles: Array<(
{ __typename?: 'TeamRole' }
& Pick<TeamRole, 'teamID' | 'roleCode'>
)>, projectRoles: Array<(
{ __typename?: 'ProjectRole' }
& Pick<ProjectRole, 'projectID' | 'roleCode'>
)> }
) }
);
export type UnassignTaskMutationVariables = {
taskID: Scalars['UUID'];
userID: Scalars['UUID'];
@ -1888,7 +2099,7 @@ export type CreateUserAccountMutation = (
{ __typename?: 'Mutation' }
& { createUserAccount: (
{ __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'initials' | 'username'>
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'initials' | 'username' | 'bio'>
& { profileIcon: (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
@ -1935,6 +2146,29 @@ export type DeleteUserAccountMutation = (
) }
);
export type UpdateUserInfoMutationVariables = {
name: Scalars['String'];
initials: Scalars['String'];
email: Scalars['String'];
bio: Scalars['String'];
};
export type UpdateUserInfoMutation = (
{ __typename?: 'Mutation' }
& { updateUserInfo: (
{ __typename?: 'UpdateUserInfoPayload' }
& { user: (
{ __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'bio'>
& { profileIcon: (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'initials'>
) }
) }
) }
);
export type UpdateUserPasswordMutationVariables = {
userID: Scalars['UUID'];
password: Scalars['String'];
@ -2013,6 +2247,7 @@ export const TaskFieldsFragmentDoc = gql`
description
dueDate
complete
completedAt
position
badges {
checklist {
@ -2467,6 +2702,7 @@ export const FindTaskDocument = gql`
complete
taskGroup {
id
name
}
badges {
checklist {
@ -2511,6 +2747,17 @@ export const FindTaskDocument = gql`
}
}
}
me {
user {
id
fullName
profileIcon {
initials
bgColor
url
}
}
}
}
`;
@ -2591,6 +2838,9 @@ export const MeDocument = gql`
user {
id
fullName
username
email
bio
profileIcon {
initials
bgColor
@ -3198,6 +3448,118 @@ export function useUpdateTaskChecklistNameMutation(baseOptions?: ApolloReactHook
export type UpdateTaskChecklistNameMutationHookResult = ReturnType<typeof useUpdateTaskChecklistNameMutation>;
export type UpdateTaskChecklistNameMutationResult = ApolloReactCommon.MutationResult<UpdateTaskChecklistNameMutation>;
export type UpdateTaskChecklistNameMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateTaskChecklistNameMutation, UpdateTaskChecklistNameMutationVariables>;
export const DeleteTaskGroupTasksDocument = gql`
mutation deleteTaskGroupTasks($taskGroupID: UUID!) {
deleteTaskGroupTasks(input: {taskGroupID: $taskGroupID}) {
tasks
taskGroupID
}
}
`;
export type DeleteTaskGroupTasksMutationFn = ApolloReactCommon.MutationFunction<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>;
/**
* __useDeleteTaskGroupTasksMutation__
*
* To run a mutation, you first call `useDeleteTaskGroupTasksMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteTaskGroupTasksMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteTaskGroupTasksMutation, { data, loading, error }] = useDeleteTaskGroupTasksMutation({
* variables: {
* taskGroupID: // value for 'taskGroupID'
* },
* });
*/
export function useDeleteTaskGroupTasksMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>) {
return ApolloReactHooks.useMutation<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>(DeleteTaskGroupTasksDocument, baseOptions);
}
export type DeleteTaskGroupTasksMutationHookResult = ReturnType<typeof useDeleteTaskGroupTasksMutation>;
export type DeleteTaskGroupTasksMutationResult = ApolloReactCommon.MutationResult<DeleteTaskGroupTasksMutation>;
export type DeleteTaskGroupTasksMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>;
export const DuplicateTaskGroupDocument = gql`
mutation duplicateTaskGroup($taskGroupID: UUID!, $name: String!, $position: Float!, $projectID: UUID!) {
duplicateTaskGroup(input: {projectID: $projectID, taskGroupID: $taskGroupID, name: $name, position: $position}) {
taskGroup {
id
name
position
tasks {
...TaskFields
}
}
}
}
${TaskFieldsFragmentDoc}`;
export type DuplicateTaskGroupMutationFn = ApolloReactCommon.MutationFunction<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>;
/**
* __useDuplicateTaskGroupMutation__
*
* To run a mutation, you first call `useDuplicateTaskGroupMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDuplicateTaskGroupMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [duplicateTaskGroupMutation, { data, loading, error }] = useDuplicateTaskGroupMutation({
* variables: {
* taskGroupID: // value for 'taskGroupID'
* name: // value for 'name'
* position: // value for 'position'
* projectID: // value for 'projectID'
* },
* });
*/
export function useDuplicateTaskGroupMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>) {
return ApolloReactHooks.useMutation<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>(DuplicateTaskGroupDocument, baseOptions);
}
export type DuplicateTaskGroupMutationHookResult = ReturnType<typeof useDuplicateTaskGroupMutation>;
export type DuplicateTaskGroupMutationResult = ApolloReactCommon.MutationResult<DuplicateTaskGroupMutation>;
export type DuplicateTaskGroupMutationOptions = ApolloReactCommon.BaseMutationOptions<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>;
export const SortTaskGroupDocument = gql`
mutation sortTaskGroup($tasks: [TaskPositionUpdate!]!, $taskGroupID: UUID!) {
sortTaskGroup(input: {taskGroupID: $taskGroupID, tasks: $tasks}) {
taskGroupID
tasks {
id
position
}
}
}
`;
export type SortTaskGroupMutationFn = ApolloReactCommon.MutationFunction<SortTaskGroupMutation, SortTaskGroupMutationVariables>;
/**
* __useSortTaskGroupMutation__
*
* To run a mutation, you first call `useSortTaskGroupMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSortTaskGroupMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [sortTaskGroupMutation, { data, loading, error }] = useSortTaskGroupMutation({
* variables: {
* tasks: // value for 'tasks'
* taskGroupID: // value for 'taskGroupID'
* },
* });
*/
export function useSortTaskGroupMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<SortTaskGroupMutation, SortTaskGroupMutationVariables>) {
return ApolloReactHooks.useMutation<SortTaskGroupMutation, SortTaskGroupMutationVariables>(SortTaskGroupDocument, baseOptions);
}
export type SortTaskGroupMutationHookResult = ReturnType<typeof useSortTaskGroupMutation>;
export type SortTaskGroupMutationResult = ApolloReactCommon.MutationResult<SortTaskGroupMutation>;
export type SortTaskGroupMutationOptions = ApolloReactCommon.BaseMutationOptions<SortTaskGroupMutation, SortTaskGroupMutationVariables>;
export const UpdateTaskGroupNameDocument = gql`
mutation updateTaskGroupName($taskGroupID: UUID!, $name: String!) {
updateTaskGroupName(input: {taskGroupID: $taskGroupID, name: $name}) {
@ -3590,6 +3952,70 @@ export function useToggleTaskLabelMutation(baseOptions?: ApolloReactHooks.Mutati
export type ToggleTaskLabelMutationHookResult = ReturnType<typeof useToggleTaskLabelMutation>;
export type ToggleTaskLabelMutationResult = ApolloReactCommon.MutationResult<ToggleTaskLabelMutation>;
export type ToggleTaskLabelMutationOptions = ApolloReactCommon.BaseMutationOptions<ToggleTaskLabelMutation, ToggleTaskLabelMutationVariables>;
export const TopNavbarDocument = gql`
query topNavbar {
notifications {
createdAt
read
id
entity {
id
type
name
}
actor {
id
type
name
}
actionType
}
me {
user {
id
fullName
profileIcon {
initials
bgColor
url
}
}
teamRoles {
teamID
roleCode
}
projectRoles {
projectID
roleCode
}
}
}
`;
/**
* __useTopNavbarQuery__
*
* To run a query within a React component, call `useTopNavbarQuery` and pass it any options that fit your needs.
* When your component renders, `useTopNavbarQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useTopNavbarQuery({
* variables: {
* },
* });
*/
export function useTopNavbarQuery(baseOptions?: ApolloReactHooks.QueryHookOptions<TopNavbarQuery, TopNavbarQueryVariables>) {
return ApolloReactHooks.useQuery<TopNavbarQuery, TopNavbarQueryVariables>(TopNavbarDocument, baseOptions);
}
export function useTopNavbarLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions<TopNavbarQuery, TopNavbarQueryVariables>) {
return ApolloReactHooks.useLazyQuery<TopNavbarQuery, TopNavbarQueryVariables>(TopNavbarDocument, baseOptions);
}
export type TopNavbarQueryHookResult = ReturnType<typeof useTopNavbarQuery>;
export type TopNavbarLazyQueryHookResult = ReturnType<typeof useTopNavbarLazyQuery>;
export type TopNavbarQueryResult = ApolloReactCommon.QueryResult<TopNavbarQuery, TopNavbarQueryVariables>;
export const UnassignTaskDocument = gql`
mutation unassignTask($taskID: UUID!, $userID: UUID!) {
unassignTask(input: {taskID: $taskID, userID: $userID}) {
@ -3891,6 +4317,7 @@ export const CreateUserAccountDocument = gql`
fullName
initials
username
bio
profileIcon {
url
initials
@ -3989,6 +4416,49 @@ export function useDeleteUserAccountMutation(baseOptions?: ApolloReactHooks.Muta
export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>;
export type DeleteUserAccountMutationResult = ApolloReactCommon.MutationResult<DeleteUserAccountMutation>;
export type DeleteUserAccountMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>;
export const UpdateUserInfoDocument = gql`
mutation updateUserInfo($name: String!, $initials: String!, $email: String!, $bio: String!) {
updateUserInfo(input: {name: $name, initials: $initials, email: $email, bio: $bio}) {
user {
id
email
fullName
bio
profileIcon {
initials
}
}
}
}
`;
export type UpdateUserInfoMutationFn = ApolloReactCommon.MutationFunction<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>;
/**
* __useUpdateUserInfoMutation__
*
* To run a mutation, you first call `useUpdateUserInfoMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateUserInfoMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateUserInfoMutation, { data, loading, error }] = useUpdateUserInfoMutation({
* variables: {
* name: // value for 'name'
* initials: // value for 'initials'
* email: // value for 'email'
* bio: // value for 'bio'
* },
* });
*/
export function useUpdateUserInfoMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>) {
return ApolloReactHooks.useMutation<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>(UpdateUserInfoDocument, baseOptions);
}
export type UpdateUserInfoMutationHookResult = ReturnType<typeof useUpdateUserInfoMutation>;
export type UpdateUserInfoMutationResult = ApolloReactCommon.MutationResult<UpdateUserInfoMutation>;
export type UpdateUserInfoMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>;
export const UpdateUserPasswordDocument = gql`
mutation updateUserPassword($userID: UUID!, $password: String!) {
updateUserPassword(input: {userID: $userID, password: $password}) {

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,26 @@
import gql from 'graphql-tag';
import TASK_FRAGMENT from '../fragments/task';
const DUPLICATE_TASK_GROUP_MUTATION = gql`
mutation duplicateTaskGroup($taskGroupID: UUID!, $name: String!, $position: Float!, $projectID: UUID!) {
duplicateTaskGroup(
input: {
projectID: $projectID
taskGroupID: $taskGroupID
name: $name
position: $position
}
) {
taskGroup {
id
name
position
tasks {
...TaskFields
}
}
}
${TASK_FRAGMENT}
}
`;

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag';
const SORT_TASK_GROUP_MUTATION = gql`
mutation sortTaskGroup($tasks: [TaskPositionUpdate!]!, $taskGroupID: UUID!) {
sortTaskGroup(input: { taskGroupID: $taskGroupID, tasks: $tasks }) {
taskGroupID
tasks {
id
position
}
}
}
`;

View File

@ -0,0 +1,43 @@
import gql from 'graphql-tag';
export const TOP_NAVBAR_QUERY = gql`
query topNavbar {
notifications {
createdAt
read
id
entity {
id
type
name
}
actor {
id
type
name
}
actionType
}
me {
user {
id
fullName
profileIcon {
initials
bgColor
url
}
}
teamRoles {
teamID
roleCode
}
projectRoles {
projectID
roleCode
}
}
}
`;
export default TOP_NAVBAR_QUERY;

View File

@ -24,6 +24,7 @@ export const CREATE_USER_MUTATION = gql`
fullName
initials
username
bio
profileIcon {
url
initials

View File

@ -0,0 +1,19 @@
import gql from 'graphql-tag';
export const UPDATE_USER_INFO_MUTATION = gql`
mutation updateUserInfo($name: String!, $initials: String!, $email: String!, $bio: String!) {
updateUserInfo(input: { name: $name, initials: $initials, email: $email, bio: $bio }) {
user {
id
email
fullName
bio
profileIcon {
initials
}
}
}
}
`;
export default UPDATE_USER_INFO_MUTATION;

View File

@ -1,17 +1,17 @@
import gql from 'graphql-tag';
export const UPDATE_USER_ROLE_MUTATION = gql`
mutation updateUserRole($userID: UUID!, $roleCode: RoleCode!) {
updateUserRole(input:{userID: $userID, roleCode:$roleCode}) {
user {
id
role {
code
name
mutation updateUserRole($userID: UUID!, $roleCode: RoleCode!) {
updateUserRole(input: { userID: $userID, roleCode: $roleCode }) {
user {
id
role {
code
name
}
}
}
}
}
`;
export default UPDATE_USER_ROLE_MUTATION;

View File

@ -0,0 +1,14 @@
import { useLayoutEffect, useState } from 'react';
export default function useWindowSize() {
const [size, setSize] = useState([0, 0]);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
}, []);
return size;
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const At: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M256 8C118.941 8 8 118.919 8 256c0 137.059 110.919 248 248 248 48.154 0 95.342-14.14 135.408-40.223 12.005-7.815 14.625-24.288 5.552-35.372l-10.177-12.433c-7.671-9.371-21.179-11.667-31.373-5.129C325.92 429.757 291.314 440 256 440c-101.458 0-184-82.542-184-184S154.542 72 256 72c100.139 0 184 57.619 184 160 0 38.786-21.093 79.742-58.17 83.693-17.349-.454-16.91-12.857-13.476-30.024l23.433-121.11C394.653 149.75 383.308 136 368.225 136h-44.981a13.518 13.518 0 0 0-13.432 11.993l-.01.092c-14.697-17.901-40.448-21.775-59.971-21.775-74.58 0-137.831 62.234-137.831 151.46 0 65.303 36.785 105.87 96 105.87 26.984 0 57.369-15.637 74.991-38.333 9.522 34.104 40.613 34.103 70.71 34.103C462.609 379.41 504 307.798 504 232 504 95.653 394.023 8 256 8zm-21.68 304.43c-22.249 0-36.07-15.623-36.07-40.771 0-44.993 30.779-72.729 58.63-72.729 22.292 0 35.601 15.241 35.601 40.77 0 45.061-33.875 72.73-58.161 72.73z" />
</Icon>
);
};
export default At;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Calender: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 448 512">
<path d="M400 64h-48V12c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v52H160V12c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v52H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48zm-6 400H54c-3.3 0-6-2.7-6-6V160h352v298c0 3.3-2.7 6-6 6z" />
</Icon>
);
};
export default Calender;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Clone: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M464 0c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48H176c-26.51 0-48-21.49-48-48V48c0-26.51 21.49-48 48-48h288M176 416c-44.112 0-80-35.888-80-80V128H48c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h288c26.51 0 48-21.49 48-48v-48H176z" />
</Icon>
);
};
export default Clone;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Eye: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 576 512">
<path d="M288 144a110.94 110.94 0 0 0-31.24 5 55.4 55.4 0 0 1 7.24 27 56 56 0 0 1-56 56 55.4 55.4 0 0 1-27-7.24A111.71 111.71 0 1 0 288 144zm284.52 97.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400c-98.65 0-189.09-55-237.93-144C98.91 167 189.34 112 288 112s189.09 55 237.93 144C477.1 345 386.66 400 288 400z" />
</Icon>
);
};
export default Eye;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const List: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M80 368H16a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16v-64a16 16 0 0 0-16-16zm0-320H16A16 16 0 0 0 0 64v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16zm0 160H16a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16v-64a16 16 0 0 0-16-16zm416 176H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-320H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16z" />
</Icon>
);
};
export default List;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Paperclip: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 448 512">
<path d="M43.246 466.142c-58.43-60.289-57.341-157.511 1.386-217.581L254.392 34c44.316-45.332 116.351-45.336 160.671 0 43.89 44.894 43.943 117.329 0 162.276L232.214 383.128c-29.855 30.537-78.633 30.111-107.982-.998-28.275-29.97-27.368-77.473 1.452-106.953l143.743-146.835c6.182-6.314 16.312-6.422 22.626-.241l22.861 22.379c6.315 6.182 6.422 16.312.241 22.626L171.427 319.927c-4.932 5.045-5.236 13.428-.648 18.292 4.372 4.634 11.245 4.711 15.688.165l182.849-186.851c19.613-20.062 19.613-52.725-.011-72.798-19.189-19.627-49.957-19.637-69.154 0L90.39 293.295c-34.763 35.56-35.299 93.12-1.191 128.313 34.01 35.093 88.985 35.137 123.058.286l172.06-175.999c6.177-6.319 16.307-6.433 22.626-.256l22.877 22.364c6.319 6.177 6.434 16.307.256 22.626l-172.06 175.998c-59.576 60.938-155.943 60.216-214.77-.485z" />
</Icon>
);
};
export default Paperclip;

View File

@ -1,21 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
type Props = {
size: number | string;
color: string;
};
const Plus = ({ size, color }: Props) => {
const Plus: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 16 16">
<path d="M15.5 6h-5.5v-5.5c0-0.276-0.224-0.5-0.5-0.5h-3c-0.276 0-0.5 0.224-0.5 0.5v5.5h-5.5c-0.276 0-0.5 0.224-0.5 0.5v3c0 0.276 0.224 0.5 0.5 0.5h5.5v5.5c0 0.276 0.224 0.5 0.5 0.5h3c0.276 0 0.5-0.224 0.5-0.5v-5.5h5.5c0.276 0 0.5-0.224 0.5-0.5v-3c0-0.276-0.224-0.5-0.5-0.5z" />
</svg>
<Icon width={width} height={height} className={className} viewBox="0 0 448 512">
<path d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z" />
</Icon>
);
};
Plus.defaultProps = {
size: 16,
color: '#000',
};
export default Plus;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Share: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M503.691 189.836L327.687 37.851C312.281 24.546 288 35.347 288 56.015v80.053C127.371 137.907 0 170.1 0 322.326c0 61.441 39.581 122.309 83.333 154.132 13.653 9.931 33.111-2.533 28.077-18.631C66.066 312.814 132.917 274.316 288 272.085V360c0 20.7 24.3 31.453 39.687 18.164l176.004-152c11.071-9.562 11.086-26.753 0-36.328z" />
</Icon>
);
};
export default Share;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Smile: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 496 512">
<path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-110.3 0-200-89.7-200-200S137.7 56 248 56s200 89.7 200 200-89.7 200-200 200zm-80-216c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm4 72.6c-20.8 25-51.5 39.4-84 39.4s-63.2-14.3-84-39.4c-8.5-10.2-23.7-11.5-33.8-3.1-10.2 8.5-11.5 23.6-3.1 33.8 30 36 74.1 56.6 120.9 56.6s90.9-20.6 120.9-56.6c8.5-10.2 7.1-25.3-3.1-33.8-10.1-8.4-25.3-7.1-33.8 3.1z" />
</Icon>
);
};
export default Smile;

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