61 Commits

Author SHA1 Message Date
960f07cd11 feat: ability to create new nodes on enter and delete old nodes on delete 2021-02-14 06:00:55 -06:00
19d302355f feat: add outliner 2020-12-22 14:35:46 -06:00
f4ef7fec83 feat: add task activity 2020-12-22 13:03:08 -06:00
f732b211c9 fix: update bg color variable name in MemberManager 2020-12-18 20:36:08 -06:00
b5fd3b1bf1 refactor: make theme more consistent 2020-12-17 22:56:49 -06:00
ea767f3d19 fix: replace deprecated method with a correct one 2020-12-17 22:47:43 -06:00
7b6624ecc3 feat: redesign project sharing & initial registration
redesigned the project sharing popup to be a multi select dropdown
that populates the options by using the input as a fuzzy search filter
on the current users & invited users.

users can now also be directly invited by email from the project share
window. if invited this way, then the user will receive an email
that sends them to a registration page, then a confirmation page.

the initial registration was always redone so that it uses a similar
system to the above in that it now will accept the first registered
user if there are no other accounts (besides 'system').
2020-12-17 22:39:14 -06:00
6c7203a4aa refactor: move default viper config values to commands/commands.go 2020-10-20 18:58:15 -05:00
86f2d90668 feat(cli): Reset Password Command
Introduce `reset-password` command.

Refs #71
2020-10-20 18:50:54 -05:00
92493deedf refactor: replace moment with dayjs 2020-10-20 16:06:16 -05:00
a288e06123 feat: add 'complete' sort option 2020-09-30 23:38:01 -07:00
ed4775faa5 docs(CONTRIBUTING): add section on unwanted PRs 2020-10-01 00:55:59 -05:00
0c7d2e2c9f feat(Login): add spinner on login 2020-09-23 15:40:35 -07:00
4277b7b2a8 feat: add personal projects
personal projects are projects that have no team.

they can only seen by the project members (one of which is whoever first
creates the project).
2020-09-19 20:23:16 -05:00
28a53f14ad docs(README): update docker badge to filter out nightly 2020-09-19 20:03:33 -05:00
0d4fb6a0d0 fix: member permissions now works correctly 2020-09-19 17:26:02 -05:00
0366b4c7f7 fix(CardComposer): add card button now creates a card 2020-09-18 20:33:15 -05:00
058749cb17 fix(commands/web): return error from ListenAndServe 2020-09-18 20:19:14 -05:00
3d95c6b600 docs(README): add docker pulls badge 2020-09-16 15:15:58 -05:00
c7538a98e5 fix: segfault on database connection failure 2020-09-12 18:23:23 -05:00
fe84f97f18 fix: url encode avatar filename when showing path
fixes #61
2020-09-12 18:12:12 -05:00
52c60abcd7 fix: secret key is no longer hard coded
the secret key for signing JWT tokens is now read from server.secret.

if that does not exist, then a random UUID v4 is generated and used
instead. a log warning is also shown.
2020-09-12 18:03:17 -05:00
9fdb3008db docs(bug_report): add note about server logs 2020-09-12 03:33:24 -05:00
e2ef8a1a19 fix: initial access token after install is now set correctly 2020-09-12 03:24:09 -05:00
61cd376bfd fix: rename host to hostname in example config
fixes #59
2020-09-12 01:32:01 -05:00
ba9fc64fd9 fix: do not add localhost:3333 url to avatar urls
fixes #58
2020-09-12 01:23:48 -05:00
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
8f410cd9b7 fix: use pre-built taskcafe image in docker-compose by default 2020-08-23 11:44:50 -05:00
adae924ca1 docs(README): fix minor typos/inconsistencies 2020-08-23 09:57:10 -05:00
22e4669c34 docs(README): remove docker compose migration step 2020-08-22 02:52:51 -05:00
81b89f2411 docs(README): update docker release badge label 2020-08-21 23:49:25 -05:00
13480acd7e fix(TaskDetails): update member layout 2020-08-21 23:37:48 -05:00
245 changed files with 23994 additions and 8205 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']

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

@ -0,0 +1,30 @@
---
name: Bug report
about: Create a report to help improve Taskcafe
title: ""
labels: ""
assignees: ""
---
**Describe the bug**
A clear and concise description of what the bug is with steps to reproduce the issue.
**Expected behavior**
What did you expect to happen?
**Screenshots / Live demo link**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
Please send the Taskcafe web service logs if applicable.
<!--
Please read the contributing guide before working on any new pull requests!
If you would like to ask a question regarding a possible bug or feature request, please
join the Taskcafe discord - https://discord.gg/JkQDruh
-->

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="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
inkscape:export-ydpi="96" inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
inkscape:export-xdpi="96"
inkscape:export-filename="/home/jordan/Projects/project-citadel/.github/taskcafe-full.png"
viewBox="0 0 1100 350"
version="1.1"
id="svg951"
sodipodi:docname="taskcafe-full.svg"
width="1100"
height="350" height="350"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"> width="1100"
sodipodi:docname="taskcafe-full.svg"
id="svg951"
version="1.1"
viewBox="0 0 1100 350"
inkscape:export-filename="/home/jordan/Projects/project-citadel/.github/taskcafe-full.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<metadata <metadata
id="metadata957"> id="metadata957">
<rdf:RDF> <rdf:RDF>
@ -25,81 +25,70 @@
<dc:format>image/svg+xml</dc:format> <dc:format>image/svg+xml</dc:format>
<dc:type <dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title> <dc:title />
</cc:Work> </cc:Work>
</rdf:RDF> </rdf:RDF>
</metadata> </metadata>
<defs <defs
id="defs955"> id="defs955">
<inkscape:path-effect <inkscape:path-effect
effect="bspline" only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true"
id="path-effect2633" id="path-effect2633"
is_visible="true" effect="bspline" />
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect <inkscape:path-effect
only_selected="false" effect="bspline"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true"
id="path-effect2614" id="path-effect2614"
effect="bspline" />
<inkscape:path-effect
only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true" is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect2500" id="path-effect2500"
effect="bspline" />
<inkscape:path-effect
only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true" is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect2491" id="path-effect2491"
effect="bspline" />
<inkscape:path-effect
only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true" is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect1706" id="path-effect1706"
effect="bspline" />
<inkscape:path-effect
only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true" is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect1697" id="path-effect1697"
effect="bspline" />
<inkscape:path-effect
effect="bspline"
id="path-effect917"
is_visible="true" is_visible="true"
lpeversion="1" lpeversion="1"
weight="33.333333" weight="33.333333"
@ -117,11 +106,11 @@
weight="33.333333" weight="33.333333"
lpeversion="1" lpeversion="1"
is_visible="true" is_visible="true"
id="path-effect950" id="path-effect917"
effect="bspline" /> effect="bspline" />
<inkscape:path-effect <inkscape:path-effect
effect="bspline" effect="bspline"
id="path-effect969" id="path-effect950"
is_visible="true" is_visible="true"
lpeversion="1" lpeversion="1"
weight="33.333333" weight="33.333333"
@ -130,131 +119,150 @@
apply_no_weight="true" apply_no_weight="true"
apply_with_weight="true" apply_with_weight="true"
only_selected="false" /> only_selected="false" />
<inkscape:path-effect
only_selected="false"
apply_with_weight="true"
apply_no_weight="true"
helper_size="0"
steps="2"
weight="33.333333"
lpeversion="1"
is_visible="true"
id="path-effect969"
effect="bspline" />
</defs> </defs>
<sodipodi:namedview <sodipodi:namedview
inkscape:document-rotation="0" inkscape:current-layer="g2585"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1032"
id="namedview953"
showgrid="false"
units="px"
inkscape:zoom="1.2109091"
inkscape:cx="550"
inkscape:cy="241.06607"
inkscape:window-x="0"
inkscape:window-y="-18"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="g2585" /> inkscape:window-y="-18"
inkscape:window-x="0"
inkscape:cy="250.95495"
inkscape:cx="534.60693"
inkscape:zoom="0.85624204"
units="px"
showgrid="false"
id="namedview953"
inkscape:window-height="1032"
inkscape:window-width="1920"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
guidetolerance="10"
gridtolerance="10"
objecttolerance="10"
borderopacity="1"
bordercolor="#666666"
pagecolor="#ffffff"
inkscape:document-rotation="0" />
<g <g
id="g974" transform="matrix(7.6408653,0,0,7.6408653,40.593299,-53.251254)"
transform="matrix(7.6408653,0,0,7.6408653,40.593299,-53.251254)"> id="g974">
<g <g
id="g1721" transform="matrix(0.21947908,0,0,0.21947908,-1.6240521,6.1742219)"
transform="matrix(0.21947908,0,0,0.21947908,-1.6240521,6.1742219)"> id="g1721">
<g <g
transform="translate(0,-133.87521)" id="g2454"
id="g2454"> transform="translate(0,-133.87521)">
<g <g
transform="translate(-35.365658,-44.89936)" id="g2554"
id="g2554"> transform="translate(-35.365658,-44.89936)">
<g <g
transform="translate(0,107.63572)" id="g2433"
id="g2433"> transform="translate(0,107.63572)">
<g <g
transform="translate(0.82337254,1.4684449)" id="g2439"
id="g2439"> transform="translate(0.82337254,1.4684449)">
<g <g
id="g2472"> id="g2472">
<g <g
transform="translate(0,-15.084391)" id="g2585"
id="g2585"> transform="translate(0,-15.084391)">
<g <g
id="g2427" transform="translate(1.0665725)"
transform="matrix(1.9229347,0,0,1.9229347,9.8747364,70.748305)"> id="g4610">
<path
transform="matrix(0.13087523,0,0,0.13087523,-5.3126573,6.9692701)"
style="fill:#7367f0;fill-opacity:1;stroke-width:0.399296"
d="m 372.01783,403.60348 c -54.69036,0 -99.02539,44.33501 -99.02539,99.02537 0,54.69035 44.33503,99.02537 99.02539,99.02537 54.69036,0 99.02538,-44.33502 99.02538,-99.02537 0,-54.69036 -44.33502,-99.02538 -99.02538,-99.02537 z m 0,19.16619 c 44.13497,0 79.85917,35.71742 79.85917,79.85918 0,44.13497 -35.71741,79.85919 -79.85917,79.85919 -44.13497,0 -79.85918,-35.71742 -79.85918,-79.85919 0,-44.13498 35.71742,-79.85918 79.85918,-79.85918 m 55.98288,52.01507 -8.99853,-9.0712 c -1.86352,-1.87869 -4.89736,-1.89107 -6.77605,-0.0272 L 355.78487,521.674 331.91016,497.60565 c -1.86352,-1.87869 -4.89736,-1.89107 -6.77605,-0.0275 l -9.07159,8.99853 c -1.8787,1.86352 -1.89108,4.89736 -0.0272,6.77646 l 36.24849,36.54196 c 1.86352,1.87869 4.89736,1.89107 6.77605,0.0272 l 68.9141,-68.36106 c 1.87828,-1.86392 1.89025,-4.89778 0.0268,-6.77646 z"
id="path1636" />
<g <g
transform="translate(-0.16733365,0.61658838)" id="g2560"
id="g2513"> transform="translate(-28.409706,-8.3958791)">
<path <text
d="m 274.76901,687.50206 h 187.88752 c 51.86479,0 93.94376,-42.07897 93.94376,-93.94375 h 31.31459 c 69.08781,0 125.25834,-56.17055 125.25834,-125.25835 0,-69.08781 -56.17053,-125.25834 -125.25834,-125.25834 H 204.31119 c -13.01512,0 -23.48594,10.47081 -23.48594,23.48593 v 227.03076 c 0,51.86478 42.07898,93.94375 93.94376,93.94375 z M 587.91488,405.67079 c 34.54391,0 62.62917,28.08527 62.62917,62.62917 0,34.5439 -28.08526,62.62917 -62.62917,62.62917 H 556.60029 V 405.67079 Z m 46.6783,375.77503 H 134.14696 c -46.580452,0 -59.693435,-62.62917 -35.228913,-62.62917 H 669.72424 c 24.46452,0 11.54725,62.62917 -35.13106,62.62917 z" xml:space="preserve"
id="path949" style="font-size:124.328px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.690712"
style="fill:#10163a;fill-opacity:1;stroke-width:0.978574" x="196.89775"
transform="matrix(0.13087523,0,0,0.13087523,-5.3126573,6.9692701)" /> y="235.17853"
id="text852"><tspan
sodipodi:role="line"
id="tspan850"
x="196.89775"
y="235.17853"
style="font-size:124.328px;fill:#10163a;fill-opacity:1;stroke-width:0.690712">Taskcafé</tspan></text>
<text
xml:space="preserve"
style="font-size:22.6594px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.596299"
x="223.15549"
y="261.83273"
id="text856"><tspan
sodipodi:role="line"
id="tspan854"
x="223.15549"
y="261.83273"
style="font-size:22.6594px;fill:#262c49;fill-opacity:1;stroke-width:0.596299">An open source project management tool</tspan></text>
</g>
<g
id="g4591"
transform="matrix(0.63214637,0,0,0.63214637,34.070751,71.061726)">
<g <g
style="stroke:#10163a;stroke-width:0.999994;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.9229347,0,0,1.9229347,9.8747364,70.748305)"
id="g1711" id="g2427">
transform="translate(-0.91048867,42.172992)"> <path
id="path1636"
d="m 372.01783,403.60348 c -54.69036,0 -99.02539,44.33501 -99.02539,99.02537 0,54.69035 44.33503,99.02537 99.02539,99.02537 54.69036,0 99.02538,-44.33502 99.02538,-99.02537 0,-54.69036 -44.33502,-99.02538 -99.02538,-99.02537 z m 0,19.16619 c 44.13497,0 79.85917,35.71742 79.85917,79.85918 0,44.13497 -35.71741,79.85919 -79.85917,79.85919 -44.13497,0 -79.85918,-35.71742 -79.85918,-79.85919 0,-44.13498 35.71742,-79.85918 79.85918,-79.85918 m 55.98288,52.01507 -8.99853,-9.0712 c -1.86352,-1.87869 -4.89736,-1.89107 -6.77605,-0.0272 L 355.78487,521.674 331.91016,497.60565 c -1.86352,-1.87869 -4.89736,-1.89107 -6.77605,-0.0275 l -9.07159,8.99853 c -1.8787,1.86352 -1.89108,4.89736 -0.0272,6.77646 l 36.24849,36.54196 c 1.86352,1.87869 4.89736,1.89107 6.77605,0.0272 l 68.9141,-68.36106 c 1.87828,-1.86392 1.89025,-4.89778 0.0268,-6.77646 z"
style="fill:#7367f0;fill-opacity:1;stroke-width:0.399296"
transform="matrix(0.13087523,0,0,0.13087523,-5.3126573,6.9692701)" />
<g <g
id="g2505" id="g2513"
transform="translate(0.09191978,50.168306)"> transform="translate(-0.16733365,0.61658838)">
<path
transform="matrix(0.13087523,0,0,0.13087523,-5.3126573,6.9692701)"
style="fill:#10163a;fill-opacity:1;stroke-width:0.978574"
id="path949"
d="m 274.76901,687.50206 h 187.88752 c 51.86479,0 93.94376,-42.07897 93.94376,-93.94375 h 31.31459 c 69.08781,0 125.25834,-56.17055 125.25834,-125.25835 0,-69.08781 -56.17053,-125.25834 -125.25834,-125.25834 H 204.31119 c -13.01512,0 -23.48594,10.47081 -23.48594,23.48593 v 227.03076 c 0,51.86478 42.07898,93.94375 93.94376,93.94375 z M 587.91488,405.67079 c 34.54391,0 62.62917,28.08527 62.62917,62.62917 0,34.5439 -28.08526,62.62917 -62.62917,62.62917 H 556.60029 V 405.67079 Z m 46.6783,375.77503 H 134.14696 c -46.580452,0 -59.693435,-62.62917 -35.228913,-62.62917 H 669.72424 c 24.46452,0 11.54725,62.62917 -35.13106,62.62917 z" />
<g <g
transform="translate(-0.68526563,40.225035)" transform="translate(-0.91048867,42.172992)"
id="g2638"> id="g1711"
<path style="stroke:#10163a;stroke-width:0.999994;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">
style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" <g
d="m 34.769434,-84.879608 c -1.513055,-1.532226 -2.968787,-3.006403 -2.895671,-4.268256 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985679,-3.166058 1.805573,-4.837199 -1.180106,-1.671141 -4.987365,-3.242735 -4.929449,-5.195115 0.05792,-1.95238 3.981161,-4.28523 4.510279,-6.39714 0.529119,-2.11191 -2.336082,-4.003 -5.201646,-5.89433" transform="translate(0.09191978,50.168306)"
id="path915" id="g2505">
inkscape:original-d="m 34.769434,-84.879608 c -1.48426,-1.560661 -2.968784,-3.006405 -4.453574,-4.510003 1.631212,-1.019797 3.262093,-2.039821 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.54119 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.14299 -11.422003,-4.71488 3.923669,-2.33269 7.846916,-4.66554 11.769976,-6.99871 -2.86505,-1.8909 -5.730251,-3.78199 -8.595774,-5.67338" <g
inkscape:path-effect="#path-effect917" /> id="g2638"
<path transform="translate(-0.68526563,40.225035)">
inkscape:path-effect="#path-effect2614" <path
inkscape:original-d="m 45.467515,-84.87961 c -1.48426,-1.56066 -2.968784,-3.006404 -4.453574,-4.510002 1.631212,-1.019797 3.262093,-2.039821 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.54119 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.142989 -11.422003,-4.714879 3.923669,-2.33269 7.846916,-4.66555 11.769976,-6.99871 -2.86505,-1.8909 -5.730251,-3.78199 -8.595774,-5.67338" inkscape:path-effect="#path-effect917"
id="path915-9" inkscape:original-d="m 34.769434,-84.879608 c -1.48426,-1.560661 -2.968784,-3.006405 -4.453574,-4.510003 1.631212,-1.019797 3.262093,-2.039821 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.54119 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.14299 -11.422003,-4.71488 3.923669,-2.33269 7.846916,-4.66554 11.769976,-6.99871 -2.86505,-1.8909 -5.730251,-3.78199 -8.595774,-5.67338"
d="m 45.467515,-84.87961 c -1.513055,-1.532226 -2.968786,-3.006402 -2.895671,-4.268255 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985679,-3.166058 1.805573,-4.837199 -1.180106,-1.671141 -4.987365,-3.242734 -4.929449,-5.195114 0.05792,-1.95238 3.981165,-4.28524 4.510282,-6.39714 0.529116,-2.11191 -2.336085,-4.003 -5.201649,-5.89433" id="path915"
style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> d="m 34.769434,-84.879608 c -1.513055,-1.532226 -2.968787,-3.006403 -2.895671,-4.268256 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985679,-3.166058 1.805573,-4.837199 -1.180106,-1.671141 -4.987365,-3.242735 -4.929449,-5.195115 0.05792,-1.95238 3.981161,-4.28523 4.510279,-6.39714 0.529119,-2.11191 -2.336082,-4.003 -5.201646,-5.89433"
<path style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" <path
d="m 56.1656,-84.87961 c -1.513055,-1.532227 -2.968786,-3.006403 -2.895671,-4.268257 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985678,-3.166057 1.805572,-4.837198 -1.180105,-1.671141 -4.987363,-3.242733 -4.929448,-5.195113 0.05792,-1.95238 3.981162,-4.28524 4.510282,-6.39715 0.529119,-2.11191 -2.336084,-4.00299 -5.201649,-5.89432" style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path915-9-0" d="m 45.467515,-84.87961 c -1.513055,-1.532226 -2.968786,-3.006402 -2.895671,-4.268255 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985679,-3.166058 1.805573,-4.837199 -1.180106,-1.671141 -4.987365,-3.242734 -4.929449,-5.195114 0.05792,-1.95238 3.981165,-4.28524 4.510282,-6.39714 0.529116,-2.11191 -2.336085,-4.003 -5.201649,-5.89433"
inkscape:original-d="m 56.1656,-84.87961 c -1.48426,-1.560662 -2.968784,-3.006405 -4.453574,-4.510004 1.631212,-1.019796 3.262093,-2.03982 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.541189 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.142987 -11.422003,-4.714877 3.923669,-2.33269 7.846916,-4.66555 11.769976,-6.99872 -2.86505,-1.89089 -5.730251,-3.78198 -8.595774,-5.67337" id="path915-9"
inkscape:path-effect="#path-effect2633" /> inkscape:original-d="m 45.467515,-84.87961 c -1.48426,-1.56066 -2.968784,-3.006404 -4.453574,-4.510002 1.631212,-1.019797 3.262093,-2.039821 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.54119 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.142989 -11.422003,-4.714879 3.923669,-2.33269 7.846916,-4.66555 11.769976,-6.99871 -2.86505,-1.8909 -5.730251,-3.78199 -8.595774,-5.67338"
inkscape:path-effect="#path-effect2614" />
<path
inkscape:path-effect="#path-effect2633"
inkscape:original-d="m 56.1656,-84.87961 c -1.48426,-1.560662 -2.968784,-3.006405 -4.453574,-4.510004 1.631212,-1.019796 3.262093,-2.03982 4.892743,-3.060127 1.447149,-1.770498 2.893976,-3.541189 4.340567,-5.312182 -3.807146,-1.571393 -7.614404,-3.142987 -11.422003,-4.714877 3.923669,-2.33269 7.846916,-4.66555 11.769976,-6.99872 -2.86505,-1.89089 -5.730251,-3.78198 -8.595774,-5.67337"
id="path915-9-0"
d="m 56.1656,-84.87961 c -1.513055,-1.532227 -2.968786,-3.006403 -2.895671,-4.268257 0.07312,-1.261854 1.703997,-2.281876 3.242849,-3.677242 1.538852,-1.395366 2.985678,-3.166057 1.805572,-4.837198 -1.180105,-1.671141 -4.987363,-3.242733 -4.929448,-5.195113 0.05792,-1.95238 3.981162,-4.28524 4.510282,-6.39715 0.529119,-2.11191 -2.336084,-4.00299 -5.201649,-5.89432"
style="fill:none;stroke:#10163a;stroke-width:3.10099;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</g>
</g> </g>
</g> </g>
</g> </g>
<path
id="path2782"
d="m 93.574758,186.094 c -13.47814,0 -24.40427,10.92613 -24.40427,24.40428 0,13.47814 10.92613,24.40427 24.40427,24.40427 13.478152,0 24.404282,-10.92613 24.404282,-24.40427 0,-13.47815 -10.92613,-24.40428 -24.404282,-24.40428 z m 0,4.72341 c 10.876832,0 19.680872,8.80236 19.680872,19.68087 0,10.87683 -8.80236,19.68086 -19.680872,19.68086 -10.876819,0 -19.680863,-8.80236 -19.680863,-19.68086 0,-10.87684 8.802364,-19.68087 19.680863,-19.68087 m 13.796692,12.81883 -2.21764,-2.23554 c -0.45925,-0.463 -1.20693,-0.46604 -1.66993,-0.007 l -13.909644,13.79786 -5.883794,-5.93153 c -0.459258,-0.46298 -1.206929,-0.46603 -1.669926,-0.007 l -2.235645,2.21764 c -0.462989,0.45926 -0.466044,1.20693 -0.0068,1.67002 l 8.933241,9.00557 c 0.459258,0.46299 1.206929,0.46605 1.669926,0.007 l 16.983502,-16.84721 c 0.4629,-0.45935 0.46585,-1.20702 0.007,-1.67002 z"
style="fill:#7367f0;fill-opacity:1;stroke-width:0.0984039" />
</g> </g>
</g> </g>
<g
id="g2560"
transform="translate(0,15.33842)">
<text
xml:space="preserve"
style="font-size:124.328px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.690712"
x="196.89775"
y="235.17853"
id="text852"><tspan
sodipodi:role="line"
id="tspan850"
x="196.89775"
y="235.17853"
style="font-size:124.328px;fill:#10163a;fill-opacity:1;stroke-width:0.690712">Taskcafé</tspan></text>
<text
xml:space="preserve"
style="font-size:22.6594px;line-height:1.25;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans';letter-spacing:0px;word-spacing:0px;opacity:0.99;stroke-width:0.596299"
x="223.15549"
y="261.83273"
id="text856"><tspan
sodipodi:role="line"
id="tspan854"
x="223.15549"
y="261.83273"
style="font-size:22.6594px;fill:#262c49;fill-opacity:1;stroke-width:0.596299">An open source project management tool</tspan></text>
</g>
<path
style="fill:#7367f0;fill-opacity:1;stroke-width:0.0984039"
d="m 93.574758,186.094 c -13.47814,0 -24.40427,10.92613 -24.40427,24.40428 0,13.47814 10.92613,24.40427 24.40427,24.40427 13.478152,0 24.404282,-10.92613 24.404282,-24.40427 0,-13.47815 -10.92613,-24.40428 -24.404282,-24.40428 z m 0,4.72341 c 10.876832,0 19.680872,8.80236 19.680872,19.68087 0,10.87683 -8.80236,19.68086 -19.680872,19.68086 -10.876819,0 -19.680863,-8.80236 -19.680863,-19.68086 0,-10.87684 8.802364,-19.68087 19.680863,-19.68087 m 13.796692,12.81883 -2.21764,-2.23554 c -0.45925,-0.463 -1.20693,-0.46604 -1.66993,-0.007 l -13.909644,13.79786 -5.883794,-5.93153 c -0.459258,-0.46298 -1.206929,-0.46603 -1.669926,-0.007 l -2.235645,2.21764 c -0.462989,0.45926 -0.466044,1.20693 -0.0068,1.67002 l 8.933241,9.00557 c 0.459258,0.46299 1.206929,0.46605 1.669926,0.007 l 16.983502,-16.84721 c 0.4629,-0.45935 0.46585,-1.20702 0.007,-1.67002 z"
id="path2782" />
</g> </g>
</g> </g>
</g> </g>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

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

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? ### Where do I go from here?
If you have noticed a bug or have a feature request, make one! If best to get confirmation So you want to contribute to Taskcafe? Great!
of your bug or feature before starting work on a pull request.
If you have noticed a bug 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 ### Fork & create a branch
@ -27,6 +32,10 @@ The `description` is a decriptive summary of the change the PR will make.
- One PR per fix or feature - One PR per fix or feature
- Setup & install [pre-commit hooks](https://pre-commit.com/#install) then install the hooks `pre-commit install && pre-commit install --hook-type commit-msg` - Setup & install [pre-commit hooks](https://pre-commit.com/#install) then install the hooks `pre-commit install && pre-commit install --hook-type commit-msg`
### Unwanted PRs
- Please do not submit pull requests containing only typo fixes, fixed spelling mistakes, or minor wording changes.
### Git Commit Message Style ### Git Commit Message Style
This project uses the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format. This project uses the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format.

View File

@ -1,24 +1,38 @@
![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&sort=semver" />
</a>
<a href="https://goreportcard.com/report/github.com/JordanKnott/taskcafe">
<img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/JordanKnott/taskcafe" />
</a>
<a href="">
<img alt="Docker pulls" src="https://img.shields.io/docker/pulls/taskcafe/taskcafe" />
</a>
</p>
<p align="center">
Was this project useful? Please consider <a href="https://www.buymeacoffee.com/jordanknott">donating</a> to help me improve it!
</p>
[![Discord](https://img.shields.io/discord/745396499613220955)](https://discord.gg/JkQDruh) **Please note that this project is still in active development. Some options may not work yet! For updates on development, join the Discord server**
[![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)](https://hub.docker.com/repository/docker/taskcafe/taskcafe)
[![Go Report Card](https://goreportcard.com/badge/github.com/JordanKnott/taskcafe)](https://goreportcard.com/report/github.com/JordanKnott/taskcafe)
## Overview
![Taskcafe](./.github/taskcafe_preview.png) ![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 ## Features
Currently Taskcafe only offers basic task tracking through a Kanban board. Currently Taskcafe only offers basic task tracking through a Kanban board.
Currently you can do the following to tasks: Currently you can do the following to tasks:
- Task sorting & filtering
- Add colors & named labels - Add colors & named labels
- Add due dates - Add due dates
- Descriptions written in Markdown - Descriptions written in Markdown
@ -26,7 +40,7 @@ Currently you can do the following to tasks:
- Checklists - Checklists
- Mark tasks as complete - Mark tasks as complete
For a list of planned features, check out the [roadmap](https://github.com/JordanKnott/taskcafe/wiki/Roadmap)! For a list of planned features, check out the [Roadmap](https://github.com/JordanKnott/taskcafe/wiki/Roadmap)!
## Installation ## Installation
@ -44,7 +58,6 @@ Now do the following:
``` bash ``` bash
docker-compose -p taskcafe up -d docker-compose -p taskcafe up -d
docker-compose -p taskcafe -f docker-compose.yml -f docker-compose.migrate.yml run --rm migrate
``` ```
This will start a postgres instance as well as a taskcafe instance. This will start a postgres instance as well as a taskcafe instance.
@ -80,25 +93,28 @@ This will:
The newly created `taskcafe` binary can be found in the __dist__ folder. The newly created `taskcafe` binary can be found in the __dist__ folder.
It contains everything neccessary to run except the config file. An example config file can be found in `conf/app.example.toml` It contains everything neccessary to run except the config file. An example config file can be found in `conf/app.example.toml`.
For more information on configuration, please read the [wiki](https://github.com/JordanKnott/taskcafe/wiki/Configuration).
The config will need to be copied to a `conf/app.toml` in the same place the binary is. The config will need to be copied to a `conf/app.toml` in the same place the binary is.
Make sure to fill out the database section of the config in order to connect it to your database. Make sure to fill out the database section of the config in order to connect it to your database.
Then run the database migrations with `taskcafe migrate`. Then run the database migrations with `taskcafe migrate`.
Now you can run the web interface by running `taskcafe web` Now you can run the web interface by running `taskcafe web`.
[A more detailed guide for installing on Ubuntu/Debian](https://github.com/JordanKnott/taskcafe/wiki/Installation-(ubuntu-debian))
## How is this different from X (Trello, NextCloud, etc)? ## How is this different from X (Trello, NextCloud, etc)?
One of the primary goal's of Taskcafe is to provide a project management tool that I personally enjoy using for my One of the primary goals of Taskcafe is to provide a project management tool that I personally enjoy using for my
own projects and fits my workflow. own projects and fits my workflow.
During alpha developement, the current plan is to build the "basic" features - features that are pretty much During alpha development, the current plan is to build the "basic" features - features that are pretty much
standard across all kanban boards / project management tools. standard across all kanban boards / project management tools.
Once Taskcafe is out of alpha, there are many features that I plan on adding that will differentiate it from other products (checkout the Roadmap for ideas on future plans). Once Taskcafe is out of alpha, there are many features that I plan on adding that will differentiate it from other products (check out the [Roadmap](https://github.com/JordanKnott/taskcafe/wiki/Roadmap) for ideas on future plans).
## Contributing & community ## Contributing & community

47
conf/air.toml Normal file
View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
version: "3" version: "3"
services: services:
web: web:
build: . image: taskcafe/taskcafe:latest
# build: .
ports: ports:
- "3333:3333" - "3333:3333"
depends_on: depends_on:

View File

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

View File

@ -6,21 +6,15 @@
"@apollo/client": "^3.0.0-rc.8", "@apollo/client": "^3.0.0-rc.8",
"@apollo/react-common": "^3.1.4", "@apollo/react-common": "^3.1.4",
"@apollo/react-hooks": "^3.1.3", "@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/axios": "^0.14.0",
"@types/color": "^3.0.1", "@types/color": "^3.0.1",
"@types/date-fns": "^2.6.0", "@types/date-fns": "^2.6.0",
"@types/jest": "^24.0.0", "@types/jest": "^24.0.0",
"@types/jwt-decode": "^2.2.1", "@types/jwt-decode": "^2.2.1",
"@types/lodash": "^4.14.149", "@types/lodash": "^4.14.149",
"@types/marked": "^1.2.2",
"@types/node": "^12.0.0", "@types/node": "^12.0.0",
"@types/query-string": "^6.3.0",
"@types/react": "^16.9.21", "@types/react": "^16.9.21",
"@types/react-beautiful-dnd": "^12.1.1", "@types/react-beautiful-dnd": "^12.1.1",
"@types/react-datepicker": "^2.11.0", "@types/react-datepicker": "^2.11.0",
@ -28,6 +22,8 @@
"@types/react-router": "^5.1.4", "@types/react-router": "^5.1.4",
"@types/react-router-dom": "^5.1.3", "@types/react-router-dom": "^5.1.3",
"@types/react-select": "^3.0.13", "@types/react-select": "^3.0.13",
"@types/react-timeago": "^4.1.1",
"@types/react-window": "^1.8.2",
"@types/styled-components": "^5.0.0", "@types/styled-components": "^5.0.0",
"apollo-cache-inmemory": "^1.6.5", "apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8", "apollo-client": "^2.6.8",
@ -40,14 +36,16 @@
"axios-auth-refresh": "^2.2.7", "axios-auth-refresh": "^2.2.7",
"color": "^3.1.2", "color": "^3.1.2",
"date-fns": "^2.14.0", "date-fns": "^2.14.0",
"dayjs": "^1.9.1",
"graphql": "^15.0.0", "graphql": "^15.0.0",
"graphql-tag": "^2.10.3", "graphql-tag": "^2.10.3",
"history": "^4.10.1", "history": "^4.10.1",
"immer": "^6.0.3", "immer": "^6.0.3",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",
"lodash": "^4.17.15", "lodash": "^4.17.20",
"moment": "^2.24.0", "marked": "^2.0.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"query-string": "^6.13.7",
"react": "^16.12.0", "react": "^16.12.0",
"react-autosize-textarea": "^7.0.0", "react-autosize-textarea": "^7.0.0",
"react-beautiful-dnd": "^13.0.0", "react-beautiful-dnd": "^13.0.0",
@ -59,6 +57,11 @@
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-scripts": "3.4.0", "react-scripts": "3.4.0",
"react-select": "^3.1.0", "react-select": "^3.1.0",
"react-timeago": "^4.4.0",
"react-toastify": "^6.0.8",
"react-visibility-sensor": "^5.1.1",
"react-window": "^1.8.6",
"rich-markdown-editor": "^10.6.5",
"styled-components": "^5.0.1", "styled-components": "^5.0.1",
"typescript": "~3.7.2" "typescript": "~3.7.2"
}, },
@ -71,7 +74,8 @@
"storybook": "start-storybook -p 9009 -s public", "storybook": "start-storybook -p 9009 -s public",
"build-storybook": "build-storybook -s public", "build-storybook": "build-storybook -s public",
"generate": "graphql-codegen", "generate": "graphql-codegen",
"lint": "eslint --ext js,ts,tsx src" "lint": "eslint --ext js,ts,tsx src",
"tsc": "tsc"
}, },
"eslintConfig": { "eslintConfig": {
"extends": "react-app" "extends": "react-app"
@ -94,10 +98,10 @@
"@graphql-codegen/typescript-operations": "^1.13.2", "@graphql-codegen/typescript-operations": "^1.13.2",
"@graphql-codegen/typescript-react-apollo": "^1.13.2", "@graphql-codegen/typescript-react-apollo": "^1.13.2",
"@storybook/addon-actions": "^5.3.13", "@storybook/addon-actions": "^5.3.13",
"@storybook/addon-links": "^5.3.13",
"@storybook/addon-backgrounds": "^5.3.17", "@storybook/addon-backgrounds": "^5.3.17",
"@storybook/addon-docs": "^5.3.17", "@storybook/addon-docs": "^5.3.17",
"@storybook/addon-knobs": "^5.3.17", "@storybook/addon-knobs": "^5.3.17",
"@storybook/addon-links": "^5.3.13",
"@storybook/addon-storysource": "^5.3.17", "@storybook/addon-storysource": "^5.3.17",
"@storybook/addon-viewport": "^5.3.17", "@storybook/addon-viewport": "^5.3.17",
"@storybook/addons": "^5.3.13", "@storybook/addons": "^5.3.13",

View File

@ -1,10 +1,11 @@
import React, { useEffect, useState, useContext } from 'react'; import React, { useEffect } from 'react';
import Admin from 'shared/components/Admin'; import Admin from 'shared/components/Admin';
import Select from 'shared/components/Select'; import Select from 'shared/components/Select';
import GlobalTopNavbar from 'App/TopNavbar'; import GlobalTopNavbar from 'App/TopNavbar';
import { import {
useUsersQuery, useUsersQuery,
useDeleteUserAccountMutation, useDeleteUserAccountMutation,
useDeleteInvitedUserAccountMutation,
useCreateUserAccountMutation, useCreateUserAccountMutation,
UsersDocument, UsersDocument,
UsersQuery, UsersQuery,
@ -16,8 +17,9 @@ import { useForm, Controller } from 'react-hook-form';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer'; import produce from 'immer';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import UserContext, { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import { Redirect } from 'react-router'; import { Redirect } from 'react-router';
import NOOP from 'shared/utils/noop';
const DeleteUserWrapper = styled.div` const DeleteUserWrapper = styled.div`
display: flex; display: flex;
@ -37,6 +39,7 @@ const DeleteUserButton = styled(Button)`
type DeleteUserPopupProps = { type DeleteUserPopupProps = {
onDeleteUser: () => void; onDeleteUser: () => void;
}; };
const DeleteUserPopup: React.FC<DeleteUserPopupProps> = ({ onDeleteUser }) => { const DeleteUserPopup: React.FC<DeleteUserPopupProps> = ({ onDeleteUser }) => {
return ( return (
<DeleteUserWrapper> <DeleteUserWrapper>
@ -47,10 +50,12 @@ const DeleteUserPopup: React.FC<DeleteUserPopupProps> = ({ onDeleteUser }) => {
</DeleteUserWrapper> </DeleteUserWrapper>
); );
}; };
type RoleCodeOption = { type RoleCodeOption = {
label: string; label: string;
value: string; value: string;
}; };
type CreateUserData = { type CreateUserData = {
email: string; email: string;
username: string; username: string;
@ -65,6 +70,7 @@ const CreateUserForm = styled.form`
flex-direction: column; flex-direction: column;
margin: 0 12px; margin: 0 12px;
`; `;
const CreateUserButton = styled(Button)` const CreateUserButton = styled(Button)`
margin-top: 8px; margin-top: 8px;
padding: 6px 12px; padding: 6px 12px;
@ -76,7 +82,7 @@ const AddUserInput = styled(Input)`
`; `;
const InputError = styled.span` const InputError = styled.span`
color: rgba(${props => props.theme.colors.danger}); color: ${props => props.theme.colors.danger};
font-size: 12px; font-size: 12px;
`; `;
@ -85,7 +91,7 @@ type AddUserPopupProps = {
}; };
const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => { 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) => { const createUser = (data: CreateUserData) => {
onAddUser(data); onAddUser(data);
@ -115,7 +121,7 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
control={control} control={control}
name="roleCode" name="roleCode"
rules={{ required: 'Role is required' }} rules={{ required: 'Role is required' }}
render={({ onChange, onBlur, value }) => ( render={({ onChange, value }) => (
<Select <Select
label="Role" label="Role"
value={value} value={value}
@ -166,16 +172,27 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
const AdminRoute = () => { const AdminRoute = () => {
useEffect(() => { useEffect(() => {
document.title = 'Taskcafé | Admin'; document.title = 'Admin | Taskcafé';
}, []); }, []);
const { loading, data } = useUsersQuery(); const { loading, data } = useUsersQuery();
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({
update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
produce(cache, draftCache => {
draftCache.invitedUsers = cache.invitedUsers.filter(
u => u.id !== response.data?.deleteInvitedUserAccount.invitedUser.id,
);
}),
);
},
});
const [deleteUser] = useDeleteUserAccountMutation({ const [deleteUser] = useDeleteUserAccountMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache => updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.users = cache.users.filter(u => u.id !== response.data.deleteUserAccount.userAccount.id); draftCache.users = cache.users.filter(u => u.id !== response.data?.deleteUserAccount.userAccount.id);
}), }),
); );
}, },
@ -186,7 +203,7 @@ const AdminRoute = () => {
query: UsersDocument, query: UsersDocument,
}); });
const newData = produce(cacheData, (draftState: any) => { const newData = produce(cacheData, (draftState: any) => {
draftState.users = [...draftState.users, { ...createData.data.createUserAccount }]; draftState.users = [...draftState.users, { ...createData.data?.createUserAccount }];
}); });
client.writeQuery({ client.writeQuery({
@ -198,21 +215,26 @@ const AdminRoute = () => {
}, },
}); });
if (loading) { if (loading) {
return <GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />; return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />;
} }
if (data && user) { if (data && user) {
if (user.roles.org != 'admin') { if (user.roles.org !== 'admin') {
return <Redirect to="/" />; return <Redirect to="/" />;
} }
return ( return (
<> <>
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} /> <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
<Admin <Admin
initialTab={0} initialTab={0}
users={data.users} users={data.users}
canInviteUser={user.roles.org == 'admin'} invitedUsers={data.invitedUsers}
onInviteUser={() => {}} canInviteUser={user.roles.org === 'admin'}
onUpdateUserPassword={(user, password) => { onInviteUser={NOOP}
onUpdateUserPassword={() => {
hidePopup();
}}
onDeleteInvitedUser={invitedUserID => {
deleteInvitedUser({ variables: { invitedUserID } });
hidePopup(); hidePopup();
}} }}
onDeleteUser={(userID, newOwnerID) => { onDeleteUser={(userID, newOwnerID) => {
@ -224,8 +246,8 @@ const AdminRoute = () => {
$target, $target,
<Popup tab={0} title="Add member" onClose={() => hidePopup()}> <Popup tab={0} title="Add member" onClose={() => hidePopup()}>
<AddUserPopup <AddUserPopup
onAddUser={user => { onAddUser={u => {
const { roleCode, ...userData } = user; const { roleCode, ...userData } = u;
createUser({ variables: { ...userData, roleCode: roleCode.value } }); createUser({ variables: { ...userData, roleCode: roleCode.value } });
hidePopup(); hidePopup();
}} }}

View File

@ -1,16 +1,21 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import {Router, Switch, Route} from 'react-router-dom'; import { Switch, Route, useHistory } from 'react-router-dom';
import * as H from 'history'; import * as H from 'history';
import Dashboard from 'Dashboard'; import Dashboard from 'Dashboard';
import Admin from 'Admin'; import Admin from 'Admin';
import Confirm from 'Confirm';
import Projects from 'Projects'; import Projects from 'Projects';
import Project from 'Projects/Project'; import Project from 'Projects/Project';
import Teams from 'Teams'; import Teams from 'Teams';
import Login from 'Auth'; import Login from 'Auth';
import Install from 'Install'; import Register from 'Register';
import Profile from 'Profile'; import Profile from 'Profile';
import styled from 'styled-components'; import styled from 'styled-components';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context';
import Outline from 'Outline';
const MainContent = styled.div` const MainContent = styled.div`
padding: 0 0 0 0; padding: 0 0 0 0;
@ -20,22 +25,73 @@ const MainContent = styled.div`
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
`; `;
type RefreshTokenResponse = {
accessToken: string;
setup?: null | { confirmToken: string };
};
const AuthorizedRoutes = () => {
const history = useHistory();
const [loading, setLoading] = useState(true);
const { setUser } = useCurrentUser();
useEffect(() => {
const abortController = new AbortController();
fetch('/auth/refresh_token', {
signal: abortController.signal,
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken, setup } = response;
if (setup) {
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
}
}
setLoading(false);
});
return () => {
abortController.abort();
};
}, []);
return loading ? null : (
<Switch>
<MainContent>
<Route exact path="/" component={Dashboard} />
<Route exact path="/projects" component={Projects} />
<Route path="/projects/:projectID" component={Project} />
<Route path="/teams/:teamID" component={Teams} />
<Route path="/outline" component={Outline} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
</MainContent>
</Switch>
);
};
type RoutesProps = { type RoutesProps = {
history: H.History; history: H.History;
}; };
const Routes = ({history}: RoutesProps) => ( const Routes: React.FC<RoutesProps> = () => (
<Switch> <Switch>
<Route exact path="/login" component={Login} /> <Route exact path="/login" component={Login} />
<Route exact path="/install" component={Install} /> <Route exact path="/register" component={Register} />
<MainContent> <Route exact path="/confirm" component={Confirm} />
<Route exact path="/" component={Dashboard} /> <AuthorizedRoutes />
<Route exact path="/projects" component={Projects} />
<Route path="/projects/:projectID" component={Project} />
<Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
</MainContent>
</Switch> </Switch>
); );

View File

@ -1,28 +1,30 @@
import { createGlobalStyle, DefaultTheme } from 'styled-components'; import { DefaultTheme } from 'styled-components';
import Color from 'color';
const theme: DefaultTheme = { const theme: DefaultTheme = {
borderRadius: { borderRadius: {
primary: '3px', primary: '3x',
alternate: '6px', alternate: '6px',
}, },
colors: { colors: {
primary: '115, 103, 240', multiColors: ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'],
secondary: '216, 93, 216', primary: 'rgb(115, 103, 240)',
alternate: '65, 69, 97', secondary: 'rgb(216, 93, 216)',
success: '40, 199, 111', alternate: 'rgb(65, 69, 97)',
danger: '234, 84, 85', success: 'rgb(40, 199, 111)',
warning: '255, 159, 67', danger: 'rgb(234, 84, 85)',
dark: '30, 30, 30', warning: 'rgb(255, 159, 67)',
dark: 'rgb(30, 30, 30)',
text: { text: {
primary: '194, 198, 220', primary: 'rgb(194, 198, 220)',
secondary: '255, 255, 255', secondary: 'rgb(255, 255, 255)',
}, },
border: '65, 69, 97', border: 'rgb(65, 69, 97)',
bg: { bg: {
primary: '16, 22, 58', primary: 'rgb(16, 22, 58)',
secondary: '38, 44, 73', secondary: 'rgb(38, 44, 73)',
}, },
}, },
}; };
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 TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
import styled from 'styled-components/macro'; 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 ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { UserContext, PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context'; import { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import { import {
RoleCode, RoleCode,
useMeQuery, useTopNavbarQuery,
useDeleteProjectMutation, useDeleteProjectMutation,
useGetProjectsQuery, useGetProjectsQuery,
GetProjectsDocument, GetProjectsDocument,
@ -18,6 +18,9 @@ import produce from 'immer';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import MiniProfile from 'shared/components/MiniProfile'; import MiniProfile from 'shared/components/MiniProfile';
import cache from 'App/cache'; import cache from 'App/cache';
import NOOP from 'shared/utils/noop';
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
import theme from './ThemeStyles';
const TeamContainer = styled.div` const TeamContainer = styled.div`
display: flex; display: flex;
@ -60,7 +63,7 @@ const TeamProjectBackground = styled.div<{ color: string }>`
opacity: 1; opacity: 1;
border-radius: 3px; border-radius: 3px;
&:before { &:before {
background: rgba(${props => props.theme.colors.bg.secondary}); background: ${props => props.theme.colors.bg.secondary};
bottom: 0; bottom: 0;
content: ''; content: '';
left: 0; left: 0;
@ -112,7 +115,7 @@ const TeamProjectContainer = styled.div`
margin: 0 4px 4px 0; margin: 0 4px 4px 0;
min-width: 0; min-width: 0;
&:hover ${TeamProjectTitle} { &:hover ${TeamProjectTitle} {
color: rgba(${props => props.theme.colors.text.secondary}); color: ${props => props.theme.colors.text.secondary};
} }
&:hover ${TeamProjectAvatar} { &:hover ${TeamProjectAvatar} {
opacity: 1; opacity: 1;
@ -122,7 +125,7 @@ const TeamProjectContainer = styled.div`
} }
`; `;
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f']; const colors = [theme.colors.primary, theme.colors.secondary];
const ProjectFinder = () => { const ProjectFinder = () => {
const { loading, data } = useGetProjectsQuery(); const { loading, data } = useGetProjectsQuery();
@ -130,12 +133,12 @@ const ProjectFinder = () => {
return <span>loading</span>; return <span>loading</span>;
} }
if (data) { if (data) {
const { projects, teams, organizations } = data; const { projects, teams } = data;
const projectTeams = teams.map(team => { const projectTeams = teams.map(team => {
return { return {
id: team.id, id: team.id,
name: team.name, name: team.name,
projects: projects.filter(project => project.team.id === team.id), projects: projects.filter(project => project.team && project.team.id === team.id),
}; };
}); });
return ( return (
@ -164,7 +167,7 @@ const ProjectFinder = () => {
return <span>error</span>; return <span>error</span>;
}; };
type ProjectPopupProps = { type ProjectPopupProps = {
history: History<History.PoorMansUnknown>; history: any;
name: string; name: string;
projectID: string; projectID: string;
}; };
@ -179,7 +182,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, proje
const newData = produce(cacheData, (draftState: any) => { const newData = produce(cacheData, (draftState: any) => {
draftState.projects = draftState.projects.filter( draftState.projects = draftState.projects.filter(
(project: any) => project.id !== deleteData.data.deleteProject.project.id, (project: any) => project.id !== deleteData.data?.deleteProject.project.id,
); );
}); });
@ -196,7 +199,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, proje
<Popup title={null} tab={0}> <Popup title={null} tab={0}>
<ProjectSettings <ProjectSettings
onDeleteProject={() => { onDeleteProject={() => {
setTab(1, 300); setTab(1, { width: 300 });
}} }}
/> />
</Popup> </Popup>
@ -228,39 +231,41 @@ type GlobalTopNavbarProps = {
menuType?: Array<MenuItem>; menuType?: Array<MenuItem>;
onChangeRole?: (userID: string, roleCode: RoleCode) => void; onChangeRole?: (userID: string, roleCode: RoleCode) => void;
projectMembers?: null | Array<TaskUser>; projectMembers?: null | Array<TaskUser>;
projectInvitedMembers?: null | Array<InvitedUser>;
onSaveProjectName?: (projectName: string) => void; onSaveProjectName?: (projectName: string) => void;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void; onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
onSetTab?: (tab: number) => void; onSetTab?: (tab: number) => void;
onRemoveFromBoard?: (userID: string) => void; onRemoveFromBoard?: (userID: string) => void;
onRemoveInvitedFromBoard?: (email: string) => void;
}; };
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
currentTab, currentTab,
onSetTab, onSetTab,
menuType, menuType,
projectID,
teamID, teamID,
onChangeProjectOwner, onChangeProjectOwner,
onChangeRole, onChangeRole,
name, name,
popupContent, popupContent,
projectMembers, projectMembers,
projectInvitedMembers,
onInviteUser, onInviteUser,
onSaveProjectName, onSaveProjectName,
onRemoveInvitedFromBoard,
onRemoveFromBoard, onRemoveFromBoard,
nameOnly,
}) => { }) => {
const { user, setUserRoles, setUser } = useCurrentUser(); const { user, setUserRoles, setUser } = useCurrentUser();
const { loading, data } = useMeQuery({ const { loading, data } = useTopNavbarQuery({
onCompleted: data => { onCompleted: response => {
if (user && user.roles) { if (user && user.roles) {
setUserRoles({ setUserRoles({
org: user.roles.org, org: user.roles.org,
teams: data.me.teamRoles.reduce((map, obj) => { teams: response.me.teamRoles.reduce((map, obj) => {
map.set(obj.teamID, obj.roleCode); map.set(obj.teamID, obj.roleCode);
return map; return map;
}, new Map<string, string>()), }, new Map<string, string>()),
projects: data.me.projectRoles.reduce((map, obj) => { projects: response.me.projectRoles.reduce((map, obj) => {
map.set(obj.projectID, obj.roleCode); map.set(obj.projectID, obj.roleCode);
return map; return map;
}, new Map<string, string>()), }, new Map<string, string>()),
@ -268,7 +273,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
} }
}, },
}); });
const { showPopup, hidePopup, setTab } = usePopup(); const { showPopup, hidePopup } = usePopup();
const history = useHistory(); const history = useHistory();
const onLogout = () => { const onLogout = () => {
fetch('/auth/logout', { fetch('/auth/logout', {
@ -301,13 +306,31 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
}} }}
/> />
</Popup>, </Popup>,
195, { width: 195 },
); );
}; };
const onOpenSettings = ($target: React.RefObject<HTMLElement>) => { const onOpenSettings = ($target: React.RefObject<HTMLElement>) => {
if (popupContent) { 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: theme.colors.primary },
);
} }
}; };
@ -315,6 +338,34 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
return null; return null;
} }
const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID); const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => {
const member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null;
if (member) {
showPopup(
$targetRef,
<MiniProfile
onRemoveFromBoard={() => {
if (onRemoveInvitedFromBoard) {
onRemoveInvitedFromBoard(member.email);
}
}}
invited
user={{
id: member.email,
fullName: member.email,
bio: 'Invited',
profileIcon: {
bgColor: '#000',
url: null,
initials: member.email.charAt(0),
},
}}
bio=""
/>,
);
}
};
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => { const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null; const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
const warning = const warning =
@ -364,16 +415,18 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
canEditProjectName={userIsTeamOrProjectAdmin} canEditProjectName={userIsTeamOrProjectAdmin}
canInviteUser={userIsTeamOrProjectAdmin} canInviteUser={userIsTeamOrProjectAdmin}
onMemberProfile={onMemberProfile} onMemberProfile={onMemberProfile}
onInvitedMemberProfile={onInvitedMemberProfile}
onInviteUser={onInviteUser} onInviteUser={onInviteUser}
onChangeRole={onChangeRole} onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner} onChangeProjectOwner={onChangeProjectOwner}
onNotificationClick={() => {}} onNotificationClick={onNotificationClick}
onSetTab={onSetTab} onSetTab={onSetTab}
onRemoveFromBoard={onRemoveFromBoard} onRemoveFromBoard={onRemoveFromBoard}
onDashboardClick={() => { onDashboardClick={() => {
history.push('/'); history.push('/');
}} }}
projectMembers={projectMembers} projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
onProfileClick={onProfileClick} onProfileClick={onProfileClick}
onSaveName={onSaveProjectName} onSaveName={onSaveProjectName}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}

View File

@ -3,22 +3,51 @@ import jwtDecode from 'jwt-decode';
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
import { Router } from 'react-router'; import { Router } from 'react-router';
import { PopupProvider } from 'shared/components/PopupMenu'; import { PopupProvider } from 'shared/components/PopupMenu';
import { ToastContainer } from 'react-toastify';
import { setAccessToken } from 'shared/utils/accessToken'; import { setAccessToken } from 'shared/utils/accessToken';
import styled, { ThemeProvider } from 'styled-components'; import styled, { ThemeProvider } from 'styled-components';
import NormalizeStyles from './NormalizeStyles'; import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles'; import BaseStyles from './BaseStyles';
import { theme } from './ThemeStyles'; import theme from './ThemeStyles';
import Routes from './Routes'; import Routes from './Routes';
import { UserContext, CurrentUserRaw, CurrentUserRoles, PermissionLevel, PermissionObjectType } from './context'; 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: ${props => props.theme.colors.danger};
}
.Toastify__toast--warning {
background: ${props => props.theme.colors.warning};
}
.Toastify__toast--success {
background: ${props => props.theme.colors.success};
}
.Toastify__toast-body {
}
.Toastify__progress-bar {
}
.Toastify__close-button {
display: none;
}
`;
const history = createBrowserHistory(); const history = createBrowserHistory();
type RefreshTokenResponse = {
accessToken: string;
isInstalled: boolean;
};
const App = () => { const App = () => {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<CurrentUserRaw | null>(null); const [user, setUser] = useState<CurrentUserRaw | null>(null);
const setUserRoles = (roles: CurrentUserRoles) => { const setUserRoles = (roles: CurrentUserRoles) => {
if (user) { if (user) {
@ -29,32 +58,6 @@ const App = () => {
} }
}; };
useEffect(() => {
fetch('/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
if (!isInstalled) {
history.replace('/install');
}
}
setLoading(false);
});
}, []);
return ( return (
<> <>
<UserContext.Provider value={{ user, setUser, setUserRoles }}> <UserContext.Provider value={{ user, setUser, setUserRoles }}>
@ -63,15 +66,21 @@ const App = () => {
<BaseStyles /> <BaseStyles />
<Router history={history}> <Router history={history}>
<PopupProvider> <PopupProvider>
{loading ? ( <Routes history={history} />
<div>loading</div>
) : (
<>
<Routes history={history} />
</>
)}
</PopupProvider> </PopupProvider>
</Router> </Router>
<StyledContainer
position="bottom-right"
autoClose={5000}
hideProgressBar
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
limit={5}
/>
</ThemeProvider> </ThemeProvider>
</UserContext.Provider> </UserContext.Provider>
</> </>

View File

@ -1,13 +1,10 @@
import React, { useState, useEffect, useContext } from 'react'; import React, { useState, useEffect, useContext } from 'react';
import { useForm } from 'react-hook-form';
import { useHistory } from 'react-router'; 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 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 Auth = () => {
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0); const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
@ -55,7 +52,20 @@ const Auth = () => {
}).then(async x => { }).then(async x => {
const { status } = x; const { status } = x;
if (status === 200) { if (status === 200) {
history.replace('/projects'); const response: RefreshTokenResponse = await x.json();
const { accessToken, setup } = response;
if (setup) {
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
history.replace('/projects');
}
} }
}); });
}, []); }, []);

View File

@ -0,0 +1,61 @@
import React, { useState } from 'react';
import axios from 'axios';
import Confirm from 'shared/components/Confirm';
import { useHistory, useLocation } from 'react-router';
import * as QueryString from 'query-string';
import { toast } from 'react-toastify';
import { Container, LoginWrapper } from './Styles';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context';
const UsersConfirm = () => {
const history = useHistory();
const location = useLocation();
const [registered, setRegistered] = useState(false);
const params = QueryString.parse(location.search);
const { setUser } = useCurrentUser();
return (
<Container>
<LoginWrapper>
<Confirm
hasConfirmToken={params.confirmToken !== undefined}
onConfirmUser={setFailed => {
fetch('/auth/confirm', {
method: 'POST',
body: JSON.stringify({
confirmToken: params.confirmToken,
}),
})
.then(async x => {
const { status } = x;
if (status === 200) {
const response = await x.json();
const { accessToken } = response;
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: {
org: claims.orgRole,
teams: new Map<string, string>(),
projects: new Map<string, string>(),
},
};
setUser(currentUser);
setAccessToken(accessToken);
history.push('/');
} else {
setFailed();
}
})
.catch(() => {
setFailed();
});
}}
/>
</LoginWrapper>
</Container>
);
};
export default UsersConfirm;

View File

@ -1,93 +0,0 @@
import React, { useEffect, useContext } from 'react';
import axios from 'axios';
import Register from 'shared/components/Register';
import { Container, LoginWrapper } from './Styles';
import { useCreateUserAccountMutation, useMeQuery, MeDocument, MeQuery } from 'shared/generated/graphql';
import { useHistory } from 'react-router';
import { getAccessToken, setAccessToken } from 'shared/utils/accessToken';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import { useApolloClient } from '@apollo/react-hooks';
import UserContext, { PermissionLevel, PermissionObjectType } from 'App/context';
import jwtDecode from 'jwt-decode';
const Install = () => {
const client = useApolloClient();
const history = useHistory();
const { setUser } = useContext(UserContext);
useEffect(() => {
fetch('/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
const response: RefreshTokenResponse = await x.json();
const { isInstalled } = response;
if (status === 200 && isInstalled) {
history.replace('/projects');
}
});
}, []);
return (
<Container>
<LoginWrapper>
<Register
onSubmit={(data, setComplete, setError) => {
const accessToken = getAccessToken();
if (data.password !== data.password_confirm) {
setError('password', { type: 'error', message: 'Passwords must match' });
setError('password_confirm', { type: 'error', message: 'Passwords must match' });
} else {
axios
.post(
'/auth/install',
{
user: {
username: data.username,
roleCode: 'admin',
email: data.email,
password: data.password,
initials: data.initials,
fullname: data.fullname,
},
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
.then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.data;
const { accessToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: {
org: claims.orgRole,
teams: new Map<string, string>(),
projects: new Map<string, string>(),
},
};
setUser(currentUser);
setAccessToken(accessToken);
if (!isInstalled) {
history.replace('/install');
}
}
history.push('/projects');
});
}
setComplete(true);
}}
/>
</LoginWrapper>
</Container>
);
};
export default Install;

View File

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

View File

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

View File

@ -0,0 +1,385 @@
import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react';
import { Dot } from 'shared/icons';
import styled from 'styled-components';
import {
findNextDraggable,
getDimensions,
getTargetDepth,
getNodeAbove,
getBelowParent,
findNodeAbove,
getNodeOver,
getLastChildInBranch,
findNodeDepth,
} from './utils';
import { useDrag } from './useDrag';
const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 9px;
background: rgba(${p => p.theme.colors.primary});
svg {
fill: rgba(${p => p.theme.colors.text.primary});
stroke: rgba(${p => p.theme.colors.text.primary});
}
`;
type DraggerProps = {
container: React.RefObject<HTMLDivElement>;
draggedNodes: { nodes: Array<string>; first?: OutlineNode | null };
isDragging: boolean;
onDragEnd: (zone: ImpactZone) => void;
initialPos: { x: number; y: number };
pageRef: React.RefObject<HTMLDivElement>;
};
let timer: any = null;
type windowScrollOptions = {
maxScrollX: number;
maxScrollY: number;
isInTopEdge: boolean;
isInBottomEdge: boolean;
edgeTop: number;
edgeBottom: number;
edgeSize: number;
viewportY: number;
$page: React.RefObject<HTMLDivElement>;
};
function adjustWindowScroll({
maxScrollY,
maxScrollX,
$page,
isInTopEdge,
isInBottomEdge,
edgeTop,
edgeBottom,
edgeSize,
viewportY,
}: windowScrollOptions) {
// Get the current scroll position of the document.
if ($page.current) {
var currentScrollX = $page.current.scrollLeft;
var currentScrollY = $page.current.scrollTop;
// Determine if the window can be scrolled in any particular direction.
var canScrollUp = currentScrollY > 0;
var canScrollDown = currentScrollY < maxScrollY;
// Since we can potentially scroll in two directions at the same time,
// let's keep track of the next scroll, starting with the current scroll.
// Each of these values can then be adjusted independently in the logic
// below.
var nextScrollX = currentScrollX;
var nextScrollY = currentScrollY;
// As we examine the mouse position within the edge, we want to make the
// incremental scroll changes more "intense" the closer that the user
// gets the viewport edge. As such, we'll calculate the percentage that
// the user has made it "through the edge" when calculating the delta.
// Then, that use that percentage to back-off from the "max" step value.
var maxStep = 50;
// Should we scroll up?
if (isInTopEdge && canScrollUp) {
var intensity = (edgeTop - viewportY) / edgeSize;
nextScrollY = nextScrollY - maxStep * intensity;
// Should we scroll down?
} else if (isInBottomEdge && canScrollDown) {
var intensity = (viewportY - edgeBottom) / edgeSize;
nextScrollY = nextScrollY + maxStep * intensity;
}
// Sanitize invalid maximums. An invalid scroll offset won't break the
// subsequent .scrollTo() call; however, it will make it harder to
// determine if the .scrollTo() method should have been called in the
// first place.
nextScrollX = Math.max(0, Math.min(maxScrollX, nextScrollX));
nextScrollY = Math.max(0, Math.min(maxScrollY, nextScrollY));
if (nextScrollX !== currentScrollX || nextScrollY !== currentScrollY) {
$page.current.scrollTo(nextScrollX, nextScrollY);
return true;
} else {
return false;
}
}
}
const Dragger: React.FC<DraggerProps> = ({
draggedNodes,
container,
onDragEnd,
isDragging,
initialPos,
pageRef: $page,
}) => {
const [pos, setPos] = useState<{ x: number; y: number }>(initialPos);
const { outline, impact, setImpact } = useDrag();
const $handle = useRef<HTMLDivElement>(null);
const handleMouseUp = useCallback(() => {
onDragEnd(impact ? impact.zone : { below: null, above: null });
}, [impact]);
const handleMouseMove = useCallback(
e => {
var t0 = performance.now();
e.preventDefault();
const { clientX, clientY, pageX, pageY } = e;
setPos({ x: clientX, y: clientY });
const { curDepth, curPosition, curDraggable } = getNodeOver({ x: clientX, y: clientY }, outline.current);
let depthTarget: number = 0;
let aboveNode: null | OutlineNode = null;
let belowNode: null | OutlineNode = null;
const edgeSize = 50;
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
var edgeTop = edgeSize + 80;
var edgeBottom = viewportHeight - edgeSize;
var isInTopEdge = clientY < edgeTop;
var isInBottomEdge = clientY > edgeBottom;
if ((isInBottomEdge || isInTopEdge) && $page.current) {
var documentWidth = Math.max(
$page.current.scrollWidth,
$page.current.offsetWidth,
$page.current.clientWidth,
$page.current.scrollWidth,
$page.current.offsetWidth,
$page.current.clientWidth,
);
var documentHeight = Math.max(
$page.current.scrollHeight,
$page.current.offsetHeight,
$page.current.clientHeight,
$page.current.scrollHeight,
$page.current.offsetHeight,
$page.current.clientHeight,
);
var maxScrollX = documentWidth - viewportWidth;
var maxScrollY = documentHeight - viewportHeight;
(function checkForWindowScroll() {
clearTimeout(timer);
if (
adjustWindowScroll({
maxScrollX,
maxScrollY,
edgeBottom,
$page,
edgeTop,
edgeSize,
isInBottomEdge,
isInTopEdge,
viewportY: clientY,
})
) {
timer = setTimeout(checkForWindowScroll, 30);
}
})();
} else {
clearTimeout(timer);
}
if (curPosition === 'before') {
belowNode = curDraggable;
} else {
aboveNode = curDraggable;
}
// if belowNode has the depth of 1, then the above element will be a part of a different branch
const { relationships, nodes } = outline.current;
if (!belowNode || !aboveNode) {
if (belowNode) {
aboveNode = findNodeAbove(outline.current, curDepth, belowNode);
} else if (aboveNode) {
let targetBelowNode: RelationshipChild | null = null;
const parent = relationships.get(aboveNode.parent);
if (aboveNode.children !== 0 && !aboveNode.collapsed) {
const abr = relationships.get(aboveNode.id);
if (abr) {
const newTarget = abr.children[0];
if (newTarget) {
targetBelowNode = newTarget;
}
}
} else if (parent) {
const aboveNodeIndex = parent.children.findIndex(c => aboveNode && c.id === aboveNode.id);
if (aboveNodeIndex !== -1) {
if (aboveNodeIndex === parent.children.length - 1) {
targetBelowNode = getBelowParent(aboveNode, outline.current);
} else {
const nextChild = parent.children[aboveNodeIndex + 1];
targetBelowNode = nextChild ?? null;
}
}
}
if (targetBelowNode) {
const depthNodes = nodes.get(targetBelowNode.depth);
if (depthNodes) {
belowNode = depthNodes.get(targetBelowNode.id) ?? null;
}
}
}
}
// if outside outline, get either first or last item in list based on mouse Y
if (!aboveNode && !belowNode) {
if (container && container.current) {
const bounds = container.current.getBoundingClientRect();
if (clientY < bounds.top + bounds.height / 2) {
const rootChildren = outline.current.relationships.get('root');
const rootDepth = outline.current.nodes.get(1);
if (rootChildren && rootDepth) {
const firstChild = rootChildren.children[0];
belowNode = rootDepth.get(firstChild.id) ?? null;
aboveNode = null;
}
} else {
// TODO: enhance to actually get last child item, not last top level branch
const rootChildren = outline.current.relationships.get('root');
const rootDepth = outline.current.nodes.get(1);
if (rootChildren && rootDepth) {
const lastChild = rootChildren.children[rootChildren.children.length - 1];
const lastParentNode = rootDepth.get(lastChild.id) ?? null;
if (lastParentNode) {
const lastBranchChild = getLastChildInBranch(outline.current, lastParentNode);
if (lastBranchChild) {
const lastChildDepth = outline.current.nodes.get(lastBranchChild.depth);
if (lastChildDepth) {
aboveNode = lastChildDepth.get(lastBranchChild.id) ?? null;
}
}
}
}
}
}
}
if (aboveNode) {
const foundDepth = findNodeDepth(outline.current.published, aboveNode.id);
if (foundDepth === null) return;
for (let i = 0; i < draggedNodes.nodes.length; i++) {
const nodeID = draggedNodes.nodes[i];
if (foundDepth.ancestors.find(c => c === nodeID)) {
if (draggedNodes.first) {
belowNode = draggedNodes.first;
aboveNode = findNodeAbove(outline.current, aboveNode ? aboveNode.depth : 1, draggedNodes.first);
} else {
const foundDepth = findNodeDepth(outline.current.published, nodeID);
if (foundDepth === null) return;
const nodeDepth = outline.current.nodes.get(foundDepth.depth);
const targetNode = nodeDepth ? nodeDepth.get(nodeID) : null;
if (targetNode) {
belowNode = targetNode;
aboveNode = findNodeAbove(outline.current, foundDepth.depth, targetNode);
}
}
}
}
}
// calculate available depths
let minDepth = 1;
let maxDepth = 2;
if (aboveNode) {
const aboveParent = relationships.get(aboveNode.parent);
if (aboveNode.children !== 0 && !aboveNode.collapsed) {
minDepth = aboveNode.depth + 1;
maxDepth = aboveNode.depth + 1;
} else if (aboveParent) {
minDepth = aboveNode.depth;
maxDepth = aboveNode.depth + 1;
const aboveNodeIndex = aboveParent.children.findIndex(c => aboveNode && c.id === aboveNode.id);
if (aboveNodeIndex === aboveParent.children.length - 1) {
minDepth = belowNode ? belowNode.depth : minDepth;
}
}
}
if (aboveNode) {
const dimensions = outline.current.dimensions.get(aboveNode.id);
const entry = getDimensions(dimensions?.entry);
if (entry) {
depthTarget = getTargetDepth(clientX, entry.left, { min: minDepth, max: maxDepth });
}
}
let aboveImpact: null | ImpactZoneData = null;
let belowImpact: null | ImpactZoneData = null;
if (aboveNode) {
const aboveDim = outline.current.dimensions.get(aboveNode.id);
if (aboveDim) {
aboveImpact = {
node: aboveNode,
dimensions: aboveDim,
};
}
}
if (belowNode) {
const belowDim = outline.current.dimensions.get(belowNode.id);
if (belowDim) {
belowImpact = {
node: belowNode,
dimensions: belowDim,
};
}
}
setImpact({
zone: {
above: aboveImpact,
below: belowImpact,
},
depth: depthTarget,
});
},
[outline.current.nodes],
);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, []);
const styles = useMemo(() => {
const position: 'fixed' | 'relative' = isDragging ? 'fixed' : 'relative';
return {
cursor: isDragging ? '-webkit-grabbing' : '-webkit-grab',
transform: `translate(${pos.x - 10}px, ${pos.y - 4}px)`,
transition: isDragging ? 'none' : 'transform 500ms',
zIndex: isDragging ? 2 : 1,
position,
};
}, [isDragging, pos]);
return (
<>
{pos && (
<Container ref={$handle} style={styles}>
<Dot width={18} height={18} />
</Container>
)}
</>
);
};
export default Dragger;

View File

@ -0,0 +1,377 @@
import React, { useRef, useEffect, useCallback, useState } from 'react';
import { Dot, CaretDown, CaretRight } from 'shared/icons';
import _ from 'lodash';
import marked from 'marked';
import VisibilitySensor from 'react-visibility-sensor';
import {
EntryChildren,
EntryWrapper,
EntryContent,
EntryInnerContent,
EntryHandle,
ExpandButton,
EntryContentEditor,
EntryContentDisplay,
} from './Styles';
import { useDrag } from './useDrag';
import { getCaretPosition, setCurrentCursorPosition } from './utils';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
type EditorProps = {
text: string;
initFocus: null | { caret: null | number };
autoFocus: number | null;
onChangeCurrentText: (text: string) => void;
onDeleteEntry: (caret: number) => void;
onBlur: () => void;
handleChangeText: (caret: number) => void;
onDepthChange: (delta: number) => void;
onCreateEntry: () => void;
onNodeFocused: () => void;
};
const Editor: React.FC<EditorProps> = ({
text,
onCreateEntry,
initFocus,
autoFocus,
onChangeCurrentText,
onDepthChange,
onDeleteEntry,
onNodeFocused,
handleChangeText,
onBlur,
}) => {
const $editor = useRef<HTMLInputElement>(null);
useOnOutsideClick($editor, true, () => onBlur(), null);
useEffect(() => {
if (autoFocus && $editor.current) {
$editor.current.focus();
$editor.current.setSelectionRange(autoFocus, autoFocus);
onNodeFocused();
}
}, [autoFocus]);
useEffect(() => {
if (initFocus && $editor.current) {
$editor.current.focus();
if (initFocus.caret) {
$editor.current.setSelectionRange(initFocus.caret ?? 0, initFocus.caret ?? 0);
}
onNodeFocused();
}
}, []);
return (
<EntryContentEditor
value={text}
ref={$editor}
onChange={e => {
onChangeCurrentText(e.currentTarget.value);
}}
onKeyDown={e => {
if (e.keyCode === 13) {
e.preventDefault();
// onCreateEntry(parentID, position * 2);
onCreateEntry();
return;
} else if (e.keyCode === 9) {
e.preventDefault();
onDepthChange(e.shiftKey ? -1 : 1);
} else if (e.keyCode === 8) {
const caretPos = e.currentTarget.selectionEnd;
if (caretPos === 0) {
// handleChangeText.flush();
// onDeleteEntry(depth, id, currentText, caretPos);
onDeleteEntry(caretPos);
e.preventDefault();
return;
}
} else if (e.key === 'z' && e.ctrlKey) {
e.preventDefault();
return;
}
handleChangeText(e.currentTarget.selectionEnd ?? 0);
// setCaretPos(e.currentTarget.selectionEnd ?? 0);
// handleChangeText();
}}
/>
);
};
type EntryProps = {
id: string;
collapsed?: boolean;
onToggleCollapse: (id: string, collapsed: boolean) => void;
parentID: string;
onStartDrag: (e: { id: string; clientX: number; clientY: number }) => void;
onStartSelect: (e: { id: string; depth: number }) => void;
isRoot?: boolean;
selection: null | Array<{ id: string }>;
draggedNodes: null | Array<string>;
onNodeFocused: (id: string) => void;
text: string;
entries: Array<ItemElement>;
onTextChange: (id: string, prex: string, next: string, caret: number) => void;
onCancelDrag: () => void;
autoFocus: null | { caret: null | number };
onCreateEntry: (parent: string, nextPositon: number) => void;
position: number;
chain?: Array<string>;
onHandleClick: (id: string) => void;
onDepthChange: (id: string, parent: string, position: number, depth: number, depthDelta: number) => void;
onDeleteEntry: (depth: number, id: string, text: string, caretPos: number) => void;
depth?: number;
};
const Entry: React.FC<EntryProps> = ({
id,
text,
parentID,
isRoot = false,
selection,
onToggleCollapse,
autoFocus,
onStartSelect,
onHandleClick,
onTextChange,
position,
onNodeFocused,
onDepthChange,
onCreateEntry,
onDeleteEntry,
onCancelDrag,
onStartDrag,
collapsed = false,
draggedNodes,
entries,
chain = [],
depth = 0,
}) => {
const $entry = useRef<HTMLDivElement>(null);
const $children = useRef<HTMLDivElement>(null);
const { setNodeDimensions, clearNodeDimensions } = useDrag();
if (autoFocus) {
}
const $snapshot = useRef<{ now: string; prev: string }>({ now: text, prev: text });
const [currentText, setCurrentText] = useState(text);
const [caretPos, setCaretPos] = useState(0);
const $firstRun = useRef<boolean>(true);
useEffect(() => {
if ($firstRun.current) {
$firstRun.current = false;
return;
}
console.log('updating text');
setCurrentText(text);
}, [text]);
const [editor, setEditor] = useState<{ open: boolean; caret: null | number }>({
open: false,
caret: null,
});
useEffect(() => {
if (autoFocus) setEditor({ open: true, caret: null });
}, [autoFocus]);
useEffect(() => {
$snapshot.current.now = currentText;
}, [currentText]);
const handleChangeText = useCallback(
_.debounce(() => {
onTextChange(id, $snapshot.current.prev, $snapshot.current.now, caretPos);
$snapshot.current.prev = $snapshot.current.now;
}, 500),
[],
);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (isRoot) return;
if (!visible) {
clearNodeDimensions(id);
return;
}
if ($entry && $entry.current) {
setNodeDimensions(id, {
entry: $entry,
children: entries.length !== 0 ? $children : null,
});
}
return () => {
clearNodeDimensions(id);
};
}, [position, depth, entries, visible]);
let showHandle = true;
if (draggedNodes && draggedNodes.length === 1 && draggedNodes.find(c => c === id)) {
showHandle = false;
}
let isSelected = false;
if (selection && selection.find(c => c.id === id)) {
isSelected = true;
}
const renderMap: Array<number> = [];
const renderer = {
text(text: any) {
const localId = renderMap.length;
renderMap.push(text.length);
return `<span id="${id}_${localId}">${text}</span>`;
},
codespan(text: any) {
const localId = renderMap.length;
renderMap.push(text.length + 2);
return `<span class="markdown-code" id="${id}_${localId}">${text}</span>`;
},
strong(text: string) {
const idx = parseInt(text.split('"')[1].split('_')[1]);
renderMap[idx] += 4;
return text.replace('<span', '<span class="markdown-strong"');
},
em(text: string) {
const idx = parseInt(text.split('"')[1].split('_')[1]);
renderMap[idx] += 2;
return text.replace('<span', '<span class="markdown-em"');
},
del(text: string) {
const idx = parseInt(text.split('"')[1].split('_')[1]);
renderMap[idx] += 2;
return text.replace('<span', '<span class="markdown-del"');
},
};
// @ts-ignore
marked.use({ renderer });
const handleMouseDown = useCallback(
_.debounce((e: any) => {
onStartDrag({ id, clientX: e.clientX, clientY: e.clientY });
}, 100),
[],
);
return (
<VisibilitySensor
onChange={v => {
if (v) {
setVisible(v);
}
}}
>
<EntryWrapper isSelected={isSelected} isDragging={!showHandle}>
{!isRoot && (
<EntryContent>
{entries.length !== 0 && (
<ExpandButton onClick={() => onToggleCollapse(id, !collapsed)}>
{collapsed ? <CaretRight width={20} height={20} /> : <CaretDown width={20} height={20} />}
</ExpandButton>
)}
{showHandle && (
<EntryHandle
onMouseUp={() => {
handleMouseDown.cancel();
onHandleClick(id);
}}
onMouseDown={e => {
handleMouseDown(e);
}}
>
<Dot width={18} height={18} />
</EntryHandle>
)}
<EntryInnerContent
onMouseDown={() => {
onStartSelect({ id, depth });
}}
ref={$entry}
>
{editor.open ? (
<Editor
onDepthChange={delta => onDepthChange(id, parentID, depth, position, delta)}
onBlur={() => setEditor({ open: false, caret: null })}
onNodeFocused={() => onNodeFocused(id)}
autoFocus={autoFocus ? (autoFocus.caret ? autoFocus.caret : 0) : null}
initFocus={editor.open ? { caret: editor.caret } : null}
text={currentText}
onDeleteEntry={caret => {
handleChangeText.flush();
onDeleteEntry(depth, id, currentText, caret);
}}
onCreateEntry={() => {
onCreateEntry(parentID, position * 2);
}}
onChangeCurrentText={text => setCurrentText(text)}
handleChangeText={caret => {
setCaretPos(caret);
handleChangeText();
}}
/>
) : (
<EntryContentDisplay
onClick={e => {
let offset = 0;
let textNode: any;
if (document.caretPositionFromPoint) {
// standard
const range = document.caretPositionFromPoint(e.pageX, e.pageY);
console.dir(range);
if (range) {
textNode = range.offsetNode;
offset = range.offset;
}
} else if (document.caretRangeFromPoint) {
// WebKit
const range = document.caretRangeFromPoint(e.pageX, e.pageY);
if (range) {
textNode = range.startContainer;
offset = range.startOffset;
}
}
const id = textNode.parentNode.id.split('_');
const index = parseInt(id[1]);
let caret = offset;
for (let i = 0; i < index; i++) {
caret += renderMap[i];
}
setEditor({ open: true, caret });
}}
dangerouslySetInnerHTML={{ __html: marked.parseInline(text) }}
/>
)}
</EntryInnerContent>
</EntryContent>
)}
{entries.length !== 0 && !collapsed && (
<EntryChildren ref={$children} isRoot={isRoot}>
{entries
.sort((a, b) => a.position - b.position)
.map(entry => (
<Entry
onDeleteEntry={onDeleteEntry}
onHandleClick={onHandleClick}
onDepthChange={onDepthChange}
parentID={id}
key={entry.id}
onTextChange={onTextChange}
position={entry.position}
text={entry.text}
depth={depth + 1}
draggedNodes={draggedNodes}
collapsed={entry.collapsed}
id={entry.id}
autoFocus={entry.focus}
onNodeFocused={onNodeFocused}
onStartSelect={onStartSelect}
onStartDrag={onStartDrag}
onCancelDrag={onCancelDrag}
entries={entry.children ?? []}
chain={[...chain, id]}
selection={selection}
onToggleCollapse={onToggleCollapse}
onCreateEntry={onCreateEntry}
/>
))}
</EntryChildren>
)}
</EntryWrapper>
</VisibilitySensor>
);
};
export default Entry;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,22 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar'; import GlobalTopNavbar from 'App/TopNavbar';
import { Link } from 'react-router-dom';
import { getAccessToken } from 'shared/utils/accessToken'; import { getAccessToken } from 'shared/utils/accessToken';
import Settings from 'shared/components/Settings'; 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 axios from 'axios';
import { useCurrentUser } from 'App/context'; 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` const MainContent = styled.div`
padding: 0 0 50px 80px; padding: 0 0 50px 80px;
@ -18,6 +28,7 @@ const Projects = () => {
const $fileUpload = useRef<HTMLInputElement>(null); const $fileUpload = useRef<HTMLInputElement>(null);
const [clearProfileAvatar] = useClearProfileAvatarMutation(); const [clearProfileAvatar] = useClearProfileAvatarMutation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [updateUserInfo] = useUpdateUserInfoMutation();
const [updateUserPassword] = useUpdateUserPasswordMutation(); const [updateUserPassword] = useUpdateUserPasswordMutation();
const { loading, data, refetch } = useMeQuery(); const { loading, data, refetch } = useMeQuery();
useEffect(() => { useEffect(() => {
@ -54,7 +65,7 @@ const Projects = () => {
} }
}} }}
/> />
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} /> <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
{!loading && data && ( {!loading && data && (
<Settings <Settings
profile={data.me.user} profile={data.me.user}
@ -65,6 +76,14 @@ const Projects = () => {
}} }}
onResetPassword={(password, done) => { onResetPassword={(password, done) => {
updateUserPassword({ variables: { userID: user.id, password } }); 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(); done();
}} }}
onProfileAvatarRemove={() => { onProfileAvatarRemove={() => {

View File

@ -0,0 +1,323 @@
import React, { useState, useEffect } from 'react';
import styled, { css } from 'styled-components';
import { Checkmark, User, Calendar, Tags, Clock } from 'shared/icons';
import { TaskMetaFilters, TaskMeta, TaskMetaMatch, DueDateFilterType } from 'shared/components/Lists';
import Input from 'shared/components/ControlledInput';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import produce from 'immer';
import { mixin } from 'shared/utils/styles';
import Member from 'shared/components/Member';
const FilterMember = styled(Member)`
margin: 2px 0;
&:hover {
cursor: pointer;
background: ${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: ${props => props.theme.colors.primary};
}
`;
export const ActionTitle = styled.span`
margin-left: 20px;
`;
const ActionItemSeparator = styled.li`
color: ${props => mixin.rgba(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: ${props => props.theme.colors.primary};
}
&: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(${props => props.theme.colors.primary});
}
`;
const ActionExtraMenuSeparator = styled.li`
color: ${props => props.theme.colors.text.primary};
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,86 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/utils/sorting';
import { mixin } from 'shared/utils/styles';
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: ${props => props.theme.colors.primary};
}
`;
export const ActionTitle = styled.span`
margin-left: 20px;
`;
const ActionItemSeparator = styled.li`
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)};
font-size: 12px;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.75rem;
padding-bottom: 0.25rem;
`;
type SortPopupProps = {
sorting: TaskSorting;
onChangeTaskSorting: (taskSorting: TaskSorting) => void;
};
const SortPopup: React.FC<SortPopupProps> = ({ sorting, onChangeTaskSorting }) => {
const [currentSorting, setSorting] = useState(sorting);
const handleSetSorting = (s: TaskSorting) => {
setSorting(s);
onChangeTaskSorting(s);
};
return (
<ActionsList>
<ActionItem onClick={() => handleSetSorting({ type: TaskSortingType.NONE, direction: TaskSortingDirection.ASC })}>
<ActionTitle>None</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.DUE_DATE, direction: TaskSortingDirection.ASC })}
>
<ActionTitle>Due date</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.MEMBERS, direction: TaskSortingDirection.ASC })}
>
<ActionTitle>Members</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.LABELS, direction: TaskSortingDirection.ASC })}
>
<ActionTitle>Labels</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
>
<ActionTitle>Task title</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.COMPLETE, direction: TaskSortingDirection.ASC })}
>
<ActionTitle>Complete</ActionTitle>
</ActionItem>
</ActionsList>
);
};
export default SortPopup;

View File

@ -1,52 +1,122 @@
import React, { useState, useRef, useContext, useEffect } from 'react'; import React, { useState, useRef } from 'react';
import { MENU_TYPES } from 'shared/components/TopNavbar';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
import LabelManagerEditor from '../LabelManagerEditor';
import styled, { css } from 'styled-components/macro'; import styled, { css } from 'styled-components/macro';
import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons'; import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
import { usePopup, Popup } from 'shared/components/PopupMenu'; 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 { import {
useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation,
useDeleteProjectMemberMutation,
useSetTaskCompleteMutation, useSetTaskCompleteMutation,
useToggleTaskLabelMutation, useToggleTaskLabelMutation,
useUpdateProjectNameMutation,
useFindProjectQuery, useFindProjectQuery,
useSortTaskGroupMutation,
useUpdateTaskGroupNameMutation, useUpdateTaskGroupNameMutation,
useUpdateTaskNameMutation, useUpdateTaskNameMutation,
useUpdateProjectLabelMutation,
useCreateTaskMutation, useCreateTaskMutation,
useDeleteProjectLabelMutation,
useDeleteTaskMutation, useDeleteTaskMutation,
useUpdateTaskLocationMutation, useUpdateTaskLocationMutation,
useUpdateTaskGroupLocationMutation, useUpdateTaskGroupLocationMutation,
useCreateTaskGroupMutation, useCreateTaskGroupMutation,
useDeleteTaskGroupMutation, useDeleteTaskGroupMutation,
useUpdateTaskDescriptionMutation,
useAssignTaskMutation, useAssignTaskMutation,
DeleteTaskDocument,
FindProjectDocument, FindProjectDocument,
useCreateProjectLabelMutation,
useUnassignTaskMutation, useUnassignTaskMutation,
useUpdateTaskDueDateMutation, useUpdateTaskDueDateMutation,
FindProjectQuery, FindProjectQuery,
useUsersQuery, useDuplicateTaskGroupMutation,
DuplicateTaskGroupMutation,
DuplicateTaskGroupDocument,
useDeleteTaskGroupTasksMutation,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import QuickCardEditor from 'shared/components/QuickCardEditor'; import QuickCardEditor from 'shared/components/QuickCardEditor';
import ListActions from 'shared/components/ListActions'; import ListActions from 'shared/components/ListActions';
import MemberManager from 'shared/components/MemberManager'; 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 produce from 'immer';
import MiniProfile from 'shared/components/MiniProfile'; import MiniProfile from 'shared/components/MiniProfile';
import DueDateManager from 'shared/components/DueDateManager'; 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 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` const ProjectBar = styled.div`
display: flex; display: flex;
@ -61,19 +131,19 @@ const ProjectActions = styled.div`
align-items: center; align-items: center;
`; `;
const ProjectAction = styled.div<{ disabled?: boolean }>` const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 15px; font-size: 15px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
&:not(:last-child) { &:not(:last-of-type) {
margin-right: 16px; margin-right: 16px;
} }
&:hover { &:hover {
color: rgba(${props => props.theme.colors.text.secondary}); color: ${props => props.theme.colors.text.secondary};
} }
${props => ${props =>
props.disabled && props.disabled &&
@ -88,6 +158,25 @@ const ProjectActionText = styled.span`
padding-left: 4px; 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 { interface QuickCardEditorState {
isOpen: boolean; isOpen: boolean;
target: React.RefObject<HTMLElement> | null; target: React.RefObject<HTMLElement> | null;
@ -113,18 +202,18 @@ export const BoardLoading = () => {
<> <>
<ProjectBar> <ProjectBar>
<ProjectActions> <ProjectActions>
<ProjectAction disabled> <ProjectAction>
<CheckCircle width={13} height={13} /> <CheckCircle width={13} height={13} />
<ProjectActionText>All Tasks</ProjectActionText> <ProjectActionText>All Tasks</ProjectActionText>
</ProjectAction> </ProjectAction>
<ProjectAction disabled> <ProjectAction>
<Filter width={13} height={13} />
<ProjectActionText>Filter</ProjectActionText>
</ProjectAction>
<ProjectAction disabled>
<Sort width={13} height={13} /> <Sort width={13} height={13} />
<ProjectActionText>Sort</ProjectActionText> <ProjectActionText>Sort</ProjectActionText>
</ProjectAction> </ProjectAction>
<ProjectAction>
<Filter width={13} height={13} />
<ProjectActionText>Filter</ProjectActionText>
</ProjectAction>
</ProjectActions> </ProjectActions>
<ProjectActions> <ProjectActions>
<ProjectAction> <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 ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick, cardLabelVariant }) => {
const [assignTask] = useAssignTaskMutation(); const [assignTask] = useAssignTaskMutation();
const [unassignTask] = useUnassignTaskMutation(); const [unassignTask] = useUnassignTaskMutation();
const $labelsRef = useRef<HTMLDivElement>(null);
const match = useRouteMatch(); const match = useRouteMatch();
const labelsRef = useRef<Array<ProjectLabel>>([]); const labelsRef = useRef<Array<ProjectLabel>>([]);
const membersRef = useRef<Array<TaskUser>>([]);
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const taskLabelsRef = useRef<Array<TaskLabel>>([]); const taskLabelsRef = useRef<Array<TaskLabel>>([]);
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState); const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
const { user } = useCurrentUser();
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({}); const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
const [taskStatusFilter, setTaskStatusFilter] = useState(initTaskStatusFilter);
const [taskMetaFilters, setTaskMetaFilters] = useState(initTaskMetaFilters);
const [taskSorting, setTaskSorting] = useState(initTaskSorting);
const history = useHistory(); const history = useHistory();
const [sortTaskGroup] = useSortTaskGroupMutation({
onCompleted: () => {
toast('List was sorted');
},
});
const [deleteTaskGroup] = useDeleteTaskGroupMutation({ const [deleteTaskGroup] = useDeleteTaskGroupMutation({
update: (client, deletedTaskGroupData) => { update: (client, deletedTaskGroupData) => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
@ -166,7 +280,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter( draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter(
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id, (taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data?.deleteTaskGroup.taskGroup.id,
); );
}), }),
{ projectID }, { projectID },
@ -182,9 +296,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
const { taskGroups } = cache.findProject; const { taskGroups } = cache.findProject;
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id); const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data?.createTask.taskGroup.id);
if (idx !== -1) { if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask }); if (newTaskData.data) {
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
}
} }
}), }),
{ projectID }, { projectID },
@ -199,7 +315,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument, FindProjectDocument,
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] }); if (newTaskGroupData.data) {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
}
}), }),
{ projectID }, { projectID },
); );
@ -210,6 +328,38 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
const { loading, data } = useFindProjectQuery({ const { loading, data } = useFindProjectQuery({
variables: { projectID }, 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 => {
if (resp.data) {
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
}
}),
{ projectID },
);
},
});
const [updateTaskDueDate] = useUpdateTaskDueDateMutation(); const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
const [setTaskComplete] = useSetTaskCompleteMutation(); const [setTaskComplete] = useSetTaskCompleteMutation();
@ -220,19 +370,24 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument, FindProjectDocument,
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation; if (newTask.data) {
if (previousTaskGroupID !== task.taskGroup.id) { const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
const { taskGroups } = cache.findProject; if (previousTaskGroupID !== task.taskGroup.id) {
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID); const { taskGroups } = cache.findProject;
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id); const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) { const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter( if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
(t: Task) => t.id !== task.id, const previousTask = cache.findProject.taskGroups[oldTaskGroupIdx].tasks.find(t => t.id === task.id);
); draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [ (t: Task) => t.id !== task.id,
...taskGroups[newTaskGroupIdx].tasks, );
{ ...task }, if (previousTask) {
]; draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
...taskGroups[newTaskGroupIdx].tasks,
{ ...previousTask },
];
}
}
} }
} }
}), }),
@ -240,6 +395,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
); );
}, },
}); });
const { user } = useCurrentUser();
const [deleteTask] = useDeleteTaskMutation(); const [deleteTask] = useDeleteTaskMutation();
const [toggleTaskLabel] = useToggleTaskLabelMutation({ const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => { onCompleted: newTaskLabel => {
@ -269,6 +425,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
id: `${Math.round(Math.random() * -1000000)}`, id: `${Math.round(Math.random() * -1000000)}`,
name, name,
complete: false, complete: false,
completedAt: null,
taskGroup: { taskGroup: {
__typename: 'TaskGroup', __typename: 'TaskGroup',
id: taskGroup.id, id: taskGroup.id,
@ -305,15 +462,19 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
if (loading) { if (loading) {
return <BoardLoading />; 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; labelsRef.current = data.findProject.labels;
membersRef.current = data.findProject.members;
const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => { 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 taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
const currentTask = taskGroup ? taskGroup.tasks.find(t => t.id === taskID) : null; const currentTask = taskGroup ? taskGroup.tasks.find(t => t.id === taskID) : null;
if (currentTask) { if (currentTask) {
@ -336,23 +497,84 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<> <>
<ProjectBar> <ProjectBar>
<ProjectActions> <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} /> <CheckCircle width={13} height={13} />
<ProjectActionText>All Tasks</ProjectActionText> <ProjectActionText>{getTaskStatusFilterLabel(taskStatusFilter)}</ProjectActionText>
</ProjectAction> </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} /> <Filter width={13} height={13} />
<ProjectActionText>Filter</ProjectActionText> <ProjectActionText>Filter</ProjectActionText>
</ProjectAction> </ProjectAction>
<ProjectAction disabled> {renderMetaFilters(taskMetaFilters, (meta, id) => {
<Sort width={13} height={13} /> setTaskMetaFilters(
<ProjectActionText>Sort</ProjectActionText> produce(taskMetaFilters, draftFilters => {
</ProjectAction> 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>
<ProjectActions> <ProjectActions>
<ProjectAction <ProjectAction
ref={$labelsRef} onClick={$labelsRef => {
onClick={() => {
showPopup( showPopup(
$labelsRef, $labelsRef,
<LabelManagerEditor <LabelManagerEditor
@ -381,7 +603,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onTaskClick={task => { onTaskClick={task => {
history.push(`${match.url}/c/${task.id}`); history.push(`${match.url}/c/${task.id}`);
}} }}
onCardLabelClick={onCardLabelClick ?? (() => {})} onCardLabelClick={onCardLabelClick ?? NOOP}
cardLabelVariant={cardLabelVariant ?? 'large'} cardLabelVariant={cardLabelVariant ?? 'large'}
onTaskDrop={(droppedTask, previousTaskGroupID) => { onTaskDrop={(droppedTask, previousTaskGroupID) => {
updateTaskLocation({ updateTaskLocation({
@ -425,9 +647,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
}); });
}} }}
taskGroups={data.findProject.taskGroups} taskGroups={data.findProject.taskGroups}
taskStatusFilter={taskStatusFilter}
taskMetaFilters={taskMetaFilters}
taskSorting={taskSorting}
onCreateTask={onCreateTask} onCreateTask={onCreateTask}
onCreateTaskGroup={onCreateList} onCreateTaskGroup={onCreateList}
onCardMemberClick={($targetRef, taskID, memberID) => { onCardMemberClick={($targetRef, _taskID, memberID) => {
const member = data.findProject.members.find(m => m.id === memberID); const member = data.findProject.members.find(m => m.id === memberID);
if (member) { if (member) {
showPopup( showPopup(
@ -449,15 +674,44 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => { onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
showPopup( showPopup(
$targetRef, $targetRef,
<Popup title="List actions" tab={0} onClose={() => hidePopup()}> <ListActions
<ListActions taskGroupID={taskGroupID}
taskGroupID={taskGroupID} onDeleteTaskGroupTasks={() => {
onArchiveTaskGroup={tgID => { deleteTaskGroupTasks({ variables: { taskGroupID } });
deleteTaskGroup({ variables: { taskGroupID: tgID } }); 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(); 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 +740,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
</Popup>, </Popup>,
); );
}} }}
onCardMemberClick={($targetRef, taskID, memberID) => { onCardMemberClick={($targetRef, _taskID, memberID) => {
const member = data.findProject.members.find(m => m.id === memberID); const member = data.findProject.members.find(m => m.id === memberID);
if (member) { if (member) {
showPopup( showPopup(
@ -516,8 +770,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
/>, />,
); );
}} }}
onArchiveCard={(_listId: string, cardId: string) => onArchiveCard={(_listId: string, cardId: string) => {
deleteTask({ return deleteTask({
variables: { taskID: cardId }, variables: { taskID: cardId },
update: client => { update: client => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
@ -533,8 +787,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
{ projectID }, { projectID },
); );
}, },
}) });
} }}
onOpenDueDatePopup={($targetRef, task) => { onOpenDueDatePopup={($targetRef, task) => {
showPopup( showPopup(
$targetRef, $targetRef,
@ -549,7 +803,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } }); updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup(); hidePopup();
}} }}
onCancel={() => {}} onCancel={NOOP}
/> />
</Popup>, </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 Modal from 'shared/components/Modal';
import TaskDetails from 'shared/components/TaskDetails'; 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 MemberManager from 'shared/components/MemberManager';
import { useRouteMatch, useHistory } from 'react-router'; import { useRouteMatch, useHistory } from 'react-router';
import { import {
@ -22,7 +22,7 @@ import {
FindTaskDocument, FindTaskDocument,
FindTaskQuery, FindTaskQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import UserContext, { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import MiniProfile from 'shared/components/MiniProfile'; import MiniProfile from 'shared/components/MiniProfile';
import DueDateManager from 'shared/components/DueDateManager'; import DueDateManager from 'shared/components/DueDateManager';
import produce from 'immer'; import produce from 'immer';
@ -31,6 +31,7 @@ import Button from 'shared/components/Button';
import Input from 'shared/components/Input'; import Input from 'shared/components/Input';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import NOOP from 'shared/utils/noop';
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => { const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
const total = checklists.reduce((prev: any, next: any) => { const total = checklists.reduce((prev: any, next: any) => {
@ -75,15 +76,12 @@ const CreateChecklistInput = styled(Input)`
margin-bottom: 8px; margin-bottom: 8px;
`; `;
const InputError = styled.span`
color: rgba(${props => props.theme.colors.danger});
font-size: 12px;
`;
type CreateChecklistPopupProps = { type CreateChecklistPopupProps = {
onCreateChecklist: (data: CreateChecklistData) => void; onCreateChecklist: (data: CreateChecklistData) => void;
}; };
const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => { const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => {
const { register, handleSubmit, errors } = useForm<CreateChecklistData>(); const { register, handleSubmit } = useForm<CreateChecklistData>();
const createUser = (data: CreateChecklistData) => { const createUser = (data: CreateChecklistData) => {
onCreateChecklist(data); onCreateChecklist(data);
}; };
@ -132,9 +130,6 @@ const Details: React.FC<DetailsProps> = ({
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const history = useHistory(); const history = useHistory();
const match = useRouteMatch();
const [currentMemberTask, setCurrentMemberTask] = useState('');
const [memberPopupData, setMemberPopupData] = useState(initialMemberPopupState);
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation(); const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({ const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
update: (client, response) => { update: (client, response) => {
@ -143,21 +138,23 @@ const Details: React.FC<DetailsProps> = ({
FindTaskDocument, FindTaskDocument,
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation; if (response.data) {
if (checklistID !== prevChecklistID) { const { prevChecklistID, taskChecklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID); if (taskChecklistID !== prevChecklistID) {
const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID); const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
if (oldIdx > -1 && newIdx > -1) { const newIdx = cache.findTask.checklists.findIndex(c => c.id === taskChecklistID);
const item = cache.findTask.checklists[oldIdx].items.find(item => item.id === checklistItem.id); if (oldIdx > -1 && newIdx > -1) {
if (item) { const item = cache.findTask.checklists[oldIdx].items.find(i => i.id === checklistItem.id);
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter( if (item) {
i => i.id !== checklistItem.id, draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
); i => i.id !== checklistItem.id,
draftCache.findTask.checklists[newIdx].items.push({ );
...item, draftCache.findTask.checklists[newIdx].items.push({
position: checklistItem.position, ...item,
taskChecklistID: checklistID, position: checklistItem.position,
}); taskChecklistID: taskChecklistID,
});
}
} }
} }
} }
@ -193,7 +190,7 @@ const Details: React.FC<DetailsProps> = ({
produce(cache, draftCache => { produce(cache, draftCache => {
const { checklists } = cache.findTask; const { checklists } = cache.findTask;
draftCache.findTask.checklists = checklists.filter( draftCache.findTask.checklists = checklists.filter(
c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id, c => c.id !== deleteData.data?.deleteTaskChecklist.taskChecklist.id,
); );
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = { draftCache.findTask.badges.checklist = {
@ -217,8 +214,10 @@ const Details: React.FC<DetailsProps> = ({
FindTaskDocument, FindTaskDocument,
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
const item = createData.data.createTaskChecklist; if (createData.data) {
draftCache.findTask.checklists.push({ ...item }); const item = createData.data.createTaskChecklist;
draftCache.findTask.checklists.push({ ...item });
}
}), }),
{ taskID }, { taskID },
); );
@ -232,36 +231,14 @@ const Details: React.FC<DetailsProps> = ({
FindTaskDocument, FindTaskDocument,
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem; if (deleteData.data) {
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID); const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
if (targetIdx > -1) { const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter( if (targetIdx > -1) {
c => item.id !== c.id, draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
); c => item.id !== c.id,
} );
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); }
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}),
{ taskID },
);
},
});
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
update: (client, newTaskItem) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask;
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
if (idx !== -1) {
draftCache.findTask.checklists[idx].items.push({ ...item });
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = { draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge', __typename: 'ChecklistBadge',
@ -274,7 +251,33 @@ const Details: React.FC<DetailsProps> = ({
); );
}, },
}); });
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } }); const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
update: (client, newTaskItem) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
if (newTaskItem.data) {
const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask;
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
if (idx !== -1) {
draftCache.findTask.checklists[idx].items.push({ ...item });
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}
}
}),
{ taskID },
);
},
});
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID }, fetchPolicy: 'cache-and-network' });
const [setTaskComplete] = useSetTaskCompleteMutation(); const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({ const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
onCompleted: () => { onCompleted: () => {
@ -295,25 +298,26 @@ const Details: React.FC<DetailsProps> = ({
}, },
}); });
if (loading) { if (loading) {
return <div>loading</div>; return null;
} }
if (!data) { if (!data) {
return <div>loading</div>; return null;
} }
return ( return (
<> <>
<Modal <Modal
width={768} width={1070}
onClose={() => { onClose={() => {
history.push(projectURL); history.push(projectURL);
}} }}
renderContent={() => { renderContent={() => {
return ( return (
<TaskDetails <TaskDetails
me={data.me.user}
task={data.findTask} task={data.findTask}
onChecklistDrop={checklist => { onChecklistDrop={checklist => {
updateTaskChecklistLocation({ updateTaskChecklistLocation({
variables: { checklistID: checklist.id, position: checklist.position }, variables: { taskChecklistID: checklist.id, position: checklist.position },
optimisticResponse: { optimisticResponse: {
__typename: 'Mutation', __typename: 'Mutation',
@ -328,20 +332,24 @@ const Details: React.FC<DetailsProps> = ({
}, },
}); });
}} }}
onChecklistItemDrop={(prevChecklistID, checklistID, checklistItem) => { onChecklistItemDrop={(prevChecklistID, taskChecklistID, checklistItem) => {
updateTaskChecklistItemLocation({ updateTaskChecklistItemLocation({
variables: { checklistID, checklistItemID: checklistItem.id, position: checklistItem.position }, variables: {
taskChecklistID,
taskChecklistItemID: checklistItem.id,
position: checklistItem.position,
},
optimisticResponse: { optimisticResponse: {
__typename: 'Mutation', __typename: 'Mutation',
updateTaskChecklistItemLocation: { updateTaskChecklistItemLocation: {
__typename: 'UpdateTaskChecklistItemLocationPayload', __typename: 'UpdateTaskChecklistItemLocationPayload',
prevChecklistID, prevChecklistID,
checklistID, taskChecklistID,
checklistItem: { checklistItem: {
__typename: 'TaskChecklistItem', __typename: 'TaskChecklistItem',
position: checklistItem.position, position: checklistItem.position,
id: checklistItem.id, id: checklistItem.id,
taskChecklistID: checklistID, taskChecklistID,
}, },
}, },
}, },
@ -398,7 +406,7 @@ const Details: React.FC<DetailsProps> = ({
if (member) { if (member) {
showPopup( showPopup(
$targetRef, $targetRef,
<Popup title={null} onClose={() => {}} tab={0}> <Popup title={null} onClose={NOOP} tab={0}>
<MiniProfile <MiniProfile
user={member} user={member}
bio="None" bio="None"
@ -412,19 +420,19 @@ const Details: React.FC<DetailsProps> = ({
); );
} }
}} }}
onOpenAddMemberPopup={(task, $targetRef) => { onOpenAddMemberPopup={(_task, $targetRef) => {
showPopup( showPopup(
$targetRef, $targetRef,
<Popup title="Members" tab={0} onClose={() => {}}> <Popup title="Members" tab={0} onClose={NOOP}>
<MemberManager <MemberManager
availableMembers={availableMembers} availableMembers={availableMembers}
activeMembers={data.findTask.assigned} activeMembers={data.findTask.assigned}
onMemberChange={(member, isActive) => { onMemberChange={(member, isActive) => {
if (user) { if (user) {
if (isActive) { if (isActive) {
assignTask({ variables: { taskID: data.findTask.id, userID: user.id } }); assignTask({ variables: { taskID: data.findTask.id, userID: member.id } });
} else { } else {
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } }); unassignTask({ variables: { taskID: data.findTask.id, userID: member.id } });
} }
} }
}} }}
@ -486,7 +494,7 @@ const Details: React.FC<DetailsProps> = ({
showPopup( showPopup(
$targetRef, $targetRef,
<Popup <Popup
title={'Change Due Date'} title="Change Due Date"
tab={0} tab={0}
onClose={() => { onClose={() => {
hidePopup(); hidePopup();
@ -502,9 +510,10 @@ const Details: React.FC<DetailsProps> = ({
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } }); updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup(); hidePopup();
}} }}
onCancel={() => {}} onCancel={NOOP}
/> />
</Popup>, </Popup>,
{ showDiamond: false, targetPadding: '0' },
); );
}} }}
/> />

View File

@ -3,32 +3,11 @@ import updateApolloCache from 'shared/utils/cache';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer'; import produce from 'immer';
import { import {
useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation,
useDeleteProjectMemberMutation,
useSetTaskCompleteMutation,
useToggleTaskLabelMutation,
useUpdateProjectNameMutation,
useFindProjectQuery,
useUpdateTaskGroupNameMutation,
useUpdateTaskNameMutation,
useUpdateProjectLabelMutation, useUpdateProjectLabelMutation,
useCreateTaskMutation,
useDeleteProjectLabelMutation, useDeleteProjectLabelMutation,
useDeleteTaskMutation,
useUpdateTaskLocationMutation,
useUpdateTaskGroupLocationMutation,
useCreateTaskGroupMutation,
useDeleteTaskGroupMutation,
useUpdateTaskDescriptionMutation,
useAssignTaskMutation,
DeleteTaskDocument,
FindProjectDocument, FindProjectDocument,
useCreateProjectLabelMutation, useCreateProjectLabelMutation,
useUnassignTaskMutation,
useUpdateTaskDueDateMutation,
FindProjectQuery, FindProjectQuery,
useUsersQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import LabelManager from 'shared/components/PopupMenu/LabelManager'; import LabelManager from 'shared/components/PopupMenu/LabelManager';
import LabelEditor from 'shared/components/PopupMenu/LabelEditor'; import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
@ -57,7 +36,9 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
FindProjectDocument, FindProjectDocument,
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel }); if (newLabelData.data) {
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel });
}
}), }),
{ {
projectID, projectID,
@ -74,7 +55,7 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findProject.labels = cache.findProject.labels.filter( draftCache.findProject.labels = cache.findProject.labels.filter(
label => label.id !== newLabelData.data.deleteProjectLabel.id, label => label.id !== newLabelData.data?.deleteProjectLabel.id,
); );
}), }),
{ projectID }, { projectID },

View File

@ -3,8 +3,8 @@ import React, { useState, useRef, useEffect, useContext } from 'react';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar'; import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import AsyncSelect from 'react-select/async';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import LabelManagerEditor from './LabelManagerEditor';
import { import {
useParams, useParams,
Route, Route,
@ -16,11 +16,12 @@ import {
} from 'react-router-dom'; } from 'react-router-dom';
import { import {
useUpdateProjectMemberRoleMutation, useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation, useInviteProjectMembersMutation,
useDeleteProjectMemberMutation, useDeleteProjectMemberMutation,
useToggleTaskLabelMutation, useToggleTaskLabelMutation,
useUpdateProjectNameMutation, useUpdateProjectNameMutation,
useFindProjectQuery, useFindProjectQuery,
useDeleteInvitedProjectMemberMutation,
useUpdateTaskNameMutation, useUpdateTaskNameMutation,
useCreateTaskMutation, useCreateTaskMutation,
useDeleteTaskMutation, useDeleteTaskMutation,
@ -31,17 +32,27 @@ import {
FindProjectDocument, FindProjectDocument,
FindProjectQuery, FindProjectQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import produce from 'immer'; import produce from 'immer';
import UserContext, { useCurrentUser } from 'App/context'; import UserContext, { useCurrentUser } from 'App/context';
import Input from 'shared/components/Input'; import Input from 'shared/components/Input';
import Member from 'shared/components/Member'; import Member from 'shared/components/Member';
import EmptyBoard from 'shared/components/EmptyBoard';
import NOOP from 'shared/utils/noop';
import { Lock, Cross } from 'shared/icons';
import Button from 'shared/components/Button';
import { useApolloClient } from '@apollo/react-hooks';
import TaskAssignee from 'shared/components/TaskAssignee';
import gql from 'graphql-tag';
import { colourStyles } from 'shared/components/Select';
import Board, { BoardLoading } from './Board'; import Board, { BoardLoading } from './Board';
import Details from './Details'; import Details from './Details';
import EmptyBoard from 'shared/components/EmptyBoard'; import LabelManagerEditor from './LabelManagerEditor';
import { mixin } from '../../shared/utils/styles';
const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant'; const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant';
const RFC2822_EMAIL = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => { const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => {
const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || ''); const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || '');
@ -60,7 +71,7 @@ const UserMember = styled(Member)`
padding: 4px 0; padding: 4px 0;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: rgba(${props => props.theme.colors.bg.primary}, 0.4); background: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
} }
border-radius: 6px; border-radius: 6px;
`; `;
@ -69,29 +80,299 @@ const MemberList = styled.div`
margin: 8px 0; margin: 8px 0;
`; `;
type InviteUserData = {
email?: string;
suerID?: string;
};
type UserManagementPopupProps = { type UserManagementPopupProps = {
projectID: string;
users: Array<User>; users: Array<User>;
projectMembers: Array<TaskUser>; projectMembers: Array<TaskUser>;
onAddProjectMember: (userID: string) => void; onInviteProjectMembers: (data: Array<InviteUserData>) => void;
}; };
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, projectMembers, onAddProjectMember }) => { const VisibiltyPrivateIcon = styled(Lock)`
padding-right: 4px;
`;
const VisibiltyButtonText = styled.span`
color: rgba(${props => props.theme.colors.text.primary});
`;
const ShareActions = styled.div`
border-top: 1px solid #414561;
margin-top: 8px;
padding-top: 8px;
display: flex;
align-items: center;
justify-content: space-between;
`;
const VisibiltyButton = styled.button`
cursor: pointer;
margin: 2px 4px;
padding: 2px 4px;
align-items: center;
justify-content: center;
border-bottom: 1px solid transparent;
&:hover ${VisibiltyButtonText} {
color: rgba(${props => props.theme.colors.text.secondary});
}
&:hover ${VisibiltyPrivateIcon} {
fill: rgba(${props => props.theme.colors.text.secondary});
stroke: rgba(${props => props.theme.colors.text.secondary});
}
&:hover {
border-bottom: 1px solid rgba(${props => props.theme.colors.primary});
}
`;
type MemberFilterOptions = {
projectID?: null | string;
teamID?: null | string;
organization?: boolean;
};
const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => {
console.log(input.trim().length < 3);
if (input && input.trim().length < 3) {
return [];
}
const res = await client.query({
query: gql`
query {
searchMembers(input: {searchFilter:"${input}", projectID:"${projectID}"}) {
id
similarity
status
user {
id
fullName
email
profileIcon {
url
initials
bgColor
}
}
}
}
`,
});
let results: any = [];
const emails: Array<string> = [];
console.log(res.data && res.data.searchMembers);
if (res.data && res.data.searchMembers) {
results = [
...res.data.searchMembers.map((m: any) => {
if (m.status === 'INVITED') {
console.log(`${m.id} is added`);
return {
label: m.id,
value: {
id: m.id,
type: 2,
profileIcon: {
bgColor: '#ccc',
initials: m.id.charAt(0),
},
},
};
} else {
console.log(`${m.user.email} is added`);
emails.push(m.user.email);
return {
label: m.user.fullName,
value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon },
};
}
}),
];
console.log(results);
}
if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) {
results = [
...results,
{
label: input,
value: {
id: input,
type: 1,
profileIcon: {
bgColor: '#ccc',
initials: input.charAt(0),
},
},
},
];
}
return results;
};
type UserOptionProps = {
innerProps: any;
isDisabled: boolean;
isFocused: boolean;
label: string;
data: any;
getValue: any;
};
const OptionWrapper = styled.div<{ isFocused: boolean }>`
cursor: pointer;
padding: 4px 8px;
${props => props.isFocused && `background: rgba(${props.theme.colors.primary});`}
display: flex;
align-items: center;
`;
const OptionContent = styled.div`
display: flex;
flex-direction: column;
margin-left: 12px;
`;
const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>`
display: flex;
align-items: center;
font-size: ${p => p.fontSize}px;
color: rgba(${p => (p.quiet ? p.theme.colors.text.primary : p.theme.colors.text.primary)});
`;
const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
console.log(data);
return !isDisabled ? (
<OptionWrapper {...innerProps} isFocused={isFocused}>
<TaskAssignee
size={32}
member={{
id: '',
fullName: data.value.label,
profileIcon: data.value.profileIcon,
}}
/>
<OptionContent>
<OptionLabel fontSize={16} quiet={false}>
{label}
</OptionLabel>
{data.value.type === 2 && (
<OptionLabel fontSize={14} quiet>
Joined
</OptionLabel>
)}
</OptionContent>
</OptionWrapper>
) : null;
};
const OptionValueWrapper = styled.div`
background: rgba(${props => props.theme.colors.bg.primary});
border-radius: 4px;
margin: 2px;
padding: 3px 6px 3px 4px;
display: flex;
align-items: center;
`;
const OptionValueLabel = styled.span`
font-size: 12px;
color: rgba(${props => props.theme.colors.text.secondary});
`;
const OptionValueRemove = styled.button`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
outline: none;
padding: 0;
margin: 0;
margin-left: 4px;
`;
const OptionValue = ({ data, removeProps }: any) => {
return (
<OptionValueWrapper>
<OptionValueLabel>{data.label}</OptionValueLabel>
<OptionValueRemove {...removeProps}>
<Cross width={14} height={14} />
</OptionValueRemove>
</OptionValueWrapper>
);
};
const InviteButton = styled(Button)`
margin-top: 12px;
height: 32px;
padding: 4px 12px;
width: 100%;
justify-content: center;
`;
const InviteContainer = styled.div`
min-height: 300px;
display: flex;
flex-direction: column;
`;
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({
projectID,
users,
projectMembers,
onInviteProjectMembers,
}) => {
const client = useApolloClient();
const [invitedUsers, setInvitedUsers] = useState<Array<any> | null>(null);
return ( return (
<Popup tab={0} title="Invite a user"> <Popup tab={0} title="Invite a user">
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" /> <InviteContainer>
<MemberList> <AsyncSelect
{users getOptionValue={option => option.value.id}
.filter(u => u.id !== projectMembers.find(p => p.id === u.id)?.id) placeholder="Email address or username"
.map(user => ( noOptionsMessage={() => null}
<UserMember onChange={(e: any) => {
key={user.id} setInvitedUsers(e);
onCardMemberClick={() => onAddProjectMember(user.id)} }}
showName isMulti
member={user} autoFocus
taskID="" cacheOptions
/> styles={colourStyles}
))} defaultOption
</MemberList> components={{
MultiValue: OptionValue,
Option: UserOption,
IndicatorSeparator: null,
DropdownIndicator: null,
}}
loadOptions={(i, cb) => fetchMembers(client, projectID, {}, i, cb)}
/>
</InviteContainer>
<InviteButton
onClick={() => {
if (invitedUsers) {
onInviteProjectMembers(
invitedUsers.map(user => {
if (user.value.type === 0) {
return {
userID: user.value.id,
};
}
return {
email: user.value.id,
};
}),
);
}
}}
disabled={invitedUsers === null}
hoverVariant="none"
fontSize="16px"
>
Send Invite
</InviteButton>
</Popup> </Popup>
); );
}; };
@ -124,6 +405,7 @@ const Project = () => {
const match = useRouteMatch(); const match = useRouteMatch();
const [updateTaskDescription] = useUpdateTaskDescriptionMutation(); const [updateTaskDescription] = useUpdateTaskDescriptionMutation();
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
const [toggleTaskLabel] = useToggleTaskLabelMutation({ const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => { onCompleted: newTaskLabel => {
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels; taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
@ -133,11 +415,32 @@ const Project = () => {
const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY); const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY);
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation(); const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
const [deleteTask] = useDeleteTaskMutation(); const [deleteTask] = useDeleteTaskMutation({
update: (client, resp) =>
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (resp.data) {
const taskGroupIdx = draftCache.findProject.taskGroups.findIndex(
tg => tg.tasks.findIndex(t => t.id === resp.data?.deleteTask.taskID) !== -1,
);
if (taskGroupIdx !== -1) {
draftCache.findProject.taskGroups[taskGroupIdx].tasks = cache.findProject.taskGroups[
taskGroupIdx
].tasks.filter(t => t.id !== resp.data?.deleteTask.taskID);
}
}
}),
{ projectID },
),
});
const [updateTaskName] = useUpdateTaskNameMutation(); const [updateTaskName] = useUpdateTaskNameMutation();
const { loading, data } = useFindProjectQuery({ const { loading, data, error } = useFindProjectQuery({
variables: { projectID }, variables: { projectID },
}); });
@ -148,21 +451,45 @@ const Project = () => {
FindProjectDocument, FindProjectDocument,
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findProject.name = newName.data.updateProjectName.name; draftCache.findProject.name = newName.data?.updateProjectName.name ?? '';
}), }),
{ projectID }, { projectID },
); );
}, },
}); });
const [createProjectMember] = useCreateProjectMemberMutation({ const [inviteProjectMembers] = useInviteProjectMembersMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findProject.members.push({ ...response.data.createProjectMember.member }); if (response.data) {
draftCache.findProject.members = [
...cache.findProject.members,
...response.data.inviteProjectMembers.members,
];
draftCache.findProject.invitedMembers = [
...cache.findProject.invitedMembers,
...response.data.inviteProjectMembers.invitedMembers,
];
}
}),
{ projectID },
);
},
});
const [deleteInvitedProjectMember] = useDeleteInvitedProjectMemberMutation({
update: (client, response) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.invitedMembers = cache.findProject.invitedMembers.filter(
m => m.email !== response.data?.deleteInvitedProjectMember.invitedMember.email ?? '',
);
}), }),
{ projectID }, { projectID },
); );
@ -176,7 +503,7 @@ const Project = () => {
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findProject.members = cache.findProject.members.filter( draftCache.findProject.members = cache.findProject.members.filter(
m => m.id !== response.data.deleteProjectMember.member.id, m => m.id !== response.data?.deleteProjectMember.member.id,
); );
}), }),
{ projectID }, { projectID },
@ -190,7 +517,6 @@ const Project = () => {
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const $labelsRef = useRef<HTMLDivElement>(null); const $labelsRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<Array<ProjectLabel>>([]); const labelsRef = useRef<Array<ProjectLabel>>([]);
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
useEffect(() => { useEffect(() => {
if (data) { if (data) {
document.title = `${data.findProject.name} | Taskcafé`; document.title = `${data.findProject.name} | Taskcafé`;
@ -199,11 +525,14 @@ const Project = () => {
if (loading) { if (loading) {
return ( return (
<> <>
<GlobalTopNavbar onSaveProjectName={projectName => {}} name="" projectID={null} /> <GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
<BoardLoading /> <BoardLoading />
</> </>
); );
} }
if (error) {
history.push('/projects');
}
if (data) { if (data) {
labelsRef.current = data.findProject.labels; labelsRef.current = data.findProject.labels;
@ -220,6 +549,10 @@ const Project = () => {
deleteProjectMember({ variables: { userID, projectID } }); deleteProjectMember({ variables: { userID, projectID } });
hidePopup(); hidePopup();
}} }}
onRemoveInvitedFromBoard={email => {
deleteInvitedProjectMember({ variables: { projectID, email } });
hidePopup();
}}
onSaveProjectName={projectName => { onSaveProjectName={projectName => {
updateProjectName({ variables: { projectID, name: projectName } }); updateProjectName({ variables: { projectID, name: projectName } });
}} }}
@ -227,8 +560,10 @@ const Project = () => {
showPopup( showPopup(
$target, $target,
<UserManagementPopup <UserManagementPopup
onAddProjectMember={userID => { projectID={projectID}
createProjectMember({ variables: { userID, projectID } }); onInviteProjectMembers={members => {
inviteProjectMembers({ variables: { projectID, members } });
hidePopup();
}} }}
users={data.users} users={data.users}
projectMembers={data.findProject.members} projectMembers={data.findProject.members}
@ -239,8 +574,9 @@ const Project = () => {
menuType={[{ name: 'Board', link: location.pathname }]} menuType={[{ name: 'Board', link: location.pathname }]}
currentTab={0} currentTab={0}
projectMembers={data.findProject.members} projectMembers={data.findProject.members}
projectInvitedMembers={data.findProject.invitedMembers}
projectID={projectID} projectID={projectID}
teamID={data.findProject.team.id} teamID={data.findProject.team ? data.findProject.team.id : null}
name={data.findProject.name} name={data.findProject.name}
/> />
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} /> <Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />
@ -261,7 +597,7 @@ const Project = () => {
path={`${match.path}/board/c/:taskID`} path={`${match.path}/board/c/:taskID`}
render={(routeProps: RouteComponentProps<TaskRouteProps>) => ( render={(routeProps: RouteComponentProps<TaskRouteProps>) => (
<Details <Details
refreshCache={() => {}} refreshCache={NOOP}
availableMembers={data.findProject.members} availableMembers={data.findProject.members}
projectURL={`${match.url}/board`} projectURL={`${match.url}/board`}
taskID={routeProps.match.params.taskID} taskID={routeProps.match.params.taskID}
@ -269,10 +605,21 @@ const Project = () => {
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } }); updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
}} }}
onTaskDescriptionChange={(updatedTask, newDescription) => { 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 => { onDeleteTask={deletedTask => {
deleteTask({ variables: { taskID: deletedTask.id } }); deleteTask({ variables: { taskID: deletedTask.id } });
history.push(`${match.url}/board`);
}} }}
onOpenAddLabelPopup={(task, $targetRef) => { onOpenAddLabelPopup={(task, $targetRef) => {
taskLabelsRef.current = task.labels; taskLabelsRef.current = task.labels;

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 styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar'; import GlobalTopNavbar from 'App/TopNavbar';
import Empty from 'shared/undraw/Empty'; import Empty from 'shared/undraw/Empty';
@ -10,48 +10,33 @@ import {
GetProjectsQuery, GetProjectsQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import ProjectGridItem, { AddProjectItem } from 'shared/components/ProjectGridItem';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import NewProject from 'shared/components/NewProject'; 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 Button from 'shared/components/Button';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import Input from 'shared/components/Input'; import Input from 'shared/components/Input';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import produce from 'immer'; import produce from 'immer';
const EmptyStateContent = styled.div` import NOOP from 'shared/utils/noop';
display: flex; import theme from 'App/ThemeStyles';
justy-content: center; import { mixin } from '../shared/utils/styles';
align-items: center;
flex-direction: column;
`;
const EmptyStateTitle = styled.h3`
color: #fff;
font-size: 18px;
`;
const EmptyStatePrompt = styled.span`
color: rgba(${props => props.theme.colors.text.primary});
font-size: 16px;
margin-top: 8px;
`;
const EmptyState = styled(Empty)`
display: block;
margin: 0 auto;
`;
const CreateTeamButton = styled(Button)`
width: 100%;
`;
type CreateTeamData = { teamName: string }; type CreateTeamData = { teamName: string };
type CreateTeamFormProps = { type CreateTeamFormProps = {
onCreateTeam: (teamName: string) => void; onCreateTeam: (teamName: string) => void;
}; };
const CreateTeamFormContainer = styled.form``; const CreateTeamFormContainer = styled.form``;
const CreateTeamButton = styled(Button)`
width: 100%;
`;
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => { const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
const { register, handleSubmit, errors } = useForm<CreateTeamData>(); const { register, handleSubmit } = useForm<CreateTeamData>();
const createTeam = (data: CreateTeamData) => { const createTeam = (data: CreateTeamData) => {
onCreateTeam(data.teamName); onCreateTeam(data.teamName);
}; };
@ -71,7 +56,7 @@ const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
}; };
const ProjectAddTile = styled.div` const ProjectAddTile = styled.div`
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4); background-color: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
background-size: cover; background-size: cover;
background-position: 50%; background-position: 50%;
color: #fff; color: #fff;
@ -186,13 +171,14 @@ const SectionActions = styled.div`
const SectionAction = styled(Button)` const SectionAction = styled(Button)`
padding: 6px 12px; padding: 6px 12px;
`; `;
const SectionActionLink = styled(Link)` const SectionActionLink = styled(Link)`
margin-right: 8px; margin-right: 8px;
`; `;
const ProjectSectionTitle = styled.h3` const ProjectSectionTitle = styled.h3`
font-size: 16px; font-size: 16px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
`; `;
const ProjectsContainer = styled.div` const ProjectsContainer = styled.div`
@ -201,12 +187,7 @@ const ProjectsContainer = styled.div`
max-width: 825px; max-width: 825px;
min-width: 288px; 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)` const AddTeamButton = styled(Button)`
padding: 6px 12px; padding: 6px 12px;
position: absolute; position: absolute;
@ -214,16 +195,11 @@ const AddTeamButton = styled(Button)`
right: 12px; right: 12px;
`; `;
const CreateFirstTeam = styled(Button)`
margin-top: 8px;
`;
type ShowNewProject = { type ShowNewProject = {
open: boolean; open: boolean;
initialTeamID: null | string; initialTeamID: null | string;
}; };
const ProjectLink = styled(Link)``;
const Projects = () => { const Projects = () => {
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' }); const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' });
@ -234,57 +210,64 @@ const Projects = () => {
update: (client, newProject) => { update: (client, newProject) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache => updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.projects.push({ ...newProject.data.createProject }); if (newProject.data) {
draftCache.projects.push({ ...newProject.data.createProject });
}
}), }),
); );
}, },
}); });
const [showNewProject, setShowNewProject] = useState<ShowNewProject>({ open: false, initialTeamID: null }); const [showNewProject, setShowNewProject] = useState<ShowNewProject>({ open: false, initialTeamID: null });
const { user, setUser } = useCurrentUser(); const { user } = useCurrentUser();
const [createTeam] = useCreateTeamMutation({ const [createTeam] = useCreateTeamMutation({
update: (client, createData) => { update: (client, createData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache => updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.teams.push({ ...createData.data.createTeam }); if (createData.data) {
draftCache.teams.push({ ...createData.data?.createTeam });
}
}), }),
); );
}, },
}); });
if (loading) { if (loading) {
return ( return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
<>
<span>loading</span>
</>
);
} }
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f']; const colors = theme.colors.multiColors;
if (data && user) { if (data && user) {
const { projects, teams, organizations } = data; const { projects, teams, organizations } = data;
const organizationID = organizations[0].id ?? null; const organizationID = organizations[0].id ?? null;
const personalProjects = projects
.filter(p => p.team === null)
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
});
const projectTeams = teams const projectTeams = teams
.sort((a, b) => { .sort((a, b) => {
const textA = a.name.toUpperCase(); const textA = a.name.toUpperCase();
const textB = b.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 => { .map(team => {
return { return {
id: team.id, id: team.id,
name: team.name, name: team.name,
projects: projects projects: projects
.filter(project => project.team.id === team.id) .filter(project => project.team && project.team.id === team.id)
.sort((a, b) => { .sort((a, b) => {
const textA = a.name.toUpperCase(); const textA = a.name.toUpperCase();
const textB = b.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 ( return (
<> <>
<GlobalTopNavbar onSaveProjectName={() => {}} projectID={null} name={null} /> <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<Wrapper> <Wrapper>
<ProjectsContainer> <ProjectsContainer>
{user.roles.org === 'admin' && ( {user.roles.org === 'admin' && (
@ -315,39 +298,35 @@ const Projects = () => {
Add Team Add Team
</AddTeamButton> </AddTeamButton>
)} )}
{projectTeams.length === 0 && ( <div>
<EmptyStateContent> <ProjectSectionTitleWrapper>
<EmptyState width={425} height={425} /> <ProjectSectionTitle>Personal Projects</ProjectSectionTitle>
<EmptyStateTitle>No teams exist</EmptyStateTitle> </ProjectSectionTitleWrapper>
<EmptyStatePrompt>Create a new team to get started</EmptyStatePrompt> <ProjectList>
<CreateFirstTeam {personalProjects.map((project, idx) => (
variant="outline" <ProjectListItem key={project.id}>
onClick={$target => { <ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
showPopup( <ProjectTileFade />
$target, <ProjectTileDetails>
<Popup <ProjectTileName>{project.name}</ProjectTileName>
title="Create team" </ProjectTileDetails>
tab={0} </ProjectTile>
onClose={() => { </ProjectListItem>
hidePopup(); ))}
}} <ProjectListItem>
> <ProjectAddTile
<CreateTeamForm onClick={() => {
onCreateTeam={teamName => { setShowNewProject({ open: true, initialTeamID: 'no-team' });
if (organizationID) { }}
createTeam({ variables: { name: teamName, organizationID } }); >
hidePopup(); <ProjectTileFade />
} <ProjectAddTileDetails>
}} <ProjectTileName centered>Create new project</ProjectTileName>
/> </ProjectAddTileDetails>
</Popup>, </ProjectAddTile>
); </ProjectListItem>
}} </ProjectList>
> </div>
Create new team
</CreateFirstTeam>
</EmptyStateContent>
)}
{projectTeams.map(team => { {projectTeams.map(team => {
return ( return (
<div key={team.id}> <div key={team.id}>
@ -401,7 +380,7 @@ const Projects = () => {
initialTeamID={showNewProject.initialTeamID} initialTeamID={showNewProject.initialTeamID}
onCreateProject={(name, teamID) => { onCreateProject={(name, teamID) => {
if (user) { if (user) {
createProject({ variables: { teamID, name, userID: user.id } }); createProject({ variables: { teamID, name } });
setShowNewProject({ open: false, initialTeamID: null }); setShowNewProject({ open: false, initialTeamID: null });
} }
}} }}

View File

@ -0,0 +1,13 @@
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
`;
export const LoginWrapper = styled.div`
width: 60%;
`;

View File

@ -0,0 +1,62 @@
import React, { useState } from 'react';
import axios from 'axios';
import Register from 'shared/components/Register';
import { useHistory, useLocation } from 'react-router';
import * as QueryString from 'query-string';
import { toast } from 'react-toastify';
import { Container, LoginWrapper } from './Styles';
const UsersRegister = () => {
const history = useHistory();
const location = useLocation();
const [registered, setRegistered] = useState(false);
const params = QueryString.parse(location.search);
return (
<Container>
<LoginWrapper>
<Register
registered={registered}
onSubmit={(data, setComplete, setError) => {
if (data.password !== data.password_confirm) {
setError('password', { type: 'error', message: 'Passwords must match' });
setError('password_confirm', { type: 'error', message: 'Passwords must match' });
} else {
// TODO: change to fetch?
fetch('/auth/register', {
method: 'POST',
body: JSON.stringify({
user: {
username: data.username,
roleCode: 'admin',
email: data.email,
password: data.password,
initials: data.initials,
fullname: data.fullname,
},
}),
})
.then(async x => {
const response = await x.json();
const { setup } = response;
console.log(response);
if (setup) {
history.replace(`/confirm?confirmToken=xxxx`);
} else if (params.confirmToken) {
history.replace(`/confirm?confirmToken=${params.confirmToken}`);
} else {
setRegistered(true);
}
})
.catch(() => {
toast('There was an issue trying to register');
});
}
setComplete(true);
}}
/>
</LoginWrapper>
</Container>
);
};
export default UsersRegister;

View File

@ -1,9 +1,9 @@
import React, { useState, useContext } from 'react'; import React, { useState } from 'react';
import Input from 'shared/components/Input'; import Input from 'shared/components/Input';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import produce from 'immer'; import produce from 'immer';
import Button from 'shared/components/Button'; 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 Select from 'shared/components/Select';
import { import {
useGetTeamQuery, useGetTeamQuery,
@ -13,8 +13,6 @@ import {
useUpdateTeamMemberRoleMutation, useUpdateTeamMemberRoleMutation,
GetTeamQuery, GetTeamQuery,
GetTeamDocument, GetTeamDocument,
MeDocument,
MeQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import { UserPlus, Checkmark } from 'shared/icons'; import { UserPlus, Checkmark } from 'shared/icons';
import styled, { css } from 'styled-components/macro'; import styled, { css } from 'styled-components/macro';
@ -22,6 +20,8 @@ import { usePopup, Popup } from 'shared/components/PopupMenu';
import TaskAssignee from 'shared/components/TaskAssignee'; import TaskAssignee from 'shared/components/TaskAssignee';
import Member from 'shared/components/Member'; import Member from 'shared/components/Member';
import ControlledInput from 'shared/components/ControlledInput'; import ControlledInput from 'shared/components/ControlledInput';
import NOOP from 'shared/utils/noop';
import { mixin } from 'shared/utils/styles';
const MemberListWrapper = styled.div` const MemberListWrapper = styled.div`
flex: 1 1; flex: 1 1;
@ -35,7 +35,7 @@ const UserMember = styled(Member)`
padding: 4px 0; padding: 4px 0;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: rgba(${props => props.theme.colors.bg.primary}, 0.4); background: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
} }
border-radius: 6px; border-radius: 6px;
`; `;
@ -120,22 +120,23 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
? css` ? css`
user-select: none; user-select: none;
pointer-events: none; pointer-events: none;
color: rgba(${props.theme.colors.text.primary}, 0.4); color: ${mixin.rgba(props.theme.colors.text.primary, 0.4)};
` `
: css` : css`
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: rgb(115, 103, 240); background: ${props.theme.colors.primary};
} }
`} `}
`; `;
export const Content = styled.div` export const Content = styled.div`
padding: 0 12px 12px; padding: 0 12px 12px;
`; `;
export const CurrentPermission = styled.span` export const CurrentPermission = styled.span`
margin-left: 4px; margin-left: 4px;
color: rgba(${props => props.theme.colors.text.secondary}, 0.4); color: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
`; `;
export const Separator = styled.div` export const Separator = styled.div`
@ -146,13 +147,13 @@ export const Separator = styled.div`
export const WarningText = styled.span` export const WarningText = styled.span`
display: flex; display: flex;
color: rgba(${props => props.theme.colors.text.primary}, 0.4); color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)};
padding: 6px; padding: 6px;
`; `;
export const DeleteDescription = styled.div` export const DeleteDescription = styled.div`
font-size: 14px; font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
`; `;
export const RemoveMemberButton = styled(Button)` export const RemoveMemberButton = styled(Button)`
@ -160,6 +161,7 @@ export const RemoveMemberButton = styled(Button)`
padding: 6px 12px; padding: 6px 12px;
width: 100%; width: 100%;
`; `;
type TeamRoleManagerPopupProps = { type TeamRoleManagerPopupProps = {
currentUserID: string; currentUserID: string;
subject: User; subject: User;
@ -253,7 +255,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
{subject.role && subject.role.code === 'owner' && ( {subject.role && subject.role.code === 'owner' && (
<> <>
<Separator /> <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> </MiniProfileActions>
@ -304,14 +306,14 @@ const MemberItemOption = styled(Button)`
`; `;
const MemberList = styled.div` const MemberList = styled.div`
border-top: 1px solid rgba(${props => props.theme.colors.border}); border-top: 1px solid ${props => props.theme.colors.border};
`; `;
const MemberListItem = styled.div` const MemberListItem = styled.div`
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid rgba(${props => props.theme.colors.border}); border-bottom: 1px solid ${props => props.theme.colors.border};
min-height: 40px; min-height: 40px;
padding: 12px 0 12px 40px; padding: 12px 0 12px 40px;
position: relative; position: relative;
@ -335,11 +337,11 @@ const MemberProfile = styled(TaskAssignee)`
`; `;
const MemberItemName = styled.p` const MemberItemName = styled.p`
color: rgba(${props => props.theme.colors.text.secondary}); color: ${props => props.theme.colors.text.secondary};
`; `;
const MemberItemUsername = styled.p` const MemberItemUsername = styled.p`
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
`; `;
const MemberListHeader = styled.div` const MemberListHeader = styled.div`
@ -348,12 +350,12 @@ const MemberListHeader = styled.div`
`; `;
const ListTitle = styled.h3` const ListTitle = styled.h3`
font-size: 18px; font-size: 18px;
color: rgba(${props => props.theme.colors.text.secondary}); color: ${props => props.theme.colors.text.secondary};
margin-bottom: 12px; margin-bottom: 12px;
`; `;
const ListDesc = styled.span` const ListDesc = styled.span`
font-size: 16px; font-size: 16px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
`; `;
const FilterSearch = styled(Input)` const FilterSearch = styled(Input)`
margin: 0; margin: 0;
@ -385,11 +387,11 @@ const FilterTabItem = styled.li`
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
padding: 6px 8px; padding: 6px 8px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
&:hover { &:hover {
border-radius: 6px; border-radius: 6px;
background: rgba(${props => props.theme.colors.primary}); background: ${props => props.theme.colors.primary};
color: rgba(${props => props.theme.colors.text.secondary}); color: ${props => props.theme.colors.text.secondary};
} }
`; `;
@ -428,11 +430,13 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
GetTeamDocument, GetTeamDocument,
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findTeam.members.push({ if (response.data) {
...response.data.createTeamMember.teamMember, draftCache.findTeam.members.push({
member: { __typename: 'MemberList', projects: [], teams: [] }, ...response.data.createTeamMember.teamMember,
owned: { __typename: 'OwnedList', projects: [], teams: [] }, member: { __typename: 'MemberList', projects: [], teams: [] },
}); owned: { __typename: 'OwnedList', projects: [], teams: [] },
});
}
}), }),
{ teamID }, { teamID },
); );
@ -457,7 +461,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findTeam.members = cache.findTeam.members.filter( draftCache.findTeam.members = cache.findTeam.members.filter(
member => member.id !== response.data.deleteTeamMember.userID, member => member.id !== response.data?.deleteTeamMember.userID,
); );
}), }),
{ teamID }, { teamID },
@ -510,7 +514,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
<MemberList> <MemberList>
{data.findTeam.members.map(member => ( {data.findTeam.members.map(member => (
<MemberListItem> <MemberListItem>
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} /> <MemberProfile showRoleIcons size={32} onMemberProfile={NOOP} member={member} />
<MemberListItemDetails> <MemberListItemDetails>
<MemberItemName>{member.fullName}</MemberItemName> <MemberItemName>{member.fullName}</MemberItemName>
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername> <MemberItemUsername>{`@${member.username}`}</MemberItemUsername>

View File

@ -8,6 +8,7 @@ import {
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Input from 'shared/components/Input'; import Input from 'shared/components/Input';
import theme from 'App/ThemeStyles';
const FilterSearch = styled(Input)` const FilterSearch = styled(Input)`
margin: 0; margin: 0;
@ -34,11 +35,11 @@ const FilterTabItem = styled.li`
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
padding: 6px 8px; padding: 6px 8px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
&:hover { &:hover {
border-radius: 6px; border-radius: 6px;
background: rgba(${props => props.theme.colors.primary}); background: ${props => props.theme.colors.primary};
color: rgba(${props => props.theme.colors.text.secondary}); color: ${props => props.theme.colors.text.secondary};
} }
`; `;
@ -55,7 +56,7 @@ const FilterTabTitle = styled.h2`
`; `;
const ProjectAddTile = styled.div` const ProjectAddTile = styled.div`
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4); background-color: ${props => props.theme.colors.bg.primary};
background-size: cover; background-size: cover;
background-position: 50%; background-position: 50%;
color: #fff; color: #fff;
@ -147,7 +148,7 @@ const ProjectListWrapper = styled.div`
flex: 1 1; flex: 1 1;
`; `;
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f']; const colors = theme.colors.multiColors;
type TeamProjectsProps = { type TeamProjectsProps = {
teamID: string; teamID: string;

View File

@ -1,24 +1,22 @@
import React, { useState, useContext, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import styled, { css } from 'styled-components/macro'; import styled from 'styled-components/macro';
import { MENU_TYPES } from 'shared/components/TopNavbar';
import GlobalTopNavbar from 'App/TopNavbar'; import GlobalTopNavbar from 'App/TopNavbar';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import { Route, Switch, useRouteMatch, Redirect } from 'react-router'; import { Route, Switch, useRouteMatch, Redirect, useParams, useHistory } from 'react-router';
import Members from './Members';
import Projects from './Projects';
import { import {
useGetTeamQuery, useGetTeamQuery,
useDeleteTeamMutation, useDeleteTeamMutation,
GetProjectsDocument, GetProjectsDocument,
GetProjectsQuery, GetProjectsQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import { useParams, useHistory, useLocation } from 'react-router';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import { History } from 'history'; import { History } from 'history';
import produce from 'immer'; import produce from 'immer';
import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings'; 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` const OuterWrapper = styled.div`
display: flex; display: flex;
@ -35,7 +33,7 @@ const Wrapper = styled.div`
`; `;
type TeamPopupProps = { type TeamPopupProps = {
history: History<History.PoorMansUnknown>; history: History<any>;
name: string; name: string;
teamID: string; teamID: string;
}; };
@ -46,9 +44,9 @@ export const TeamPopup: React.FC<TeamPopupProps> = ({ history, name, teamID }) =
update: (client, deleteData) => { update: (client, deleteData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache => updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data.deleteTeam.team.id); draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data?.deleteTeam.team.id);
draftCache.projects = cache.projects.filter( draftCache.projects = cache.projects.filter(
(project: any) => project.team.id !== deleteData.data.deleteTeam.team.id, (project: any) => project.team.id !== deleteData.data?.deleteTeam.team.id,
); );
}), }),
); );
@ -59,7 +57,7 @@ export const TeamPopup: React.FC<TeamPopupProps> = ({ history, name, teamID }) =
<Popup title={null} tab={0}> <Popup title={null} tab={0}>
<TeamSettings <TeamSettings
onDeleteTeam={() => { onDeleteTeam={() => {
setTab(1, 340); setTab(1, { width: 340 });
}} }}
/> />
</Popup> </Popup>
@ -87,18 +85,30 @@ type TeamsRouteProps = {
const Teams = () => { const Teams = () => {
const { teamID } = useParams<TeamsRouteProps>(); const { teamID } = useParams<TeamsRouteProps>();
const history = useHistory(); 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 { user } = useCurrentUser();
const [currentTab, setCurrentTab] = useState(0); const [currentTab, setCurrentTab] = useState(0);
const match = useRouteMatch(); const match = useRouteMatch();
useEffect(() => {
document.title = 'Teams | Taskcafé';
}, []);
if (loading) { if (loading) {
return ( return (
<> <GlobalTopNavbar
<span>loading</span> 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) { if (data && user) {
@ -117,7 +127,7 @@ const Teams = () => {
setCurrentTab(tab); setCurrentTab(tab);
}} }}
popupContent={<TeamPopup history={history} name={data.findTeam.name} teamID={teamID} />} popupContent={<TeamPopup history={history} name={data.findTeam.name} teamID={teamID} />}
onSaveProjectName={() => {}} onSaveProjectName={NOOP}
projectID={null} projectID={null}
name={data.findTeam.name} name={data.findTeam.name}
/> />

View File

@ -8,13 +8,34 @@ import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error'; import { onError } from 'apollo-link-error';
import { enableMapSet } from 'immer'; import { enableMapSet } from 'immer';
import { ApolloLink, Observable, fromPromise } from 'apollo-link'; import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import dayjs from 'dayjs';
import updateLocale from 'dayjs/plugin/updateLocale';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween';
import weekday from 'dayjs/plugin/weekday';
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken'; import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
import cache from './App/cache'; import cache from './App/cache';
import App from './App'; import App from './App';
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8 // https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
dayjs.extend(isSameOrAfter);
dayjs.extend(weekday);
dayjs.extend(isBetween);
dayjs.extend(customParseFormat);
enableMapSet(); enableMapSet();
dayjs.extend(updateLocale);
dayjs.updateLocale('en', {
week: {
dow: 1, // First day of week is Monday
doy: 7, // First week of year must contain 1 January (7 + 1 - 1)
},
});
let forward$; let forward$;
let isRefreshing = false; let isRefreshing = false;
let pendingRequests: any = []; let pendingRequests: any = [];
@ -81,7 +102,7 @@ const errorLink = onError(({ graphQLErrors, networkError, operation, forward })
} }
} }
if (networkError) { if (networkError) {
console.log(`[Network error]: ${networkError}`); console.log(`[Network error]: ${networkError}`); // eslint-disable-line no-console
} }
return undefined; return undefined;
}); });
@ -122,12 +143,13 @@ const client = new ApolloClient({
link: ApolloLink.from([ link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => { onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) { if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) => graphQLErrors.forEach(
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`), ({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`), // eslint-disable-line no-console
); );
} }
if (networkError) { if (networkError) {
console.log(`[Network error]: ${networkError}`); console.log(`[Network error]: ${networkError}`); // eslint-disable-line no-console
} }
}), }),
errorLink, errorLink,

0
frontend/src/outline.d.ts vendored Normal file
View File

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import theme from 'App/ThemeStyles';
import AddList from '.'; import AddList from '.';
export default { export default {
@ -7,7 +8,7 @@ export default {
title: 'AddList', title: 'AddList',
parameters: { parameters: {
backgrounds: [ backgrounds: [
{ name: 'gray', value: '#262c49', default: true }, { name: 'gray', value: theme.colors.bg.secondary, default: true },
{ name: 'white', value: '#ffffff' }, { name: 'white', value: '#ffffff' },
], ],
}, },

View File

@ -67,7 +67,7 @@ export const ListNameEditorWrapper = styled.div`
display: flex; display: flex;
`; `;
export const ListNameEditor = styled(TextareaAutosize)` export const ListNameEditor = styled(TextareaAutosize)`
background-color: ${props => mixin.lighten('#262c49', 0.05)}; background-color: ${props => mixin.lighten(props.theme.colors.bg.secondary, 0.05)};
border: none; border: none;
box-shadow: inset 0 0 0 2px #0079bf; box-shadow: inset 0 0 0 2px #0079bf;
transition: margin 85ms ease-in, background 85ms ease-in; transition: margin 85ms ease-in, background 85ms ease-in;
@ -91,7 +91,7 @@ export const ListNameEditor = styled(TextareaAutosize)`
color: #c2c6dc; color: #c2c6dc;
l &:focus { l &:focus {
background-color: ${props => mixin.lighten('#262c49', 0.05)}; background-color: ${props => mixin.lighten(props.theme.colors.bg.secondary, 0.05)};
} }
`; `;

View File

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

View File

@ -1,10 +1,10 @@
import React, { useRef } from 'react'; import React from 'react';
import Admin from '.';
import { theme } from 'App/ThemeStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import { ThemeProvider } from 'styled-components'; import { ThemeProvider } from 'styled-components';
import { action } from '@storybook/addon-actions'; 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 { export default {
component: Admin, component: Admin,
@ -51,7 +51,9 @@ export const Default = () => {
}, },
}, },
]} ]}
invitedUsers={[]}
onAddUser={action('add user')} onAddUser={action('add user')}
onDeleteInvitedUser={action('delete invited user')}
/> />
</ThemeProvider> </ThemeProvider>
</> </>

View File

@ -1,15 +1,14 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { UserPlus, Checkmark } from 'shared/icons';
import styled, { css } from 'styled-components'; import styled, { css } from 'styled-components';
import TaskAssignee from 'shared/components/TaskAssignee'; import TaskAssignee from 'shared/components/TaskAssignee';
import Select from 'shared/components/Select'; 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 { usePopup, Popup } from 'shared/components/PopupMenu';
import { RoleCode, useUpdateUserRoleMutation } from 'shared/generated/graphql'; import { RoleCode, useUpdateUserRoleMutation } from 'shared/generated/graphql';
import Input from 'shared/components/Input'; import Input from 'shared/components/Input';
import Member from 'shared/components/Member';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import NOOP from 'shared/utils/noop';
import { mixin } from 'shared/utils/styles';
export const RoleCheckmark = styled(Checkmark)` export const RoleCheckmark = styled(Checkmark)`
padding-left: 4px; padding-left: 4px;
@ -60,22 +59,23 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
? css` ? css`
user-select: none; user-select: none;
pointer-events: none; pointer-events: none;
color: rgba(${props.theme.colors.text.primary}, 0.4); color: ${mixin.rgba(props.theme.colors.text.primary, 0.4)};
` `
: css` : css`
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: rgb(115, 103, 240); background: ${props.theme.colors.primary};
} }
`} `}
`; `;
export const Content = styled.div` export const Content = styled.div`
padding: 0 12px 12px; padding: 0 12px 12px;
`; `;
export const CurrentPermission = styled.span` export const CurrentPermission = styled.span`
margin-left: 4px; margin-left: 4px;
color: rgba(${props => props.theme.colors.text.secondary}, 0.4); color: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
`; `;
export const Separator = styled.div` export const Separator = styled.div`
@ -86,13 +86,13 @@ export const Separator = styled.div`
export const WarningText = styled.span` export const WarningText = styled.span`
display: flex; display: flex;
color: rgba(${props => props.theme.colors.text.primary}, 0.4); color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)};
padding: 6px; padding: 6px;
`; `;
export const DeleteDescription = styled.div` export const DeleteDescription = styled.div`
font-size: 14px; font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
`; `;
export const RemoveMemberButton = styled(Button)` export const RemoveMemberButton = styled(Button)`
@ -100,12 +100,13 @@ export const RemoveMemberButton = styled(Button)`
padding: 6px 12px; padding: 6px 12px;
width: 100%; width: 100%;
`; `;
type TeamRoleManagerPopupProps = { type TeamRoleManagerPopupProps = {
user: User; user: User;
users: Array<User>; users: Array<User>;
warning?: string | null; warning?: string | null;
canChangeRole: boolean; canChangeRole?: boolean;
onChangeRole: (roleCode: RoleCode) => void; onChangeRole?: (roleCode: RoleCode) => void;
updateUserPassword?: (user: TaskUser, password: string) => void; updateUserPassword?: (user: TaskUser, password: string) => void;
onDeleteUser?: (userID: string, newOwnerID: string | null) => void; onDeleteUser?: (userID: string, newOwnerID: string | null) => void;
}; };
@ -120,7 +121,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
onChangeRole, onChangeRole,
}) => { }) => {
const { hidePopup, setTab } = usePopup(); 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 [deleteUser, setDeleteUser] = useState<{ label: string; value: string } | null>(null);
const hasOwned = user.owned.projects.length !== 0 || user.owned.teams.length !== 0; const hasOwned = user.owned.projects.length !== 0 || user.owned.teams.length !== 0;
return ( return (
@ -195,7 +196,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
{user.role && user.role.code === 'owner' && ( {user.role && user.role.code === 'owner' && (
<> <>
<Separator /> <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> </MiniProfileActions>
@ -209,7 +210,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>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} teams.`}</DeleteDescription>
<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> </DeleteDescription>
<UserSelect <UserSelect
onChange={v => setDeleteUser(v)} onChange={v => setDeleteUser(v)}
@ -239,7 +240,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
Removing this user from the organzation will remove them from assigned tasks, projects, and teams. Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
</DeleteDescription> </DeleteDescription>
<DeleteDescription>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} 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 <UserPassConfirmButton
onClick={() => { onClick={() => {
// onDeleteUser(); // onDeleteUser();
@ -253,7 +254,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}> <Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
<Content> <Content>
<DeleteDescription> <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. own password.
</DeleteDescription> </DeleteDescription>
<UserPassBar> <UserPassBar>
@ -291,6 +292,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
</> </>
); );
}; };
const UserSelect = styled(Select)` const UserSelect = styled(Select)`
margin: 8px 0; margin: 8px 0;
padding: 8px 0; padding: 8px 0;
@ -299,6 +301,7 @@ const UserSelect = styled(Select)`
const NewUserPassInput = styled(Input)` const NewUserPassInput = styled(Input)`
margin: 8px 0; margin: 8px 0;
`; `;
const InviteMemberButton = styled(Button)` const InviteMemberButton = styled(Button)`
padding: 7px 12px; padding: 7px 12px;
`; `;
@ -307,6 +310,7 @@ const UserPassBar = styled.div`
display: flex; display: flex;
padding-top: 8px; padding-top: 8px;
`; `;
const UserPassConfirmButton = styled(Button)` const UserPassConfirmButton = styled(Button)`
width: 100%; width: 100%;
padding: 7px 12px; padding: 7px 12px;
@ -330,14 +334,14 @@ const MemberItemOption = styled(Button)`
`; `;
const MemberList = styled.div` const MemberList = styled.div`
border-top: 1px solid rgba(${props => props.theme.colors.border}); border-top: 1px solid ${props => props.theme.colors.border};
`; `;
const MemberListItem = styled.div` const MemberListItem = styled.div`
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid rgba(${props => props.theme.colors.border}); border-bottom: 1px solid ${props => props.theme.colors.border};
min-height: 40px; min-height: 40px;
padding: 12px 0 12px 40px; padding: 12px 0 12px 40px;
position: relative; position: relative;
@ -361,11 +365,11 @@ const MemberProfile = styled(TaskAssignee)`
`; `;
const MemberItemName = styled.p` const MemberItemName = styled.p`
color: rgba(${props => props.theme.colors.text.secondary}); color: ${props => props.theme.colors.text.secondary};
`; `;
const MemberItemUsername = styled.p` const MemberItemUsername = styled.p`
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
`; `;
const MemberListHeader = styled.div` const MemberListHeader = styled.div`
@ -374,12 +378,12 @@ const MemberListHeader = styled.div`
`; `;
const ListTitle = styled.h3` const ListTitle = styled.h3`
font-size: 18px; font-size: 18px;
color: rgba(${props => props.theme.colors.text.secondary}); color: ${props => props.theme.colors.text.secondary};
margin-bottom: 12px; margin-bottom: 12px;
`; `;
const ListDesc = styled.span` const ListDesc = styled.span`
font-size: 16px; font-size: 16px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
`; `;
const FilterSearch = styled(Input)` const FilterSearch = styled(Input)`
margin: 0; margin: 0;
@ -397,104 +401,6 @@ const MemberListWrapper = styled.div`
flex: 1 1; 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` const Container = styled.div`
padding: 2.2rem; padding: 2.2rem;
display: flex; display: flex;
@ -525,6 +431,7 @@ const TabNavItem = styled.li`
display: block; display: block;
position: relative; position: relative;
`; `;
const TabNavItemButton = styled.button<{ active: boolean }>` const TabNavItemButton = styled.button<{ active: boolean }>`
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@ -537,14 +444,18 @@ const TabNavItemButton = styled.button<{ active: boolean }>`
width: 100%; width: 100%;
position: relative; position: relative;
color: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')}; color: ${props => (props.active ? `${props.theme.colors.secondary}` : props.theme.colors.text.primary)};
&:hover { &:hover {
color: rgba(115, 103, 240); color: ${props => `${props.theme.colors.primary}`};
} }
&:hover svg { &:hover svg {
fill: rgba(115, 103, 240); fill: ${props => props.theme.colors.primary};
} }
`; `;
const TabItemUser = styled(User)<{ active: boolean }>`
fill: ${props => (props.active ? `${props.theme.colors.primary}` : props.theme.colors.text.primary)}
stroke: ${props => (props.active ? `${props.theme.colors.primary}` : props.theme.colors.text.primary)}
`;
const TabNavItemSpan = styled.span` const TabNavItemSpan = styled.span`
text-align: left; text-align: left;
@ -560,8 +471,8 @@ const TabNavLine = styled.span<{ top: number }>`
transform: scaleX(1); transform: scaleX(1);
top: ${props => props.top}px; top: ${props => props.top}px;
background: linear-gradient(30deg, rgba(115, 103, 240), rgba(115, 103, 240)); background: linear-gradient(30deg, ${props => props.theme.colors.primary}, ${props => props.theme.colors.primary});
box-shadow: 0 0 8px 0 rgba(115, 103, 240); box-shadow: 0 0 8px 0 ${props => props.theme.colors.primary};
display: block; display: block;
position: absolute; position: absolute;
transition: all 0.2s ease; transition: all 0.2s ease;
@ -607,7 +518,7 @@ const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
}} }}
> >
<TabNavItemButton active={active}> <TabNavItemButton active={active}>
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} /> <TabItemUser width={14} height={14} active={active} />
<TabNavItemSpan>{name}</TabNavItemSpan> <TabNavItemSpan>{name}</TabNavItemSpan>
</TabNavItemButton> </TabNavItemButton>
</TabNavItem> </TabNavItem>
@ -620,8 +531,10 @@ type AdminProps = {
onDeleteUser: (userID: string, newOwnerID: string | null) => void; onDeleteUser: (userID: string, newOwnerID: string | null) => void;
onInviteUser: ($target: React.RefObject<HTMLElement>) => void; onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
users: Array<User>; users: Array<User>;
invitedUsers: Array<InvitedUserAccount>;
canInviteUser: boolean; canInviteUser: boolean;
onUpdateUserPassword: (user: TaskUser, password: string) => void; onUpdateUserPassword: (user: TaskUser, password: string) => void;
onDeleteInvitedUser: (invitedUserID: string) => void;
}; };
const Admin: React.FC<AdminProps> = ({ const Admin: React.FC<AdminProps> = ({
@ -630,14 +543,16 @@ const Admin: React.FC<AdminProps> = ({
onUpdateUserPassword, onUpdateUserPassword,
canInviteUser, canInviteUser,
onDeleteUser, onDeleteUser,
onDeleteInvitedUser,
onInviteUser, onInviteUser,
invitedUsers,
users, users,
}) => { }) => {
const warning = const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.'; '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 [currentTop, setTop] = useState(initialTab * 48);
const [currentTab, setTab] = useState(initialTab); const [currentTab, setTab] = useState(initialTab);
const { showPopup, hidePopup } = usePopup(); const { showPopup } = usePopup();
const $tabNav = useRef<HTMLDivElement>(null); const $tabNav = useRef<HTMLDivElement>(null);
const [updateUserRole] = useUpdateUserRoleMutation(); const [updateUserRole] = useUpdateUserRoleMutation();
@ -647,6 +562,7 @@ const Admin: React.FC<AdminProps> = ({
<TabNavContent> <TabNavContent>
{items.map((item, idx) => ( {items.map((item, idx) => (
<NavItem <NavItem
key={item.name}
onClick={(tab, top) => { onClick={(tab, top) => {
if ($tabNav && $tabNav.current) { if ($tabNav && $tabNav.current) {
const pos = $tabNav.current.getBoundingClientRect(); const pos = $tabNav.current.getBoundingClientRect();
@ -666,7 +582,7 @@ const Admin: React.FC<AdminProps> = ({
<TabContent> <TabContent>
<MemberListWrapper> <MemberListWrapper>
<MemberListHeader> <MemberListHeader>
<ListTitle>{`Members (${users.length})`}</ListTitle> <ListTitle>{`Members (${users.length + invitedUsers.length})`}</ListTitle>
<ListDesc> <ListDesc>
Organization admins can create / manage / delete all projects & teams. Members only have access to teams Organization admins can create / manage / delete all projects & teams. Members only have access to teams
or projects they have been added to. or projects they have been added to.
@ -690,7 +606,7 @@ const Admin: React.FC<AdminProps> = ({
const projectTotal = member.owned.projects.length + member.member.projects.length; const projectTotal = member.owned.projects.length + member.member.projects.length;
return ( return (
<MemberListItem> <MemberListItem>
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} /> <MemberProfile showRoleIcons size={32} onMemberProfile={NOOP} member={member} />
<MemberListItemDetails> <MemberListItemDetails>
<MemberItemName>{member.fullName}</MemberItemName> <MemberItemName>{member.fullName}</MemberItemName>
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername> <MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
@ -724,6 +640,65 @@ const Admin: React.FC<AdminProps> = ({
</MemberListItem> </MemberListItem>
); );
})} })}
{invitedUsers.map(member => {
return (
<MemberListItem>
<MemberProfile
showRoleIcons
size={32}
onMemberProfile={NOOP}
member={{
id: member.id,
fullName: member.email,
profileIcon: {
bgColor: '#fff',
url: null,
initials: member.email.charAt(0),
},
}}
/>
<MemberListItemDetails>
<MemberItemName>{member.email}</MemberItemName>
<MemberItemUsername>Invited</MemberItemUsername>
</MemberListItemDetails>
<MemberItemOptions>
<MemberItemOption
variant="outline"
onClick={$target => {
showPopup(
$target,
<TeamRoleManagerPopup
user={{
id: member.id,
fullName: member.email,
profileIcon: {
bgColor: '#fff',
url: null,
initials: member.email.charAt(0),
},
member: {
teams: [],
projects: [],
},
owned: {
teams: [],
projects: [],
},
}}
users={users}
onDeleteUser={() => {
onDeleteInvitedUser(member.id);
}}
/>,
);
}}
>
Manage
</MemberItemOption>
</MemberItemOptions>
</MemberListItem>
);
})}
</MemberList> </MemberList>
</MemberListWrapper> </MemberListWrapper>
</TabContent> </TabContent>

View File

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

View File

@ -1,14 +1,20 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import styled, { css } from 'styled-components/macro'; import styled, { css } from 'styled-components/macro';
import { mixin } from '../../utils/styles';
const Text = styled.span<{ fontSize: string; justifyTextContent: string }>` const Text = styled.span<{ fontSize: string; justifyTextContent: string; hasIcon?: boolean }>`
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: ${props => props.justifyTextContent}; justify-content: ${props => props.justifyTextContent};
transition: all 0.2s ease; transition: all 0.2s ease;
font-size: ${props => props.fontSize}; font-size: ${props => props.fontSize};
color: rgba(${props => props.theme.colors.text.secondary}); color: ${props => props.theme.colors.text.secondary};
${props =>
props.hasIcon &&
css`
padding-left: 4px;
`}
`; `;
const Base = styled.button<{ color: string; disabled: boolean }>` const Base = styled.button<{ color: string; disabled: boolean }>`
@ -18,6 +24,8 @@ const Base = styled.button<{ color: string; disabled: boolean }>`
cursor: pointer; cursor: pointer;
padding: 0.75rem 2rem; padding: 0.75rem 2rem;
border-radius: ${props => props.theme.borderRadius.alternate}; border-radius: ${props => props.theme.borderRadius.alternate};
display: flex;
align-items: center;
${props => ${props =>
props.disabled && props.disabled &&
@ -28,28 +36,45 @@ const Base = styled.button<{ color: string; disabled: boolean }>`
`} `}
`; `;
const Filled = styled(Base)` const Filled = styled(Base)<{ hoverVariant: HoverVariant }>`
background: rgba(${props => props.theme.colors[props.color]}); background: ${props => props.theme.colors[props.color]};
&:hover { ${props =>
box-shadow: 0 8px 25px -8px rgba(${props => props.theme.colors[props.color]}); props.hoverVariant === 'boxShadow' &&
} css`
&:hover {
box-shadow: 0 8px 25px -8px ${props.theme.colors[props.color]};
}
`}
`; `;
const Outline = styled(Base)`
border: 1px solid rgba(${props => props.theme.colors[props.color]});
background: transparent;
& ${Text} {
color: rgba(${props => props.theme.colors[props.color]});
}
&:hover { const Outline = styled(Base)<{ invert: boolean }>`
background: rgba(${props => props.theme.colors[props.color]}, 0.08); border: 1px solid ${props => props.theme.colors[props.color]};
} background: transparent;
${props =>
props.invert
? css`
background: ${props.theme.colors[props.color]});
& ${Text} {
color: ${props.theme.colors.text.secondary});
}
&:hover {
background: ${mixin.rgba(props.theme.colors[props.color], 0.8)};
}
`
: css`
& ${Text} {
color: ${props.theme.colors[props.color]});
}
&:hover {
background: ${mixin.rgba(props.theme.colors[props.color], 0.08)};
}
`}
`; `;
const Flat = styled(Base)` const Flat = styled(Base)`
background: transparent; background: transparent;
&:hover { &:hover {
background: rgba(${props => props.theme.colors[props.color]}, 0.2); background: ${props => mixin.rgba(props.theme.colors[props.color], 0.2)};
} }
`; `;
@ -62,7 +87,7 @@ const LineX = styled.span<{ color: string }>`
bottom: -2px; bottom: -2px;
left: 50%; left: 50%;
transform: translate(-50%); transform: translate(-50%);
background: rgba(${props => props.theme.colors[props.color]}, 1); background: ${props => mixin.rgba(props.theme.colors[props.color], 1)};
`; `;
const LineDown = styled(Base)` const LineDown = styled(Base)`
@ -71,7 +96,7 @@ const LineDown = styled(Base)`
border-width: 0; border-width: 0;
border-style: solid; border-style: solid;
border-bottom-width: 2px; border-bottom-width: 2px;
border-color: rgba(${props => props.theme.colors[props.color]}, 0.2); border-color: ${props => mixin.rgba(props.theme.colors[props.color], 0.2)};
&:hover ${LineX} { &:hover ${LineX} {
width: 100%; width: 100%;
@ -84,8 +109,8 @@ const LineDown = styled(Base)`
const Gradient = styled(Base)` const Gradient = styled(Base)`
background: linear-gradient( background: linear-gradient(
30deg, 30deg,
rgba(${props => props.theme.colors[props.color]}, 1), ${props => mixin.rgba(props.theme.colors[props.color], 1)},
rgba(${props => props.theme.colors[props.color]}, 0.5) ${props => mixin.rgba(props.theme.colors[props.color], 0.5)}
); );
text-shadow: 1px 2px 4px rgba(0, 0, 0, 0.3); text-shadow: 1px 2px 4px rgba(0, 0, 0, 0.3);
&:hover { &:hover {
@ -94,7 +119,7 @@ const Gradient = styled(Base)`
`; `;
const Relief = styled(Base)` const Relief = styled(Base)`
background: rgba(${props => props.theme.colors[props.color]}, 1); background: ${props => mixin.rgba(props.theme.colors[props.color], 1)};
-webkit-box-shadow: 0 -3px 0 0 rgba(0, 0, 0, 0.2) inset; -webkit-box-shadow: 0 -3px 0 0 rgba(0, 0, 0, 0.2) inset;
box-shadow: inset 0 -3px 0 0 rgba(0, 0, 0, 0.2); box-shadow: inset 0 -3px 0 0 rgba(0, 0, 0, 0.2);
@ -104,12 +129,16 @@ const Relief = styled(Base)`
} }
`; `;
type HoverVariant = 'boxShadow' | 'none';
type ButtonProps = { type ButtonProps = {
fontSize?: string; fontSize?: string;
variant?: 'filled' | 'outline' | 'flat' | 'lineDown' | 'gradient' | 'relief'; variant?: 'filled' | 'outline' | 'flat' | 'lineDown' | 'gradient' | 'relief';
hoverVariant?: HoverVariant;
color?: 'primary' | 'danger' | 'success' | 'warning' | 'dark'; color?: 'primary' | 'danger' | 'success' | 'warning' | 'dark';
disabled?: boolean; disabled?: boolean;
type?: 'button' | 'submit'; type?: 'button' | 'submit';
icon?: JSX.Element;
invert?: boolean;
className?: string; className?: string;
onClick?: ($target: React.RefObject<HTMLButtonElement>) => void; onClick?: ($target: React.RefObject<HTMLButtonElement>) => void;
justifyTextContent?: string; justifyTextContent?: string;
@ -118,10 +147,13 @@ type ButtonProps = {
const Button: React.FC<ButtonProps> = ({ const Button: React.FC<ButtonProps> = ({
disabled = false, disabled = false,
fontSize = '14px', fontSize = '14px',
invert = false,
color = 'primary', color = 'primary',
variant = 'filled', variant = 'filled',
hoverVariant = 'boxShadow',
type = 'button', type = 'button',
justifyTextContent = 'center', justifyTextContent = 'center',
icon,
onClick, onClick,
className, className,
children, children,
@ -135,8 +167,17 @@ const Button: React.FC<ButtonProps> = ({
switch (variant) { switch (variant) {
case 'filled': case 'filled':
return ( return (
<Filled ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}> <Filled
<Text justifyTextContent={justifyTextContent} fontSize={fontSize}> ref={$button}
hoverVariant={hoverVariant}
type={type}
onClick={handleClick}
className={className}
disabled={disabled}
color={color}
>
{icon && icon}
<Text hasIcon={typeof icon !== 'undefined'} justifyTextContent={justifyTextContent} fontSize={fontSize}>
{children} {children}
</Text> </Text>
</Filled> </Filled>
@ -145,6 +186,7 @@ const Button: React.FC<ButtonProps> = ({
return ( return (
<Outline <Outline
ref={$button} ref={$button}
invert={invert}
type={type} type={type}
onClick={handleClick} onClick={handleClick}
className={className} className={className}

View File

@ -1,14 +1,12 @@
import styled, { css, keyframes } from 'styled-components'; import styled, { css, keyframes } from 'styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
import TextareaAutosize from 'react-autosize-textarea'; import TextareaAutosize from 'react-autosize-textarea';
import { CheckCircle, CheckSquareOutline } from 'shared/icons'; import { CheckCircle, CheckSquareOutline, Clock } from 'shared/icons';
import { RefObject } from 'react';
import TaskAssignee from 'shared/components/TaskAssignee'; import TaskAssignee from 'shared/components/TaskAssignee';
export const CardMember = styled(TaskAssignee)<{ zIndex: number }>` export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
box-shadow: 0 0 0 2px rgba(${props => props.theme.colors.bg.secondary}), box-shadow: 0 0 0 2px ${props => props.theme.colors.bg.secondary},
inset 0 0 0 1px rgba(${props => props.theme.colors.bg.secondary}, 0.07); inset 0 0 0 1px ${props => mixin.rgba(props.theme.colors.bg.secondary, 0.07)};
z-index: ${props => props.zIndex}; z-index: ${props => props.zIndex};
position: relative; position: relative;
`; `;
@ -16,11 +14,13 @@ export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'no
${props => ${props =>
props.color === 'success' && props.color === 'success' &&
css` css`
fill: rgba(${props.theme.colors.success}); fill: ${props.theme.colors.success};
stroke: rgba(${props.theme.colors.success}); stroke: ${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)` export const EditorTextarea = styled(TextareaAutosize)`
overflow: hidden; overflow: hidden;
@ -38,7 +38,7 @@ export const EditorTextarea = styled(TextareaAutosize)`
padding: 0; padding: 0;
font-size: 14px; font-size: 14px;
line-height: 18px; line-height: 18px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
&:focus { &:focus {
border: none; border: none;
outline: none; outline: none;
@ -89,7 +89,7 @@ export const ListCardBadgeText = styled.span<{ color?: 'success' | 'normal' }>`
padding: 0 4px 0 6px; padding: 0 4px 0 6px;
vertical-align: top; vertical-align: top;
white-space: nowrap; white-space: nowrap;
${props => props.color === 'success' && `color: rgba(${props.theme.colors.success});`} ${props => props.color === 'success' && `color: ${props.theme.colors.success};`}
`; `;
export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>` export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>`
@ -101,7 +101,9 @@ export const ListCardContainer = styled.div<{ isActive: boolean; editable: boole
position: relative; position: relative;
background-color: ${props => background-color: ${props =>
props.isActive && !props.editable ? mixin.darken('#262c49', 0.1) : `rgba(${props.theme.colors.bg.secondary})`}; props.isActive && !props.editable
? mixin.darken(props.theme.colors.bg.secondary, 0.1)
: `${props.theme.colors.bg.secondary}`};
`; `;
export const ListCardInnerContainer = styled.div` export const ListCardInnerContainer = styled.div`
@ -147,6 +149,11 @@ export const ListCardLabelText = styled.span`
line-height: 16px; line-height: 16px;
`; `;
export const ListCardLabelsWrapper = styled.div`
overflow: auto;
position: relative;
`;
export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>` export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
${props => ${props =>
props.variant === 'small' props.variant === 'small'
@ -178,8 +185,6 @@ export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
`; `;
export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>` export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>`
overflow: auto;
position: relative;
&:hover { &:hover {
opacity: 0.8; opacity: 0.8;
} }
@ -218,7 +223,7 @@ export const ListCardOperation = styled.span`
top: 2px; top: 2px;
z-index: 100; z-index: 100;
&:hover { &:hover {
background-color: ${props => mixin.darken('#262c49', 0.25)}; background-color: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.25)};
} }
`; `;
@ -230,7 +235,7 @@ export const CardTitle = styled.span`
word-wrap: break-word; word-wrap: break-word;
line-height: 18px; line-height: 18px;
font-size: 14px; font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
display: flex; display: flex;
align-items: center; align-items: center;
@ -243,7 +248,7 @@ export const CardMembers = styled.div`
`; `;
export const CompleteIcon = styled(CheckCircle)` export const CompleteIcon = styled(CheckCircle)`
fill: rgba(${props => props.theme.colors.success}); fill: ${props => props.theme.colors.success};
margin-right: 4px; margin-right: 4px;
flex-shrink: 0; flex-shrink: 0;
`; `;

View File

@ -1,7 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Pencil, Eye, List } from 'shared/icons';
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons';
import { import {
EditorTextarea, EditorTextarea,
CardMember, CardMember,
@ -20,11 +18,11 @@ import {
ListCardLabels, ListCardLabels,
ListCardLabel, ListCardLabel,
ListCardLabelText, ListCardLabelText,
ListCardLabelsWrapper,
ListCardOperation, ListCardOperation,
CardTitle, CardTitle,
CardMembers, CardMembers,
} from './Styles'; } from './Styles';
import { CheckSquare } from 'shared/icons';
type DueDate = { type DueDate = {
isPastDue: boolean; 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> </ListCardOperation>
)} )}
<ListCardDetails complete={complete ?? false}> <ListCardDetails complete={complete ?? false}>
<ListCardLabels {labels && labels.length !== 0 && (
toggleLabels={toggleLabels} <ListCardLabelsWrapper>
toggleDirection={toggleDirection} <ListCardLabels
onClick={e => { toggleLabels={toggleLabels}
e.stopPropagation(); toggleDirection={toggleDirection}
if (onCardLabelClick) { onClick={e => {
onCardLabelClick(); e.stopPropagation();
} if (onCardLabelClick) {
}} onCardLabelClick();
> }
{labels && }}
labels >
.slice() {labels
.sort((a, b) => a.labelColor.position - b.labelColor.position) .slice()
.map(label => ( .sort((a, b) => a.labelColor.position - b.labelColor.position)
<ListCardLabel .map(label => (
onAnimationEnd={() => { <ListCardLabel
if (setToggleLabels) { onAnimationEnd={() => {
setToggleLabels(false); if (setToggleLabels) {
} setToggleLabels(false);
}} }
variant={labelVariant ?? 'large'} }}
color={label.labelColor.colorHex} variant={labelVariant ?? 'large'}
key={label.id} color={label.labelColor.colorHex}
> key={label.id}
<ListCardLabelText>{label.name}</ListCardLabelText> >
</ListCardLabel> <ListCardLabelText>{label.name}</ListCardLabelText>
))} </ListCardLabel>
</ListCardLabels> ))}
</ListCardLabels>
</ListCardLabelsWrapper>
)}
{editable ? ( {editable ? (
<EditorContent> <EditorContent>
{complete && <CompleteIcon width={16} height={16} />} {complete && <CompleteIcon width={16} height={16} />}
@ -209,24 +210,24 @@ const Card = React.forwardRef(
) : ( ) : (
<CardTitle> <CardTitle>
{complete && <CompleteIcon width={16} height={16} />} {complete && <CompleteIcon width={16} height={16} />}
{`${title}${position ? ' - ' + position : ''}`} {`${title}${position ? ` - ${position}` : ''}`}
</CardTitle> </CardTitle>
)} )}
<ListCardBadges> <ListCardBadges>
{watched && ( {watched && (
<ListCardBadge> <ListCardBadge>
<FontAwesomeIcon color="#6b778c" icon={faEye} size="xs" /> <Eye width={8} height={8} />
</ListCardBadge> </ListCardBadge>
)} )}
{dueDate && ( {dueDate && (
<DueDateCardBadge isPastDue={dueDate.isPastDue}> <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> <ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
</DueDateCardBadge> </DueDateCardBadge>
)} )}
{description && ( {description && (
<DescriptionBadge> <DescriptionBadge>
<FontAwesomeIcon color="#6b778c" icon={faList} size="xs" /> <List width={8} height={8} />
</DescriptionBadge> </DescriptionBadge>
)} )}
{checklists && ( {checklists && (
@ -236,9 +237,9 @@ const Card = React.forwardRef(
width={8} width={8}
height={8} height={8}
/> />
<ListCardBadgeText <ListCardBadgeText color={checklists.complete === checklists.total ? 'success' : 'normal'}>
color={checklists.complete === checklists.total ? 'success' : 'normal'} {`${checklists.complete}/${checklists.total}`}
>{`${checklists.complete}/${checklists.total}`}</ListCardBadgeText> </ListCardBadgeText>
</ListCardBadge> </ListCardBadge>
)} )}
</ListCardBadges> </ListCardBadges>

View File

@ -1,15 +1,15 @@
import styled from 'styled-components'; import styled from 'styled-components';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import TextareaAutosize from 'react-autosize-textarea'; import TextareaAutosize from 'react-autosize-textarea';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
export const CancelIcon = styled(FontAwesomeIcon)` export const CancelIconWrapper = styled.div`
opacity: 0.8; opacity: 0.8;
cursor: pointer; cursor: pointer;
font-size: 1.25em; font-size: 1.25em;
padding-left: 5px; padding-left: 5px;
`; `;
export const CardComposerWrapper = styled.div<{ isOpen: boolean }>` export const CardComposerWrapper = styled.div<{ isOpen: boolean }>`
padding-bottom: 8px; padding-bottom: 8px;
display: ${props => (props.isOpen ? 'flex' : 'none')}; display: ${props => (props.isOpen ? 'flex' : 'none')};

View File

@ -1,12 +1,12 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown'; import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import useOnOutsideClick from 'shared/hooks/onOutsideClick'; import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { Cross } from 'shared/icons';
import { import {
CardComposerWrapper, CardComposerWrapper,
CancelIcon, CancelIconWrapper,
AddCardButton, AddCardButton,
ComposerControls, ComposerControls,
ComposerControlsSaveSection, ComposerControlsSaveSection,
@ -26,10 +26,9 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
useOnOutsideClick($cardRef, true, onClose, null); useOnOutsideClick($cardRef, true, onClose, null);
useOnEscapeKeyDown(isOpen, onClose); useOnEscapeKeyDown(isOpen, onClose);
return ( return (
<CardComposerWrapper isOpen={isOpen}> <CardComposerWrapper isOpen={isOpen} ref={$cardRef}>
<Card <Card
title={cardName} title={cardName}
ref={$cardRef}
taskID="" taskID=""
taskGroupID="" taskGroupID=""
editable editable
@ -52,7 +51,9 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
> >
Add Card Add Card
</AddCardButton> </AddCardButton>
<CancelIcon onClick={onClose} icon={faTimes} color="#42526e" /> <CancelIconWrapper onClick={() => onClose()}>
<Cross width={12} height={12} />
</CancelIconWrapper>
</ComposerControlsSaveSection> </ComposerControlsSaveSection>
<ComposerControlsActionsSection /> <ComposerControlsActionsSection />
</ComposerControls> </ComposerControls>

View File

@ -2,9 +2,10 @@ import React, { useState } from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import BaseStyles from 'App/BaseStyles'; import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles'; import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles'; import theme from 'App/ThemeStyles';
import produce from 'immer'; import produce from 'immer';
import styled, { ThemeProvider } from 'styled-components'; import styled, { ThemeProvider } from 'styled-components';
import NOOP from 'shared/utils/noop';
import Checklist, { ChecklistItem } from '.'; import Checklist, { ChecklistItem } from '.';
export default { export default {
@ -21,7 +22,7 @@ export default {
const Container = styled.div` const Container = styled.div`
width: 552px; width: 552px;
margin: 25px; margin: 25px;
border: 1px solid rgba(${props => props.theme.colors.bg.primary}); border: 1px solid ${props => props.theme.colors.bg.primary};
`; `;
const defaultItems = [ const defaultItems = [
@ -132,7 +133,7 @@ export const Default = () => {
}} }}
onToggleItem={onToggleItem} onToggleItem={onToggleItem}
> >
{items.map((item, idx) => ( {items.map(item => (
<ChecklistItem <ChecklistItem
key={item.id} key={item.id}
wrapperProps={{}} wrapperProps={{}}
@ -141,9 +142,9 @@ export const Default = () => {
itemID={item.id} itemID={item.id}
name={item.name} name={item.name}
complete={item.complete} complete={item.complete}
onDeleteItem={() => {}} onDeleteItem={NOOP}
onChangeName={() => {}} onChangeName={NOOP}
onToggleItem={() => {}} onToggleItem={NOOP}
/> />
))} ))}
</Checklist> </Checklist>

View File

@ -12,6 +12,7 @@ import Button from 'shared/components/Button';
import TextareaAutosize from 'react-autosize-textarea'; import TextareaAutosize from 'react-autosize-textarea';
import Control from 'react-select/src/components/Control'; import Control from 'react-select/src/components/Control';
import useOnOutsideClick from 'shared/hooks/onOutsideClick'; import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { mixin } from 'shared/utils/styles';
const Wrapper = styled.div` const Wrapper = styled.div`
margin-bottom: 24px; margin-bottom: 24px;
@ -25,7 +26,7 @@ const WindowTitle = styled.div`
const WindowTitleIcon = styled(CheckSquareOutline)` const WindowTitleIcon = styled(CheckSquareOutline)`
top: 10px; top: 10px;
left: -40px; left: -32px;
position: absolute; position: absolute;
`; `;
@ -38,7 +39,7 @@ const WindowChecklistTitle = styled.div`
const WindowTitleText = styled.h3` const WindowTitleText = styled.h3`
cursor: pointer; cursor: pointer;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
margin: 6px 0; margin: 6px 0;
display: inline-block; display: inline-block;
width: auto; width: auto;
@ -73,7 +74,7 @@ const ChecklistProgressPercent = styled.span`
`; `;
const ChecklistProgressBar = styled.div` const ChecklistProgressBar = styled.div`
background: rgba(${props => props.theme.colors.bg.primary}); background: ${props => props.theme.colors.bg.primary};
border-radius: 4px; border-radius: 4px;
clear: both; clear: both;
height: 8px; height: 8px;
@ -83,7 +84,7 @@ const ChecklistProgressBar = styled.div`
`; `;
const ChecklistProgressBarCurrent = styled.div<{ width: number }>` const ChecklistProgressBarCurrent = styled.div<{ width: number }>`
width: ${props => props.width}%; width: ${props => props.width}%;
background: rgba(${props => (props.width === 100 ? props.theme.colors.success : props.theme.colors.primary)}); background: ${props => (props.width === 100 ? props.theme.colors.success : props.theme.colors.primary)};
bottom: 0; bottom: 0;
left: 0; left: 0;
position: absolute; position: absolute;
@ -111,7 +112,7 @@ const ChecklistIcon = styled.div`
`; `;
const ChecklistItemCheckedIcon = styled(CheckSquare)` const ChecklistItemCheckedIcon = styled(CheckSquare)`
fill: rgba(${props => props.theme.colors.primary}); fill: ${props => props.theme.colors.primary};
`; `;
const ChecklistItemDetails = styled.div` const ChecklistItemDetails = styled.div`
@ -133,7 +134,7 @@ const ChecklistItemTextControls = styled.div`
`; `;
const ChecklistItemText = styled.span<{ complete: boolean }>` const ChecklistItemText = styled.span<{ complete: boolean }>`
color: ${props => (props.complete ? '#5e6c84' : `rgba(${props.theme.colors.text.primary})`)}; color: ${props => (props.complete ? '#5e6c84' : `${props.theme.colors.text.primary}`)};
${props => props.complete && 'text-decoration: line-through;'} ${props => props.complete && 'text-decoration: line-through;'}
line-height: 20px; line-height: 20px;
font-size: 16px; font-size: 16px;
@ -155,14 +156,14 @@ const ControlButton = styled.div`
margin-left: 4px; margin-left: 4px;
padding: 4px 6px; padding: 4px 6px;
border-radius: 6px; border-radius: 6px;
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.8); background-color: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.8)};
display: flex; display: flex;
width: 32px; width: 32px;
height: 32px; height: 32px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&:hover { &:hover {
background-color: rgba(${props => props.theme.colors.primary}, 1); background-color: ${props => mixin.rgba(props.theme.colors.primary, 1)};
} }
`; `;
@ -189,27 +190,27 @@ export const ChecklistNameEditor = styled(TextareaAutosize)`
padding: 8px 12px; padding: 8px 12px;
font-size: 16px; font-size: 16px;
line-height: 20px; line-height: 20px;
border: 1px solid rgba(${props => props.theme.colors.primary}); border: 1px solid ${props => props.theme.colors.primary};
border-radius: 3px; border-radius: 3px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
border-color: rgba(${props => props.theme.colors.border}); border-color: ${props => props.theme.colors.border};
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4); background-color: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
&:focus { &:focus {
border-color: rgba(${props => props.theme.colors.primary}); border-color: ${props => props.theme.colors.primary};
} }
`; `;
const AssignUserButton = styled(AccountPlus)` const AssignUserButton = styled(AccountPlus)`
fill: rgba(${props => props.theme.colors.text.primary}); fill: ${props => props.theme.colors.text.primary};
`; `;
const ClockButton = styled(Clock)` const ClockButton = styled(Clock)`
fill: rgba(${props => props.theme.colors.text.primary}); fill: ${props => props.theme.colors.text.primary};
`; `;
const TrashButton = styled(Trash)` const TrashButton = styled(Trash)`
fill: rgba(${props => props.theme.colors.text.primary}); fill: ${props => props.theme.colors.text.primary};
`; `;
const ChecklistItemWrapper = styled.div<{ ref: any }>` const ChecklistItemWrapper = styled.div<{ ref: any }>`
@ -224,7 +225,7 @@ const ChecklistItemWrapper = styled.div<{ ref: any }>`
} }
&:hover { &:hover {
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4); background-color: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
} }
&:hover ${ControlButton} { &:hover ${ControlButton} {
opacity: 1; opacity: 1;
@ -246,10 +247,10 @@ const CancelButton = styled.div`
cursor: pointer; cursor: pointer;
margin: 5px; margin: 5px;
& svg { & svg {
fill: rgba(${props => props.theme.colors.text.primary}); fill: ${props => props.theme.colors.text.primary};
} }
&:hover svg { &:hover svg {
fill: rgba(${props => props.theme.colors.text.secondary}); fill: ${props => props.theme.colors.text.secondary};
} }
`; `;
@ -265,7 +266,7 @@ const EditableDeleteButton = styled.button`
border-radius: 3px; border-radius: 3px;
&:hover { &:hover {
background: rgba(${props => props.theme.colors.primary}, 0.8); background: ${props => mixin.rgba(props.theme.colors.primary, 0.8)};
} }
`; `;

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: ${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: ${props.theme.colors.text.secondary};
}
`
: css`
background: ${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

@ -0,0 +1,103 @@
import styled from 'styled-components';
import Button from 'shared/components/Button';
export const Wrapper = styled.div`
background: #eff2f7;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
`;
export const Column = styled.div`
width: 50%;
display: flex;
justify-content: center;
align-items: center;
`;
export const LoginFormWrapper = styled.div`
background: #10163a;
width: 100%;
`;
export const LoginFormContainer = styled.div`
min-height: 505px;
padding: 2rem;
`;
export const Title = styled.h1`
color: #ebeefd;
font-size: 18px;
margin-bottom: 14px;
`;
export const SubTitle = styled.h2`
color: #c2c6dc;
font-size: 14px;
margin-bottom: 14px;
`;
export const Form = styled.form`
display: flex;
flex-direction: column;
`;
export const FormLabel = styled.label`
color: #c2c6dc;
font-size: 12px;
position: relative;
margin-top: 14px;
`;
export const FormTextInput = styled.input`
width: 100%;
background: #262c49;
border: 1px solid rgba(0, 0, 0, 0.2);
margin-top: 4px;
padding: 0.7rem 1rem 0.7rem 3rem;
font-size: 1rem;
color: #c2c6dc;
border-radius: 5px;
`;
export const FormIcon = styled.div`
top: 30px;
left: 16px;
position: absolute;
`;
export const FormError = styled.span`
font-size: 0.875rem;
color: rgb(234, 84, 85);
`;
export const LoginButton = styled(Button)``;
export const ActionButtons = styled.div`
margin-top: 17.5px;
display: flex;
justify-content: space-between;
`;
export const RegisterButton = styled(Button)``;
export const LogoTitle = styled.div`
font-size: 24px;
font-weight: 600;
margin-left: 12px;
transition: visibility, opacity, transform 0.25s ease;
color: #7367f0;
`;
export const LogoWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
position: relative;
width: 100%;
padding-bottom: 16px;
margin-bottom: 24px;
color: rgb(222, 235, 255);
border-bottom: 1px solid rgba(65, 69, 97, 0.65);
`;

View File

@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import AccessAccount from 'shared/undraw/AccessAccount';
import { User, Lock, Taskcafe } from 'shared/icons';
import { useForm } from 'react-hook-form';
import LoadingSpinner from 'shared/components/LoadingSpinner';
import {
Form,
LogoWrapper,
LogoTitle,
ActionButtons,
RegisterButton,
FormError,
FormIcon,
FormLabel,
FormTextInput,
Wrapper,
Column,
LoginFormWrapper,
LoginFormContainer,
Title,
SubTitle,
} from './Styles';
const Confirm = ({ onConfirmUser, hasConfirmToken }: ConfirmProps) => {
const [hasFailed, setFailed] = useState(false);
const setHasFailed = () => {
setFailed(true);
};
useEffect(() => {
onConfirmUser(setHasFailed);
});
return (
<Wrapper>
<Column>
<AccessAccount width={275} height={250} />
</Column>
<Column>
<LoginFormWrapper>
<LoginFormContainer>
<LogoWrapper>
<Taskcafe width={42} height={42} />
<LogoTitle>Taskcafé</LogoTitle>
</LogoWrapper>
{hasConfirmToken ? (
<>
<Title>Confirming user...</Title>
{hasFailed ? <SubTitle>There was an error while confirming your user</SubTitle> : <LoadingSpinner />}
</>
) : (
<>
<Title>There is no confirmation token</Title>
<SubTitle>There seems to have been an error.</SubTitle>
</>
)}
</LoginFormContainer>
</LoginFormWrapper>
</Column>
</Wrapper>
);
};
export default Confirm;

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import BaseStyles from 'App/BaseStyles'; import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles'; import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles'; import theme from 'App/ThemeStyles';
import styled, { ThemeProvider } from 'styled-components'; import styled, { ThemeProvider } from 'styled-components';
import { User } from 'shared/icons';
import Input from '.'; import Input from '.';
import { User } from 'shared/icons';
export default { export default {
component: Input, component: Input,
@ -19,7 +19,7 @@ export default {
}; };
const Wrapper = styled.div` const Wrapper = styled.div`
background: rgba(${props => props.theme.colors.bg.primary}); background: ${props => props.theme.colors.bg.primary};
padding: 45px; padding: 45px;
margin: 25px; margin: 25px;
display: flex; display: flex;
@ -35,7 +35,7 @@ export const Default = () => {
<Wrapper> <Wrapper>
<Input label="Label placeholder" /> <Input label="Label placeholder" />
<Input width="100%" placeholder="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> </Wrapper>
</ThemeProvider> </ThemeProvider>
</> </>

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import styled, { css } from 'styled-components/macro'; import styled, { css } from 'styled-components/macro';
import theme from '../../../App/ThemeStyles';
const InputWrapper = styled.div<{ width: string }>` const InputWrapper = styled.div<{ width: string }>`
position: relative; position: relative;
@ -57,14 +58,14 @@ const InputInput = styled.input<{
background: ${props => props.focusBg}; background: ${props => props.focusBg};
} }
&:focus ~ ${InputLabel} { &:focus ~ ${InputLabel} {
color: rgba(115, 103, 240); color: ${props => props.theme.colors.primary};
transform: translate(-3px, -90%); transform: translate(-3px, -90%);
} }
${props => ${props =>
props.hasValue && props.hasValue &&
css` css`
& ~ ${InputLabel} { & ~ ${InputLabel} {
color: rgba(115, 103, 240); color: ${props.theme.colors.primary};
transform: translate(-3px, -90%); transform: translate(-3px, -90%);
} }
`} `}
@ -115,8 +116,8 @@ const ControlledInput = ({
}: ControlledInputProps) => { }: ControlledInputProps) => {
const $input = useRef<HTMLInputElement>(null); const $input = useRef<HTMLInputElement>(null);
const [hasValue, setHasValue] = useState(false); const [hasValue, setHasValue] = useState(false);
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561'; const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : theme.colors.alternate;
const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)'; const focusBg = variant === 'normal' ? theme.colors.bg.secondary : theme.colors.bg.primary;
useEffect(() => { useEffect(() => {
if (autoFocus && $input && $input.current) { if (autoFocus && $input && $input.current) {
$input.current.focus(); $input.current.focus();

View File

@ -2,6 +2,7 @@ import React, { createRef, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import DropdownMenu from '.'; import DropdownMenu from '.';
import theme from '../../../App/ThemeStyles';
export default { export default {
component: DropdownMenu, component: DropdownMenu,
@ -10,7 +11,7 @@ export default {
backgrounds: [ backgrounds: [
{ name: 'white', value: '#ffffff' }, { name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#f8f8f8' }, { name: 'gray', value: '#f8f8f8' },
{ name: 'darkBlue', value: '#262c49', default: true }, { name: 'darkBlue', value: theme.colors.bg.secondary, default: true },
], ],
}, },
}; };

View File

@ -59,7 +59,7 @@ export const ActionItem = styled.li`
align-items: center; align-items: center;
font-size: 14px; font-size: 14px;
&:hover { &:hover {
background: rgb(115, 103, 240); background: ${props => props.theme.colors.primary};
} }
`; `;

View File

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

View File

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

View File

@ -19,23 +19,23 @@ display: flex
} }
& .react-datepicker-time__header { & .react-datepicker-time__header {
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
} }
& .react-datepicker__time-list-item { & .react-datepicker__time-list-item {
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
} }
& .react-datepicker__time-container .react-datepicker__time & .react-datepicker__time-container .react-datepicker__time
.react-datepicker__time-box ul.react-datepicker__time-list .react-datepicker__time-box ul.react-datepicker__time-list
li.react-datepicker__time-list-item:hover { li.react-datepicker__time-list-item:hover {
color: rgba(${props => props.theme.colors.text.secondary}); color: ${props => props.theme.colors.text.secondary};
background: rgba(${props => props.theme.colors.bg.secondary}); background: ${props => props.theme.colors.bg.secondary};
} }
& .react-datepicker__time-container .react-datepicker__time { & .react-datepicker__time-container .react-datepicker__time {
background: rgba(${props => props.theme.colors.bg.primary}); background: ${props => props.theme.colors.bg.primary};
} }
& .react-datepicker--time-only { & .react-datepicker--time-only {
background: rgba(${props => props.theme.colors.bg.primary}); background: ${props => props.theme.colors.bg.primary};
border: 1px solid rgba(${props => props.theme.colors.border}); border: 1px solid ${props => props.theme.colors.border};
} }
& .react-datepicker * { & .react-datepicker * {
@ -75,12 +75,12 @@ display: flex
} }
& .react-datepicker__day--selected { & .react-datepicker__day--selected {
border-radius: 50%; border-radius: 50%;
background: rgba(115, 103, 240); background: ${props => props.theme.colors.primary};
color: #fff; color: #fff;
} }
& .react-datepicker__day--selected:hover { & .react-datepicker__day--selected:hover {
border-radius: 50%; border-radius: 50%;
background: rgba(115, 103, 240); background: ${props => props.theme.colors.primary};
color: #fff; color: #fff;
} }
& .react-datepicker__header { & .react-datepicker__header {
@ -88,7 +88,7 @@ display: flex
border: none; border: none;
} }
& .react-datepicker__header--time { & .react-datepicker__header--time {
border-bottom: 1px solid rgba(${props => props.theme.colors.border}); border-bottom: 1px solid ${props => props.theme.colors.border};
} }
`; `;

View File

@ -1,23 +1,14 @@
import React, { useState, useEffect, forwardRef } from 'react'; import React, { useState, useEffect, forwardRef } from 'react';
import moment from 'moment'; import dayjs from 'dayjs';
import styled from 'styled-components'; import styled from 'styled-components';
import DatePicker from 'react-datepicker'; import DatePicker from 'react-datepicker';
import { Cross } from 'shared/icons';
import _ from 'lodash'; import _ from 'lodash';
import {
Wrapper,
ActionWrapper,
RemoveDueDate,
DueDateInput,
DueDatePickerWrapper,
ConfirmAddDueDate,
CancelDueDate,
} from './Styles';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { getYear, getMonth } from 'date-fns'; import { getYear, getMonth } from 'date-fns';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import NOOP from 'shared/utils/noop';
import { Wrapper, ActionWrapper, RemoveDueDate, DueDateInput, DueDatePickerWrapper, ConfirmAddDueDate } from './Styles';
type DueDateManagerProps = { type DueDateManagerProps = {
task: Task; task: Task;
@ -52,7 +43,7 @@ const HeaderSelectLabel = styled.div`
color: #c2c6dc; color: #c2c6dc;
&:hover { &:hover {
background: rgba(115, 103, 240); background: ${props => props.theme.colors.primary};
color: #c2c6dc; color: #c2c6dc;
} }
`; `;
@ -69,8 +60,8 @@ const HeaderSelect = styled.select`
appearance: none; appearance: none;
&:hover { &:hover {
background: #262c49; background: ${props => props.theme.colors.bg.secondary};
border: 1px solid rgba(115, 103, 240); border: 1px solid ${props => props.theme.colors.primary};
outline: none !important; outline: none !important;
box-shadow: none; box-shadow: none;
color: #c2c6dc; color: #c2c6dc;
@ -102,7 +93,7 @@ const HeaderButton = styled.button`
border: none; border: none;
border-radius: 3px; border-radius: 3px;
&:hover { &:hover {
background: rgba(115, 103, 240); background: ${props => props.theme.colors.primary};
color: #fff; color: #fff;
} }
`; `;
@ -119,18 +110,14 @@ const HeaderActions = styled.div`
`; `;
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => { const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
const now = moment(); const now = dayjs();
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()); 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(() => { useEffect(() => {
setTextEndTime(moment(endTime).format('h:mm A')); const newDate = dayjs(startDate).format('YYYY-MM-DD');
}, [endTime]); setValue('endDate', newDate);
}, [startDate]);
const years = _.range(2010, getYear(new Date()) + 10, 1); const years = _.range(2010, getYear(new Date()) + 10, 1);
const months = [ const months = [
@ -147,9 +134,8 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
'November', 'November',
'December', 'December',
]; ];
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
const saveDueDate = (data: any) => { const saveDueDate = (data: any) => {
const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A'); const newDate = dayjs(`${data.endDate} ${dayjs(data.endTime).format('h:mm A')}`, 'YYYY-MM-DD h:mm A');
if (newDate.isValid()) { if (newDate.isValid()) {
onDueDateChange(task, newDate.toDate()); onDueDateChange(task, newDate.toDate());
} }
@ -158,11 +144,12 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
return ( return (
<DueDateInput <DueDateInput
id="endTime" id="endTime"
value={value}
name="endTime" name="endTime"
ref={$ref} ref={$ref}
width="100%" width="100%"
variant="alternate" variant="alternate"
label="Date" label="Time"
onClick={onClick} onClick={onClick}
/> />
); );
@ -177,7 +164,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
width="100%" width="100%"
variant="alternate" variant="alternate"
label="Date" label="Date"
defaultValue={textStartDate} defaultValue={now.format('YYYY-MM-DD')}
ref={register({ ref={register({
required: 'End date is required.', required: 'End date is required.',
})} })}
@ -186,6 +173,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
<FormField> <FormField>
<Controller <Controller
control={control} control={control}
defaultValue={now.toDate()}
name="endTime" name="endTime"
render={({ onChange, onBlur, value }) => ( render={({ onChange, onBlur, value }) => (
<DatePicker <DatePicker
@ -220,7 +208,10 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
</HeaderButton> </HeaderButton>
<HeaderSelectLabel> <HeaderSelectLabel>
{months[date.getMonth()]} {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 => ( {years.map(option => (
<option key={option} value={option}> <option key={option} value={option}>
{option} {option}
@ -250,12 +241,14 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
selected={startDate} selected={startDate}
inline inline
onChange={date => { onChange={date => {
setStartDate(date ?? new Date()); if (date) {
setStartDate(date);
}
}} }}
/> />
</DueDatePickerWrapper> </DueDatePickerWrapper>
<ActionWrapper> <ActionWrapper>
<ConfirmAddDueDate type="submit" onClick={() => {}}> <ConfirmAddDueDate type="submit" onClick={NOOP}>
Save Save
</ConfirmAddDueDate> </ConfirmAddDueDate>
<RemoveDueDate <RemoveDueDate

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import styled, { keyframes } from 'styled-components/macro'; import styled, { keyframes } from 'styled-components/macro';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
import theme from '../../../App/ThemeStyles';
export const BoardContainer = styled.div` export const BoardContainer = styled.div`
position: relative; position: relative;
@ -34,9 +35,9 @@ export const Container = styled.div`
white-space: nowrap; white-space: nowrap;
`; `;
export const defaultBaseColor = '#10163a'; export const defaultBaseColor = theme.colors.bg.primary;
export const defaultHighlightColor = mixin.lighten('#10163a', 0.25); export const defaultHighlightColor = mixin.lighten(theme.colors.bg.primary, 0.25);
export const skeletonKeyframes = keyframes` export const skeletonKeyframes = keyframes`
0% { 0% {

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import BaseStyles from 'App/BaseStyles'; import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles'; import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles'; import theme from 'App/ThemeStyles';
import styled, { ThemeProvider } from 'styled-components'; import styled, { ThemeProvider } from 'styled-components';
import { User } from 'shared/icons';
import Input from '.'; import Input from '.';
import { User } from 'shared/icons';
export default { export default {
component: Input, component: Input,
@ -19,7 +19,7 @@ export default {
}; };
const Wrapper = styled.div` const Wrapper = styled.div`
background: rgba(${props => props.theme.colors.bg.primary}); background: ${props => props.theme.colors.bg.primary};
padding: 45px; padding: 45px;
margin: 25px; margin: 25px;
display: flex; display: flex;
@ -35,7 +35,7 @@ export const Default = () => {
<Wrapper> <Wrapper>
<Input label="Label placeholder" /> <Input label="Label placeholder" />
<Input width="100%" placeholder="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> </Wrapper>
</ThemeProvider> </ThemeProvider>
</> </>

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import styled, { css } from 'styled-components/macro'; import styled, { css } from 'styled-components/macro';
import theme from '../../../App/ThemeStyles';
const InputWrapper = styled.div<{ width: string }>` const InputWrapper = styled.div<{ width: string }>`
position: relative; position: relative;
@ -53,18 +54,18 @@ const InputInput = styled.input<{
transition: all 0.3s ease; transition: all 0.3s ease;
&:focus { &:focus {
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15); box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
border: 1px solid rgba(115, 103, 240); border: 1px solid ${props => props.theme.colors.primary};
background: ${props => props.focusBg}; background: ${props => props.focusBg};
} }
&:focus ~ ${InputLabel} { &:focus ~ ${InputLabel} {
color: rgba(115, 103, 240); color: ${props => props.theme.colors.primary};
transform: translate(-3px, -90%); transform: translate(-3px, -90%);
} }
${props => ${props =>
props.hasValue && props.hasValue &&
css` css`
& ~ ${InputLabel} { & ~ ${InputLabel} {
color: rgba(115, 103, 240); color: ${props.theme.colors.primary};
transform: translate(-3px, -90%); transform: translate(-3px, -90%);
} }
`} `}
@ -78,6 +79,7 @@ const Icon = styled.div`
type InputProps = { type InputProps = {
variant?: 'normal' | 'alternate'; variant?: 'normal' | 'alternate';
disabled?: boolean;
label?: string; label?: string;
width?: string; width?: string;
floatingLabel?: boolean; floatingLabel?: boolean;
@ -91,6 +93,7 @@ type InputProps = {
name?: string; name?: string;
className?: string; className?: string;
defaultValue?: string; defaultValue?: string;
value?: string;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void; onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
}; };
@ -115,6 +118,7 @@ function useCombinedRefs(...refs: any) {
const Input = React.forwardRef( const Input = React.forwardRef(
( (
{ {
disabled = false,
width = 'auto', width = 'auto',
variant = 'normal', variant = 'normal',
type = 'text', type = 'text',
@ -129,13 +133,14 @@ const Input = React.forwardRef(
onClick, onClick,
floatingLabel, floatingLabel,
defaultValue, defaultValue,
value,
id, id,
}: InputProps, }: InputProps,
$ref: any, $ref: any,
) => { ) => {
const [hasValue, setHasValue] = useState(defaultValue !== ''); const [hasValue, setHasValue] = useState(defaultValue !== '');
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561'; const borderColor = variant === 'normal' ? 'rgba(0,0,0,0.2)' : theme.colors.alternate;
const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)'; const focusBg = variant === 'normal' ? theme.colors.bg.secondary : theme.colors.bg.primary;
// Merge forwarded ref and internal ref in order to be able to access the ref in the useEffect // Merge forwarded ref and internal ref in order to be able to access the ref in the useEffect
// The forwarded ref is not accessible by itself, which is what the innerRef & combined ref is for // The forwarded ref is not accessible by itself, which is what the innerRef & combined ref is for
@ -158,6 +163,7 @@ const Input = React.forwardRef(
onChange={e => { onChange={e => {
setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false); setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false);
}} }}
disabled={disabled}
hasValue={hasValue} hasValue={hasValue}
ref={combinedRef} ref={combinedRef}
id={id} id={id}
@ -166,6 +172,7 @@ const Input = React.forwardRef(
onClick={onClick} onClick={onClick}
autoComplete={autocomplete ? 'on' : 'off'} autoComplete={autocomplete ? 'on' : 'off'}
defaultValue={defaultValue} defaultValue={defaultValue}
value={value}
hasIcon={typeof icon !== 'undefined'} hasIcon={typeof icon !== 'undefined'}
width={width} width={width}
placeholder={placeholder} placeholder={placeholder}

View File

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

View File

@ -1,6 +1,5 @@
import styled, { css } from 'styled-components'; import styled, { css } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea'; import TextareaAutosize from 'react-autosize-textarea';
import { mixin } from 'shared/utils/styles';
export const Container = styled.div` export const Container = styled.div`
width: 272px; width: 272px;
@ -34,7 +33,7 @@ export const AddCardButton = styled.a`
&:hover { &:hover {
color: #c2c6dc; color: #c2c6dc;
text-decoration: none; text-decoration: none;
background: rgb(115, 103, 240); background: ${props => props.theme.colors.primary};
} }
`; `;
export const Wrapper = styled.div` export const Wrapper = styled.div`
@ -73,7 +72,6 @@ export const HeaderName = styled(TextareaAutosize)`
box-shadow: none; box-shadow: none;
font-weight: 600; font-weight: 600;
margin: -4px 0; margin: -4px 0;
padding: 4px 8px;
letter-spacing: normal; letter-spacing: normal;
word-spacing: normal; word-spacing: normal;
@ -97,7 +95,7 @@ export const Header = styled.div<{ isEditing: boolean }>`
props.isEditing && props.isEditing &&
css` css`
& ${HeaderName} { & ${HeaderName} {
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px; box-shadow: ${props.theme.colors.primary} 0px 0px 0px 1px;
} }
`} `}
`; `;

View File

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

@ -21,7 +21,7 @@ export const ListActionItem = styled.span`
margin: 0 -12px; margin: 0 -12px;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
background: rgb(115, 103, 240); background: ${props => props.theme.colors.primary};
} }
`; `;

View File

@ -1,50 +1,100 @@
import React from 'react'; 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'; import { InnerContent, ListActionsWrapper, ListActionItemWrapper, ListActionItem, ListSeparator } from './Styles';
const CopyWrapper = styled.div`
margin: 0 12px;
`;
type Props = { type Props = {
taskGroupID: string; taskGroupID: string;
onDuplicateTaskGroup: (newTaskGroupName: string) => void;
onDeleteTaskGroupTasks: () => void;
onArchiveTaskGroup: (taskGroupID: string) => 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 ( return (
<InnerContent> <>
<ListActionsWrapper> <Popup tab={0} title={null}>
<ListActionItemWrapper> <InnerContent>
<ListActionItem>Add card...</ListActionItem> <ListActionsWrapper>
</ListActionItemWrapper> <ListActionItemWrapper onClick={() => setTab(1)}>
<ListActionItemWrapper> <ListActionItem>Duplicate</ListActionItem>
<ListActionItem>Copy List...</ListActionItem> </ListActionItemWrapper>
</ListActionItemWrapper> <ListActionItemWrapper onClick={() => setTab(2)}>
<ListActionItemWrapper> <ListActionItem>Sort</ListActionItem>
<ListActionItem>Move card...</ListActionItem> </ListActionItemWrapper>
</ListActionItemWrapper> </ListActionsWrapper>
<ListActionItemWrapper> <ListSeparator />
<ListActionItem>Watch</ListActionItem> <ListActionsWrapper>
</ListActionItemWrapper> <ListActionItemWrapper onClick={() => onDeleteTaskGroupTasks()}>
</ListActionsWrapper> <ListActionItem>Delete All Tasks</ListActionItem>
<ListSeparator /> </ListActionItemWrapper>
<ListActionsWrapper> </ListActionsWrapper>
<ListActionItemWrapper> <ListSeparator />
<ListActionItem>Sort By...</ListActionItem> <ListActionsWrapper>
</ListActionItemWrapper> <ListActionItemWrapper onClick={() => onArchiveTaskGroup(taskGroupID)}>
</ListActionsWrapper> <ListActionItem>Delete</ListActionItem>
<ListSeparator /> </ListActionItemWrapper>
<ListActionsWrapper> </ListActionsWrapper>
<ListActionItemWrapper> </InnerContent>
<ListActionItem>Move All Cards in This List...</ListActionItem> </Popup>
</ListActionItemWrapper> <Popup tab={1} title="Copy list" onClose={NOOP}>
<ListActionItemWrapper> <CopyWrapper>
<ListActionItem>Archive All Cards in This List...</ListActionItem> <NameEditor
</ListActionItemWrapper> onCancel={NOOP}
</ListActionsWrapper> onSave={listName => {
<ListSeparator /> onDuplicateTaskGroup(listName);
<ListActionsWrapper> }}
<ListActionItemWrapper onClick={() => onArchiveTaskGroup(taskGroupID)}> buttonLabel="Duplicate"
<ListActionItem>Archive This List</ListActionItem> />
</ListActionItemWrapper> </CopyWrapper>
</ListActionsWrapper> </Popup>
</InnerContent> <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; export default LabelManager;

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import theme from 'App/ThemeStyles';
import Lists from '.'; import Lists from '.';
export default { export default {
@ -7,7 +8,7 @@ export default {
title: 'Lists', title: 'Lists',
parameters: { parameters: {
backgrounds: [ backgrounds: [
{ name: 'gray', value: '#262c49', default: true }, { name: 'gray', value: theme.colors.bg.secondary, default: true },
{ name: 'white', value: '#ffffff' }, { name: 'white', value: '#ffffff' },
], ],
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import styled from 'styled-components'; import styled from 'styled-components';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { mixin } from 'shared/utils/styles';
export const Wrapper = styled.div` export const Wrapper = styled.div`
background: #eff2f7; background: #eff2f7;
@ -68,7 +69,7 @@ export const FormIcon = styled.div`
export const FormError = styled.span` export const FormError = styled.span`
font-size: 0.875rem; font-size: 0.875rem;
color: rgb(234, 84, 85); color: ${props => props.theme.colors.danger};
`; `;
export const LoginButton = styled(Button)``; export const LoginButton = styled(Button)``;
@ -99,5 +100,5 @@ export const LogoWrapper = styled.div`
padding-bottom: 16px; padding-bottom: 16px;
margin-bottom: 24px; margin-bottom: 24px;
color: rgb(222, 235, 255); color: rgb(222, 235, 255);
border-bottom: 1px solid rgba(65, 69, 97, 0.65); border-bottom: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0.65)};
`; `;

View File

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

View File

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

View File

@ -20,14 +20,14 @@ export const MemberManagerSearch = styled(TextareaAutosize)`
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
background: #262c49; background: ${props => props.theme.colors.bg.secondary};
outline: none; outline: none;
color: #c2c6dc; color: ${props => props.theme.colors.text.primary};
border-color: #414561; border-color: ${props => props.theme.colors.border};
&:focus { &:focus {
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px; box-shadow: ${props => props.theme.colors.primary} 0px 0px 0px 1px;
background: ${mixin.darken('#262c49', 0.15)}; background: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.15)};
} }
`; `;
@ -66,8 +66,8 @@ export const BoardMemberListItemContent = styled(Member)`
color: #c2c6dc; color: #c2c6dc;
&:hover { &:hover {
background-color: rgba(${props => props.theme.colors.primary}); background-color: ${props => props.theme.colors.primary};
color: rgba(${props => props.theme.colors.text.secondary}); color: ${props => props.theme.colors.text.secondary};
} }
`; `;
@ -80,7 +80,7 @@ export const ProfileIcon = styled.div`
justify-content: center; justify-content: center;
color: #c2c6dc; color: #c2c6dc;
font-weight: 700; font-weight: 700;
background: rgb(115, 103, 240); background: ${props => props.theme.colors.primary};
cursor: pointer; cursor: pointer;
margin-right: 6px; margin-right: 6px;
`; `;

View File

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

View File

@ -1,6 +1,7 @@
import styled, { css } from 'styled-components'; import styled, { css } from 'styled-components';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { Checkmark } from 'shared/icons'; import { Checkmark } from 'shared/icons';
import { mixin } from 'shared/utils/styles';
export const RoleCheckmark = styled(Checkmark)` export const RoleCheckmark = styled(Checkmark)`
padding-left: 4px; padding-left: 4px;
@ -80,36 +81,36 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
? css` ? css`
user-select: none; user-select: none;
pointer-events: none; pointer-events: none;
color: rgba(${props.theme.colors.text.primary}, 0.4); color: ${mixin.rgba(props.theme.colors.text.primary, 0.4)};
` `
: css` : css`
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: rgb(115, 103, 240); background: ${props.theme.colors.primary};
} }
`} `}
`; `;
export const CurrentPermission = styled.span` export const CurrentPermission = styled.span`
margin-left: 4px; margin-left: 4px;
color: rgba(${props => props.theme.colors.text.secondary}, 0.4); color: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
`; `;
export const Separator = styled.div` export const Separator = styled.div`
height: 1px; height: 1px;
border-top: 1px solid #414561; border-top: 1px solid ${props => props.theme.colors.alternate};
margin: 0.25rem !important; margin: 0.25rem !important;
`; `;
export const WarningText = styled.span` export const WarningText = styled.span`
display: flex; display: flex;
color: rgba(${props => props.theme.colors.text.primary}, 0.4); color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)};
padding: 6px; padding: 6px;
`; `;
export const DeleteDescription = styled.div` export const DeleteDescription = styled.div`
font-size: 14px; font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary}); color: ${props => props.theme.colors.text.primary};
`; `;
export const RemoveMemberButton = styled(Button)` export const RemoveMemberButton = styled(Button)`

View File

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

View File

@ -15,18 +15,20 @@ export const ScrollOverlay = styled.div`
export const ClickableOverlay = styled.div` export const ClickableOverlay = styled.div`
min-height: 100%; min-height: 100%;
background: rgba(0, 0, 0, 0.4); background: rgba(0, 0, 0, 0.4);
display: flex;
justify-content: center;
`; `;
export const StyledModal = styled.div<{ width: number }>` export const StyledModal = styled.div<{ width: number; height: number }>`
display: inline-block;
position: relative; position: relative;
margin: 48px 0 80px; width: ${props => props.width}px;
width: 100%; height: ${props => props.height}px;
left: 0;
right: 0;
top: 48px;
bottom: 16px;
margin: auto;
background: #262c49; background: #262c49;
max-width: ${props => props.width}px;
vertical-align: middle; vertical-align: middle;
border-radius: 3px; border-radius: 6px;
${mixin.boxShadowMedium} ${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 ReactDOM from 'react-dom';
import useOnOutsideClick from 'shared/hooks/onOutsideClick'; import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown'; 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'; import { ScrollOverlay, ClickableOverlay, StyledModal } from './Styles';
const $root: HTMLElement = document.getElementById('root')!; // eslint-disable-line @typescript-eslint/no-non-null-assertion const $root: HTMLElement = document.getElementById('root')!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
@ -14,21 +15,50 @@ type ModalProps = {
renderContent: () => JSX.Element; 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: ${props => props.theme.colors.text.primary};
&:hover {
fill: ${props => props.theme.colors.text.secondary};
}
`;
const InnerModal: React.FC<ModalProps> = ({ width, onClose: tellParentToClose, renderContent }) => {
const $modalRef = useRef<HTMLDivElement>(null); const $modalRef = useRef<HTMLDivElement>(null);
const $clickableOverlayRef = useRef<HTMLDivElement>(null); const $clickableOverlayRef = useRef<HTMLDivElement>(null);
const [_width, height] = useWindowSize();
useOnOutsideClick($modalRef, true, tellParentToClose, $clickableOverlayRef); useOnOutsideClick($modalRef, true, tellParentToClose, $clickableOverlayRef);
useOnEscapeKeyDown(true, tellParentToClose); useOnEscapeKeyDown(true, tellParentToClose);
return ReactDOM.createPortal( return (
<ScrollOverlay> <ScrollOverlay>
<ClickableOverlay ref={$clickableOverlayRef}> <ClickableOverlay ref={$clickableOverlayRef}>
<StyledModal width={width} ref={$modalRef}> <StyledModal width={width} height={getAdjustedHeight(height)} ref={$modalRef}>
{renderContent()} {renderContent()}
<CloseIcon onClick={() => tellParentToClose()} width={20} height={20} />
</StyledModal> </StyledModal>
</ClickableOverlay> </ClickableOverlay>
</ScrollOverlay>, </ScrollOverlay>
);
};
const Modal: React.FC<ModalProps> = ({ width, onClose: tellParentToClose, renderContent }) => {
return ReactDOM.createPortal(
<InnerModal width={width} onClose={tellParentToClose} renderContent={renderContent} />,
$root, $root,
); );
}; };

View File

@ -1,4 +1,5 @@
import styled, { css } from 'styled-components'; import styled, { css } from 'styled-components';
import { mixin } from 'shared/utils/styles';
export const Logo = styled.div``; export const Logo = styled.div``;
@ -9,7 +10,7 @@ export const LogoTitle = styled.div`
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
transition: visibility, opacity, transform 0.25s ease; transition: visibility, opacity, transform 0.25s ease;
color: #7367f0; color: #22ff00;
`; `;
export const ActionContainer = styled.div` export const ActionContainer = styled.div`
position: relative; position: relative;
@ -46,8 +47,8 @@ export const ActionButtonWrapper = styled.div<{ active?: boolean }>`
${props => ${props =>
props.active && props.active &&
css` css`
background: rgb(115, 103, 240); background: ${props.theme.colors.primary};
box-shadow: 0 0 10px 1px rgba(115, 103, 240, 0.7); box-shadow: 0 0 10px 1px ${mixin.rgba(props.theme.colors.primary, 0.7)};
`} `}
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -73,7 +74,7 @@ export const LogoWrapper = styled.div`
color: rgb(222, 235, 255); color: rgb(222, 235, 255);
cursor: pointer; cursor: pointer;
transition: color 0.1s ease 0s, border 0.1s ease 0s; transition: color 0.1s ease 0s, border 0.1s ease 0s;
border-bottom: 1px solid rgba(65, 69, 97, 0.65); border-bottom: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0.65)};
`; `;
export const Container = styled.aside` export const Container = styled.aside`
@ -87,12 +88,12 @@ export const Container = styled.aside`
transform: translateZ(0px); transform: translateZ(0px);
background: #10163a; background: #10163a;
transition: all 0.1s ease 0s; transition: all 0.1s ease 0s;
border-right: 1px solid rgba(65, 69, 97, 0.65); border-right: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0.65)};
&:hover { &:hover {
width: 260px; width: 260px;
box-shadow: rgba(0, 0, 0, 0.6) 0px 0px 50px 0px; box-shadow: rgba(0, 0, 0, 0.6) 0px 0px 50px 0px;
border-right: 1px solid rgba(65, 69, 97, 0); border-right: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0)};
} }
&:hover ${LogoTitle} { &:hover ${LogoTitle} {
bottom: -12px; bottom: -12px;
@ -106,6 +107,6 @@ export const Container = styled.aside`
} }
&:hover ${LogoWrapper} { &:hover ${LogoWrapper} {
border-bottom: 1px solid rgba(65, 69, 97, 0); border-bottom: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0)};
} }
`; `;

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