117 Commits
0.1.1 ... 0.3.4

Author SHA1 Message Date
3bfce1825c docs: update unreleased changelog section 2021-09-04 13:16:03 -05:00
2b4f94117c fix: add missing rich-markdown-editor dependency
fixes #122
2021-09-04 12:16:01 -05:00
05799fce90 fix: hide any open popups when closing task details modal 2021-05-10 12:46:46 -05:00
b4f37350a9 refactor: switch to personal fork of rich-markdown-editor 2021-05-10 12:45:40 -05:00
8c6a3db0bc deps: upgrade all dependencies 2021-05-02 17:31:24 -05:00
5a9a66effe feat: apply new label to task when available 2021-04-30 23:49:12 -05:00
167d285d02 refactor: polling is now turned off in development mode 2021-04-30 23:36:58 -05:00
e2634dc490 feat: redirect after login when applicable 2021-04-30 23:25:48 -05:00
04c12e4da9 feat: projects can be set to public 2021-04-30 22:55:37 -05:00
3e72271d9b refactor(Project): split out components into their own files 2021-04-30 20:06:05 -05:00
bd34f4b3ad feat: change primary font to Open Sans 2021-04-30 16:35:43 -05:00
f45e359402 refactor: clean up components 2021-04-28 21:51:47 -05:00
229a53fa0a refactor: replace refresh & access token with auth token only
changes authentication to no longer use a refresh token & access token
for accessing protected endpoints. Instead only an auth token is used.

Before the login flow was:

Login -> get refresh (stored as HttpOnly cookie) + access token (stored in memory) ->
  protected endpoint request (attach access token as Authorization header) -> access token expires in
  15 minutes, so use refresh token to obtain new one when that happens

now it looks like this:

Login -> get auth token (stored as HttpOnly cookie) -> make protected endpont
request (token sent)

the reasoning for using the refresh + access token was to reduce DB
calls, but in the end I don't think its worth the hassle.
2021-04-28 21:38:49 -05:00
3392b3345d fix(Project): remove spacing between task group list and add new task list component 2021-04-28 21:38:49 -05:00
29b7c028ca chore: update yarn.lock 2021-04-28 21:38:49 -05:00
0cf4141418 refactor: move server.secret warning to before server startup messsage
done as it seems to be confusing some users that the server
actually had some issues starting when in reality it did not.
2021-04-28 21:38:49 -05:00
383d90d747 chore(Pipfile): update python version 2021-04-28 21:38:49 -05:00
0cc1b5a1df refactor: remove Storybook stories 2021-04-28 21:38:49 -05:00
0760edac80 feat(FilterMeta): auto focus task name search input on popup open 2021-04-28 21:38:49 -05:00
ceaa49c5a1 feat(TaskDetails): clicking the '+' button now opens label manager 2021-04-28 21:38:49 -05:00
8b22d33dad fix(QuickCardEditor): use correct ref on due date open 2021-04-28 21:38:49 -05:00
61e9249c98 fix: prevent empty list title from being saved 2021-04-28 21:38:49 -05:00
c347c6bdc3 chore(deps): bump immer from 6.0.9 to 8.0.1 in /frontend
Bumps [immer](https://github.com/immerjs/immer) from 6.0.9 to 8.0.1.
- [Release notes](https://github.com/immerjs/immer/releases)
- [Commits](https://github.com/immerjs/immer/compare/v6.0.9...v8.0.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-02-01 16:18:30 -06:00
35ac12b7b2 fix(Lists): fix flickering when list was a certain size 2021-01-06 17:00:10 -06:00
40b27aa1f1 fix: card composer no longer allows creates tasks with empty names 2021-01-06 16:20:05 -06:00
533b9511c9 feat: add alternate project finder to left navbar 2021-01-05 19:47:52 -06:00
dc50ef3566 refactor: change nav icons to use Link instead of history.push 2021-01-05 19:10:07 -06:00
f9e6fba552 fix(Projects): set overflow-y to auto on Wrapper 2021-01-05 19:06:49 -06:00
be7e945313 feat(ProjectFinder): auto focus search bar 2021-01-05 19:04:47 -06:00
edc7b649ec feat: card composer now auto scrolls into view on being opened 2021-01-05 19:02:19 -06:00
4b83ff594f fix: fix AddList component behaving weirdly when a Task Group was moved 2021-01-05 18:58:13 -06:00
c2a0f5e5d0 feat: change project title input to auto-grow on content change 2021-01-05 18:53:48 -06:00
ff15e7fb53 feat: hide project finder after selecting project 2021-01-05 17:00:31 -06:00
b5744bcf22 fix: fix task position to use task idx not task group idx 2021-01-05 17:00:07 -06:00
a7c1ca328f feat: add search and minify to project finder 2021-01-05 16:46:49 -06:00
783e1c84c3 feat: add seed command to generate fake project data 2021-01-05 16:46:15 -06:00
433a4fd55c docs(README): update readme feature list & heading 2021-01-04 16:24:36 -06:00
eb0727ddcb chore(deps): bump axios from 0.19.2 to 0.21.1 in /frontend
Bumps [axios](https://github.com/axios/axios) from 0.19.2 to 0.21.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v0.21.1/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.19.2...v0.21.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-01-04 16:14:21 -06:00
77087158a9 fix: emoji remark plugin sets children type to html instead of parent
This fixes the issue where if a URL was added to the comment, the remark
plugin would break. Instead of changing the parent node to be html, it
now sets any 'text' type children to be 'html' instead.
2021-01-04 16:09:14 -06:00
f215418be1 fix: fix background not changing on hover in extra menu on sort popup 2021-01-03 17:11:08 -06:00
f051bebd48 feat(MyTasks): allow filtering by task complete status 2021-01-03 17:04:15 -06:00
a1c9251a1f fix(TaskDetails): blur task title textarea on pressing enter 2021-01-03 15:59:53 -06:00
9d7f46907f fix(MyTasks): update task entry name when updating name through TaskDetails 2021-01-03 15:54:32 -06:00
dcf53b9077 feat: add my tasks list view 2021-01-01 22:20:55 -06:00
d6101d9221 feat: redesign task due date manager 2021-01-01 14:54:05 -06:00
a8b3809515 feat: allow access token expiration to be set in the config 2020-12-30 21:10:55 -06:00
f16cceb0e1 feat: add ui skeleton to Task Details while loading 2020-12-30 19:14:00 -06:00
90b92781d7 refactor(Magefile): add build info in backend:build through ldflags 2020-12-29 19:37:14 -06:00
1bac555ebb fix: respect jwt validation errors 2020-12-29 17:42:38 -06:00
668b118b25 fix: admin created users are now set to be active by default 2020-12-29 17:25:42 -06:00
9c051c51a6 fix: add cascade delete to task comment & activity rows 2020-12-25 22:16:16 -06:00
66c603de75 docs: update contributing & redirect feature request to discussions 2020-12-24 20:18:20 -06:00
8d3b0bd510 fix: fix flashing on pollInterval 2020-12-23 20:02:38 -06:00
9f27bd157f feat: smtp server for sending email can now be set by config 2020-12-23 16:44:13 -06:00
e25a426e7b fix: update editor style to use new format for theme colors 2020-12-23 16:15:20 -06:00
0c9ab8abc2 feat: add update polling to relevant views 2020-12-23 15:55:17 -06:00
c4a80590a1 fix: fix issue where personal projects did not show up in Project Finder 2020-12-23 13:21:06 -06:00
978be2218d fix: fix issue where the Task Details modal would false when changing due date 2020-12-23 13:17:54 -06:00
19deab0515 feat: add task activity 2020-12-23 13:15:15 -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
315 changed files with 34599 additions and 17741 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
-->

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Feature Request
url: https://github.com/JordanKnott/taskcafe/discussions/new?category=ideas
about: Share ideas for new features
- name: Ask a Question
url: https://github.com/JordanKnott/taskcafe/discussions/new?category=q-a
about: Ask the community for help

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

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

28
CHANGELOG.md Normal file
View File

@ -0,0 +1,28 @@
# 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).
## [0.4.0] - 2021-09-04
### Added
- Project visibility can now be set to public - meaning anyone can view the project board
- When redirected to login page while trying to view a page that requires login, you'll be redirected back to the correct page after login
- When creating a new label within the LabelManager on a card, the new label will automatically be applied to the task after creation
### Changed
- Switch primary font to Open Sans
### Fixed
- Any open popups are hidden when closing the Task Details window
## [0.1.1] - 2020-08-21
### Fixed
- fix panic(nil) when loading config if config file actually exists
## [0.1.0] - 2020-08-21
### Added
- first "stable" alpha release

View File

@ -4,8 +4,15 @@ Thanks for wanting to contribute to Taskcafe!
### Where do I go from here?
If you have noticed a bug or have a feature request, make one! If best to get confirmation
of your bug or feature before starting work on a pull request.
So you want to contribute to Taskcafe? Great!
If you have noticed a bug, please [create an issue](https://github.com/JordanKnott/taskcafe/issues/new/choose) for it before starting any work on a pull request.
If there is a [new feature you'd like added](https://github.com/JordanKnott/taskcafe/discussions/new?category=ideas) or [have a question](https://github.com/JordanKnott/taskcafe/discussions/new?category=q-a), please visit the [discussions section](https://github.com/JordanKnott/taskcafe/discussions)
Alternatively you can join the [Taskcafe discord](https://discord.gg/JkQDruh) and ask in the #questions channel.
After the bug is validated or the feature is accepted by a project maintainer, the next step is to fork the repository!
### Fork & create a branch
@ -27,6 +34,10 @@ The `description` is a decriptive summary of the change the PR will make.
- One PR per fix or feature
- Setup & install [pre-commit hooks](https://pre-commit.com/#install) then install the hooks `pre-commit install && pre-commit install --hook-type commit-msg`
### Unwanted PRs
- Please do not submit pull requests containing only typo fixes, fixed spelling mistakes, or minor wording changes.
### Git Commit Message Style
This project uses the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) format.

View File

@ -9,4 +9,4 @@ verify_ssl = true
pre-commit = "*"
[requires]
python_version = "3.8"
python_version = "3.9"

71
Pipfile.lock generated
View File

@ -1,11 +1,11 @@
{
"_meta": {
"hash": {
"sha256": "83ec7c0175ee9763b335b1855d3d226b2fe799fcd4cafd8e08eb7294cb5ddd07"
"sha256": "76a59164ad995ef4d02794470696e6f1dd199ede126c2d92a2bc1011eb288f69"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.8"
"python_version": "3.9"
},
"sources": [
{
@ -47,64 +47,77 @@
},
"identify": {
"hashes": [
"sha256:69c4769f085badafd0e04b1763e847258cbbf6d898e8678ebffc91abdb86f6c6",
"sha256:d6ae6daee50ba1b493e9ca4d36a5edd55905d2cf43548fdc20b2a14edef102e7"
"sha256:70b638cf4743f33042bebb3b51e25261a0a10e80f978739f17e7fd4837664a66",
"sha256:9dfb63a2e871b807e3ba62f029813552a24b5289504f5b071dea9b041aee9fe4"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.4.28"
"version": "==1.5.13"
},
"nodeenv": {
"hashes": [
"sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc"
"sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9",
"sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"
],
"version": "==1.4.0"
"version": "==1.5.0"
},
"pre-commit": {
"hashes": [
"sha256:1657663fdd63a321a4a739915d7d03baedd555b25054449090f97bb0cb30a915",
"sha256:e8b1315c585052e729ab7e99dcca5698266bedce9067d21dc909c23e3ceed626"
"sha256:16212d1fde2bed88159287da88ff03796863854b04dc9f838a55979325a3d20e",
"sha256:399baf78f13f4de82a29b649afd74bef2c4e28eb4f021661fc7f29246e8c7a3a"
],
"index": "pypi",
"version": "==2.6.0"
"version": "==2.10.1"
},
"pyyaml": {
"hashes": [
"sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
"sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
"sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
"sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
"sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
"sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
"sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
"sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696",
"sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393",
"sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77",
"sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922",
"sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5",
"sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8",
"sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10",
"sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc",
"sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018",
"sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e",
"sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253",
"sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183",
"sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb",
"sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185",
"sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db",
"sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46",
"sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b",
"sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63",
"sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df",
"sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"
],
"version": "==5.3.1"
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==5.4.1"
},
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
},
"toml": {
"hashes": [
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
],
"version": "==0.10.1"
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.2"
},
"virtualenv": {
"hashes": [
"sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc",
"sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b"
"sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d",
"sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.0.31"
"version": "==20.4.2"
}
},
"develop": {}

View File

@ -1,32 +1,53 @@
![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>
[![Discord](https://img.shields.io/discord/745396499613220955)](https://discord.gg/JkQDruh)
[![Releases](https://img.shields.io/github/v/release/JordanKnott/taskcafe)](https://github.com/JordanKnott/taskcafe/releases)
[![Dockerhub](https://img.shields.io/docker/v/taskcafe/taskcafe)](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
<p align="center">
<a href="https://github.com/JordanKnott/taskcafe/issues/new?assignees=&labels=&template=bug_report.md&title=">Report Bug</a>
·
<a href="https://github.com/JordanKnott/taskcafe/discussions/new?category=ideas">Request Feature</a>
·
<a href="https://github.com/JordanKnott/taskcafe/discussions/new?category=q-a">Ask a Question</a>
</p>
<p align="center">
Was this project useful? Please consider <a href="https://www.buymeacoffee.com/jordanknott">donating</a> to help me improve it!
</p>
<p align="center">
This project is still in <strong>alpha development</strong></p>
![Taskcafe](./.github/taskcafe_preview.png)
A free & open source alternative project management tool.
**Please note that this project is still in active development. Some options may not work yet!**
## Features
Currently Taskcafe only offers basic task tracking through a Kanban board.
The following features have been implemented:
Currently you can do the following to tasks:
- Manage tasks through a Kanban board interface (set due dates, labels, add checklists)
- View all your current assigned tasks through the My Tasks view
- Personal projects
- Task comments and activity
- Add colors & named labels
- Add due dates
- Descriptions written in Markdown
- Assign members
- Checklists
- Mark tasks as complete
This project is still in active development, so some options may not be fully implemented yet.
For a list of planned features, check out the [roadmap](https://github.com/JordanKnott/taskcafe/wiki/Roadmap)!
**For updates on development, join the [Discord server](https://discord.gg/JkQDruh).**
For a list of planned features, check out the [Roadmap](https://github.com/JordanKnott/taskcafe/wiki/Roadmap)!
## Installation
@ -44,7 +65,6 @@ Now do the following:
``` bash
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.
@ -80,25 +100,28 @@ This will:
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.
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`.
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)?
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.
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.
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

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]
host = '0.0.0.0:3333'
[server]
hostname = '0.0.0.0:3333'
[email_notifications]
enabled = true
@ -17,8 +17,9 @@ user = 'taskcafe'
password = 'taskcafe_test'
[smtp]
username = 'admin@example.com'
password = 'example'
server = 'mail.example.com'
port = 465
connection_security = 'STARTTLS'
username = 'taskcafe@example.com'
password = ''
from = 'no-reply@taskcafe.com'
host = 'localhost'
port = 11500
skip_verify = false

View File

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

View File

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

2
frontend/.env Normal file
View File

@ -0,0 +1,2 @@
REACT_APP_ENABLE_POLLING=true
ESLINT_NO_DEV_ERRORS=true

View File

@ -0,0 +1 @@
REACT_APP_ENABLE_POLLING=false

View File

@ -24,13 +24,17 @@
"plugin:@typescript-eslint/recommended"
],
"rules": {
"prettier/prettier": "error",
"prettier/prettier": "warn",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }],
"react/require-default-props": "off",
"no-case-declarations": "off",
"no-plusplus": "off",
"react/prop-types": 0,
"no-continue": "off",
"react/jsx-props-no-spreading": "off",
"no-param-reassign": "off",
"import/extensions": [
@ -44,6 +48,8 @@
"tsx": "never"
}
],
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": ["error"],
"import/no-extraneous-dependencies": [
"error",
{

View File

@ -3,32 +3,25 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@apollo/client": "^3.0.0-rc.8",
"@apollo/client": "^3.3.16",
"@apollo/react-common": "^3.1.4",
"@apollo/react-hooks": "^3.1.3",
"@fortawesome/fontawesome-svg-core": "^1.2.27",
"@fortawesome/free-brands-svg-icons": "^5.12.1",
"@fortawesome/free-regular-svg-icons": "^5.12.1",
"@fortawesome/free-solid-svg-icons": "^5.12.1",
"@fortawesome/react-fontawesome": "^0.1.8",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/axios": "^0.14.0",
"@apollo/react-hooks": "^4.0.0",
"@taskcafe/rich-markdown-editor": "^11.0.10",
"@types/color": "^3.0.1",
"@types/date-fns": "^2.6.0",
"@types/jest": "^24.0.0",
"@types/jwt-decode": "^2.2.1",
"@types/lodash": "^4.14.149",
"@types/node": "^12.0.0",
"@types/react": "^16.9.21",
"@types/react-beautiful-dnd": "^12.1.1",
"@types/react-datepicker": "^2.11.0",
"@types/react-dom": "^16.9.5",
"@types/react-router": "^5.1.4",
"@types/react-router-dom": "^5.1.3",
"@types/react-select": "^3.0.13",
"@types/styled-components": "^5.0.0",
"@types/dompurify": "^2.2.2",
"@types/emoji-mart": "^3.0.4",
"@types/jest": "^26.0.23",
"@types/lodash": "^4.14.168",
"@types/node": "^15.0.1",
"@types/react": "^17.0.4",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-datepicker": "^3.1.8",
"@types/react-dom": "^17.0.3",
"@types/react-router": "^5.1.13",
"@types/react-router-dom": "^5.1.7",
"@types/react-select": "^4.0.15",
"@types/react-timeago": "^4.1.1",
"@types/styled-components": "^5.1.0",
"apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8",
"apollo-link": "^1.2.13",
@ -36,31 +29,40 @@
"apollo-link-http": "^1.5.16",
"apollo-link-state": "^0.4.2",
"apollo-utilities": "^1.3.3",
"axios": "^0.19.2",
"axios-auth-refresh": "^2.2.7",
"axios": "^0.21.1",
"axios-auth-refresh": "^3.1.0",
"color": "^3.1.2",
"date-fns": "^2.14.0",
"graphql": "^15.0.0",
"graphql-tag": "^2.10.3",
"history": "^4.10.1",
"immer": "^6.0.3",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.15",
"moment": "^2.24.0",
"date-fns": "^2.21.1",
"dayjs": "^1.10.4",
"dompurify": "^2.2.8",
"emoji-mart": "^3.0.1",
"emoticon": "^4.0.0",
"graphql": "^15.5.0",
"graphql-tag": "^2.12.4",
"history": "^5.0.0",
"immer": "^9.0.2",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"node-emoji": "^1.10.0",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"query-string": "^7.0.0",
"react": "^17.0.2",
"react-autosize-textarea": "^7.0.0",
"react-beautiful-dnd": "^13.0.0",
"react-datepicker": "^2.14.1",
"react-dom": "^16.12.0",
"react-hook-form": "^6.0.6",
"react-markdown": "^4.3.1",
"react-beautiful-dnd": "^13.1.0",
"react-datepicker": "^3.8.0",
"react-dom": "^17.0.2",
"react-emoji-render": "^1.2.4",
"react-hook-form": "^7.3.6",
"react-markdown": "^6.0.1",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.0",
"react-select": "^3.1.0",
"styled-components": "^5.0.1",
"typescript": "~3.7.2"
"react-scripts": "4.0.3",
"react-select": "^4.3.0",
"react-timeago": "^5.2.0",
"react-toastify": "^7.0.4",
"rich-markdown-editor": "^11.17.4-0",
"styled-components": "^5.2.3",
"typescript": "~4.2.4"
},
"proxy": "http://localhost:3333",
"scripts": {
@ -68,10 +70,9 @@
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"storybook": "start-storybook -p 9009 -s public",
"build-storybook": "build-storybook -s public",
"generate": "graphql-codegen",
"lint": "eslint --ext js,ts,tsx src"
"lint": "eslint --ext js,ts,tsx src",
"tsc": "tsc"
},
"eslintConfig": {
"extends": "react-app"
@ -89,30 +90,20 @@
]
},
"devDependencies": {
"@graphql-codegen/cli": "^1.13.2",
"@graphql-codegen/typescript": "^1.13.2",
"@graphql-codegen/typescript-operations": "^1.13.2",
"@graphql-codegen/typescript-react-apollo": "^1.13.2",
"@storybook/addon-actions": "^5.3.13",
"@storybook/addon-links": "^5.3.13",
"@storybook/addon-backgrounds": "^5.3.17",
"@storybook/addon-docs": "^5.3.17",
"@storybook/addon-knobs": "^5.3.17",
"@storybook/addon-storysource": "^5.3.17",
"@storybook/addon-viewport": "^5.3.17",
"@storybook/addons": "^5.3.13",
"@storybook/preset-create-react-app": "^1.5.2",
"@storybook/react": "^5.3.13",
"@typescript-eslint/eslint-plugin": "^2.20.0",
"@typescript-eslint/parser": "^2.20.0",
"eslint": "^6.8.0",
"@graphql-codegen/cli": "^1.21.4",
"@graphql-codegen/typescript": "^1.22.0",
"@graphql-codegen/typescript-operations": "^1.17.16",
"@graphql-codegen/typescript-react-apollo": "^2.2.4",
"@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0",
"eslint": "^7.25.0",
"eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.10.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-prettier": "^3.1.2",
"eslint-plugin-react": "^7.18.3",
"eslint-plugin-react-hooks": "^1.7.0",
"prettier": "^1.19.1"
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^4.2.0",
"prettier": "^2.2.1"
}
}

View File

@ -1,23 +1,25 @@
import React, { useEffect, useState, useContext } from 'react';
import React, { useEffect } from 'react';
import Admin from 'shared/components/Admin';
import Select from 'shared/components/Select';
import GlobalTopNavbar from 'App/TopNavbar';
import {
useUsersQuery,
useDeleteUserAccountMutation,
useDeleteInvitedUserAccountMutation,
useCreateUserAccountMutation,
UsersDocument,
UsersQuery,
} from 'shared/generated/graphql';
import Input from 'shared/components/Input';
import styled from 'styled-components';
import Button from 'shared/components/Button';
import { useForm, Controller } from 'react-hook-form';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer';
import updateApolloCache from 'shared/utils/cache';
import UserContext, { useCurrentUser } from 'App/context';
import { useCurrentUser } from 'App/context';
import { Redirect } from 'react-router';
import NOOP from 'shared/utils/noop';
import ControlledInput from 'shared/components/ControlledInput';
const DeleteUserWrapper = styled.div`
display: flex;
@ -37,6 +39,7 @@ const DeleteUserButton = styled(Button)`
type DeleteUserPopupProps = {
onDeleteUser: () => void;
};
const DeleteUserPopup: React.FC<DeleteUserPopupProps> = ({ onDeleteUser }) => {
return (
<DeleteUserWrapper>
@ -47,10 +50,12 @@ const DeleteUserPopup: React.FC<DeleteUserPopupProps> = ({ onDeleteUser }) => {
</DeleteUserWrapper>
);
};
type RoleCodeOption = {
label: string;
value: string;
};
type CreateUserData = {
email: string;
username: string;
@ -65,18 +70,19 @@ const CreateUserForm = styled.form`
flex-direction: column;
margin: 0 12px;
`;
const CreateUserButton = styled(Button)`
margin-top: 8px;
padding: 6px 12px;
width: 100%;
`;
const AddUserInput = styled(Input)`
const AddUserInput = styled(ControlledInput)`
margin-bottom: 8px;
`;
const InputError = styled.span`
color: rgba(${props => props.theme.colors.danger});
color: ${(props) => props.theme.colors.danger};
font-size: 12px;
`;
@ -85,7 +91,12 @@ type AddUserPopupProps = {
};
const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
const { register, handleSubmit, errors, setValue, control } = useForm<CreateUserData>();
const {
register,
handleSubmit,
formState: { errors },
control,
} = useForm<CreateUserData>();
const createUser = (data: CreateUserData) => {
onAddUser(data);
@ -96,30 +107,25 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
floatingLabel
width="100%"
label="Full Name"
id="fullName"
name="fullName"
variant="alternate"
ref={register({ required: 'Full name is required' })}
{...register('fullName', { required: 'Full name is required' })}
/>
{errors.fullName && <InputError>{errors.fullName.message}</InputError>}
<AddUserInput
floatingLabel
width="100%"
label="Email"
id="email"
name="email"
variant="alternate"
ref={register({ required: 'Email is required' })}
{...register('email', { required: 'Email is required' })}
/>
<Controller
control={control}
name="roleCode"
rules={{ required: 'Role is required' }}
render={({ onChange, onBlur, value }) => (
render={({ field }) => (
<Select
{...field}
label="Role"
value={value}
onChange={onChange}
options={[
{ label: 'Admin', value: 'admin' },
{ label: 'Member', value: 'member' },
@ -132,31 +138,25 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
floatingLabel
width="100%"
label="Username"
id="username"
name="username"
variant="alternate"
ref={register({ required: 'Username is required' })}
{...register('username', { required: 'Username is required' })}
/>
{errors.username && <InputError>{errors.username.message}</InputError>}
<AddUserInput
floatingLabel
width="100%"
label="Initials"
id="initials"
name="initials"
variant="alternate"
ref={register({ required: 'Initials is required' })}
{...register('initials', { required: 'Initials is required' })}
/>
{errors.initials && <InputError>{errors.initials.message}</InputError>}
<AddUserInput
floatingLabel
width="100%"
label="Password"
id="password"
name="password"
variant="alternate"
type="password"
ref={register({ required: 'Password is required' })}
{...register('password', { required: 'Password is required' })}
/>
{errors.password && <InputError>{errors.password.message}</InputError>}
<CreateUserButton type="submit">Create</CreateUserButton>
@ -166,16 +166,27 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
const AdminRoute = () => {
useEffect(() => {
document.title = 'Taskcafé | Admin';
document.title = 'Admin | Taskcafé';
}, []);
const { loading, data } = useUsersQuery();
const { loading, data } = useUsersQuery({ fetchPolicy: 'cache-and-network' });
const { showPopup, hidePopup } = usePopup();
const { user } = useCurrentUser();
const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({
update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, (cache) =>
produce(cache, (draftCache) => {
draftCache.invitedUsers = cache.invitedUsers.filter(
(u) => u.id !== response.data?.deleteInvitedUserAccount.invitedUser.id,
);
}),
);
},
});
const [deleteUser] = useDeleteUserAccountMutation({
update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
produce(cache, draftCache => {
draftCache.users = cache.users.filter(u => u.id !== response.data.deleteUserAccount.userAccount.id);
updateApolloCache<UsersQuery>(client, UsersDocument, (cache) =>
produce(cache, (draftCache) => {
draftCache.users = cache.users.filter((u) => u.id !== response.data?.deleteUserAccount.userAccount.id);
}),
);
},
@ -186,7 +197,7 @@ const AdminRoute = () => {
query: UsersDocument,
});
const newData = produce(cacheData, (draftState: any) => {
draftState.users = [...draftState.users, { ...createData.data.createUserAccount }];
draftState.users = [...draftState.users, { ...createData.data?.createUserAccount }];
});
client.writeQuery({
@ -197,35 +208,41 @@ const AdminRoute = () => {
});
},
});
if (loading) {
return <GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />;
}
if (data && user) {
if (user.roles.org != 'admin') {
/*
TODO: add permision check
if (user.roles.org !== 'admin') {
return <Redirect to="/" />;
}
*/
return (
<>
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />
<GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
<Admin
initialTab={0}
users={data.users}
canInviteUser={user.roles.org == 'admin'}
onInviteUser={() => {}}
onUpdateUserPassword={(user, password) => {
invitedUsers={data.invitedUsers}
// canInviteUser={user.roles.org === 'admin'} TODO: add permision check
canInviteUser={true}
onInviteUser={NOOP}
onUpdateUserPassword={() => {
hidePopup();
}}
onDeleteInvitedUser={(invitedUserID) => {
deleteInvitedUser({ variables: { invitedUserID } });
hidePopup();
}}
onDeleteUser={(userID, newOwnerID) => {
deleteUser({ variables: { userID, newOwnerID } });
hidePopup();
}}
onAddUser={$target => {
onAddUser={($target) => {
showPopup(
$target,
<Popup tab={0} title="Add member" onClose={() => hidePopup()}>
<AddUserPopup
onAddUser={user => {
const { roleCode, ...userData } = user;
onAddUser={(u) => {
const { roleCode, ...userData } = u;
createUser({ variables: { ...userData, roleCode: roleCode.value } });
hidePopup();
}}
@ -237,7 +254,7 @@ const AdminRoute = () => {
</>
);
}
return <span>error</span>;
return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />;
};
export default AdminRoute;

View File

@ -126,4 +126,8 @@ export default createGlobalStyle`
}
${mixin.placeholderColor(color.textLight)}
.picker-hidden {
display: none;
}
`;

View File

@ -1,16 +1,19 @@
import React from 'react';
import {Router, Switch, Route} from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import { Switch, Route, useHistory, useLocation, Redirect } from 'react-router-dom';
import * as H from 'history';
import Dashboard from 'Dashboard';
import Admin from 'Admin';
import MyTasks from 'MyTasks';
import Confirm from 'Confirm';
import Projects from 'Projects';
import Project from 'Projects/Project';
import Teams from 'Teams';
import Login from 'Auth';
import Install from 'Install';
import Register from 'Register';
import Profile from 'Profile';
import styled from 'styled-components';
import { useCurrentUser } from 'App/context';
const MainContent = styled.div`
padding: 0 0 0 0;
@ -20,23 +23,68 @@ const MainContent = styled.div`
flex-direction: column;
flex-grow: 1;
`;
type RoutesProps = {
history: H.History;
type ValidateTokenResponse = {
valid: boolean;
userID: string;
};
const Routes = ({history}: RoutesProps) => (
const UserRequiredRoute: React.FC<any> = ({ children }) => {
const { user } = useCurrentUser();
const location = useLocation();
console.log('user required', user);
if (user) {
return children;
}
return (
<Redirect
to={{
pathname: '/login',
state: { redirect: location.pathname },
}}
/>
);
};
const Routes: React.FC = () => {
const [loading, setLoading] = useState(true);
const { setUser } = useCurrentUser();
useEffect(() => {
fetch('/auth/validate', {
method: 'POST',
credentials: 'include',
}).then(async (x) => {
const response: ValidateTokenResponse = await x.json();
const { valid, userID } = response;
if (valid) {
setUser(userID);
}
setLoading(false);
});
}, []);
console.log('loading', loading);
if (loading) return null;
return (
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/install" component={Install} />
<Route exact path="/register" component={Register} />
<Route exact path="/confirm" component={Confirm} />
<Switch>
<MainContent>
<Route path="/projects/:projectID" component={Project} />
<UserRequiredRoute>
<Route exact path="/" component={Dashboard} />
<Route exact path="/projects" component={Projects} />
<Route path="/projects/:projectID" component={Project} />
<Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
<Route path="/tasks" component={MyTasks} />
</UserRequiredRoute>
</MainContent>
</Switch>
);
</Switch>
);
};
export default Routes;

View File

@ -1,28 +1,30 @@
import { createGlobalStyle, DefaultTheme } from 'styled-components';
import { DefaultTheme } from 'styled-components';
import Color from 'color';
const theme: DefaultTheme = {
borderRadius: {
primary: '3px',
primary: '3x',
alternate: '6px',
},
colors: {
primary: '115, 103, 240',
secondary: '216, 93, 216',
alternate: '65, 69, 97',
success: '40, 199, 111',
danger: '234, 84, 85',
warning: '255, 159, 67',
dark: '30, 30, 30',
multiColors: ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'],
primary: 'rgb(115, 103, 240)',
secondary: 'rgb(216, 93, 216)',
alternate: 'rgb(65, 69, 97)',
success: 'rgb(40, 199, 111)',
danger: 'rgb(234, 84, 85)',
warning: 'rgb(255, 159, 67)',
dark: 'rgb(30, 30, 30)',
text: {
primary: '194, 198, 220',
secondary: '255, 255, 255',
primary: 'rgb(194, 198, 220)',
secondary: 'rgb(255, 255, 255)',
},
border: '65, 69, 97',
border: 'rgb(65, 69, 97)',
bg: {
primary: '16, 22, 58',
secondary: '38, 44, 73',
primary: 'rgb(16, 22, 58)',
secondary: 'rgb(38, 44, 73)',
},
},
};
export { theme };
export default theme;

35
frontend/src/App/Toast.ts Normal file
View File

@ -0,0 +1,35 @@
import styled from 'styled-components';
import { ToastContainer } from 'react-toastify';
const ToastedContainer = 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;
}
`;
export default ToastedContainer;

View File

@ -1,385 +0,0 @@
import React, { useState, useContext, useEffect } from 'react';
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
import styled from 'styled-components/macro';
import DropdownMenu, { ProfileMenu } from 'shared/components/DropdownMenu';
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { useHistory } from 'react-router';
import { UserContext, PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import {
RoleCode,
useMeQuery,
useDeleteProjectMutation,
useGetProjectsQuery,
GetProjectsDocument,
} from 'shared/generated/graphql';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { History } from 'history';
import produce from 'immer';
import { Link } from 'react-router-dom';
import MiniProfile from 'shared/components/MiniProfile';
import cache from 'App/cache';
const TeamContainer = styled.div`
display: flex;
flex-direction: column;
margin: 0 8px;
`;
const TeamTitle = styled.h3`
font-size: 14px;
font-weight: 700;
`;
const TeamProjects = styled.div`
display: flex;
flex-direction: column;
margin-top: 8px;
margin-bottom: 4px;
`;
const TeamProjectLink = styled(Link)`
display: flex;
font-weight: 700;
height: 36px;
overflow: hidden;
padding: 0;
position: relative;
text-decoration: none;
user-select: none;
`;
const TeamProjectBackground = styled.div<{ color: string }>`
background-image: url(null);
background-color: ${props => props.color};
background-size: cover;
background-position: 50%;
position: absolute;
width: 100%;
height: 36px;
opacity: 1;
border-radius: 3px;
&:before {
background: rgba(${props => props.theme.colors.bg.secondary});
bottom: 0;
content: '';
left: 0;
opacity: 0.88;
position: absolute;
right: 0;
top: 0;
}
`;
const TeamProjectAvatar = styled.div<{ color: string }>`
background-image: url(null);
background-color: ${props => props.color};
display: inline-block;
flex: 0 0 auto;
background-size: cover;
border-radius: 3px 0 0 3px;
height: 36px;
width: 36px;
position: relative;
opacity: 0.7;
`;
const TeamProjectContent = styled.div`
display: flex;
position: relative;
flex: 1;
width: 100%;
padding: 9px 0 9px 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TeamProjectTitle = styled.div`
font-weight: 700;
display: block;
padding-right: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TeamProjectContainer = styled.div`
box-sizing: border-box;
border-radius: 3px;
position: relative;
margin: 0 4px 4px 0;
min-width: 0;
&:hover ${TeamProjectTitle} {
color: rgba(${props => props.theme.colors.text.secondary});
}
&:hover ${TeamProjectAvatar} {
opacity: 1;
}
&:hover ${TeamProjectBackground}:before {
opacity: 0.78;
}
`;
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
const ProjectFinder = () => {
const { loading, data } = useGetProjectsQuery();
if (loading) {
return <span>loading</span>;
}
if (data) {
const { projects, teams, organizations } = data;
const projectTeams = teams.map(team => {
return {
id: team.id,
name: team.name,
projects: projects.filter(project => project.team.id === team.id),
};
});
return (
<>
{projectTeams.map(team => (
<TeamContainer key={team.id}>
<TeamTitle>{team.name}</TeamTitle>
<TeamProjects>
{team.projects.map((project, idx) => (
<TeamProjectContainer key={project.id}>
<TeamProjectLink to={`/projects/${project.id}`}>
<TeamProjectBackground color={colors[idx % 5]} />
<TeamProjectAvatar color={colors[idx % 5]} />
<TeamProjectContent>
<TeamProjectTitle>{project.name}</TeamProjectTitle>
</TeamProjectContent>
</TeamProjectLink>
</TeamProjectContainer>
))}
</TeamProjects>
</TeamContainer>
))}
</>
);
}
return <span>error</span>;
};
type ProjectPopupProps = {
history: History<History.PoorMansUnknown>;
name: string;
projectID: string;
};
export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, projectID }) => {
const { hidePopup, setTab } = usePopup();
const [deleteProject] = useDeleteProjectMutation({
update: (client, deleteData) => {
const cacheData: any = client.readQuery({
query: GetProjectsDocument,
});
const newData = produce(cacheData, (draftState: any) => {
draftState.projects = draftState.projects.filter(
(project: any) => project.id !== deleteData.data.deleteProject.project.id,
);
});
client.writeQuery({
query: GetProjectsDocument,
data: {
...newData,
},
});
},
});
return (
<>
<Popup title={null} tab={0}>
<ProjectSettings
onDeleteProject={() => {
setTab(1, 300);
}}
/>
</Popup>
<Popup title={`Delete the "${name}" project?`} tab={1}>
<DeleteConfirm
description={DELETE_INFO.DELETE_PROJECTS.description}
deletedItems={DELETE_INFO.DELETE_PROJECTS.deletedItems}
onConfirmDelete={() => {
if (projectID) {
deleteProject({ variables: { projectID } });
hidePopup();
history.push('/projects');
}
}}
/>
</Popup>
</>
);
};
type GlobalTopNavbarProps = {
nameOnly?: boolean;
projectID: string | null;
teamID?: string | null;
onChangeProjectOwner?: (userID: string) => void;
name: string | null;
currentTab?: number;
popupContent?: JSX.Element;
menuType?: Array<MenuItem>;
onChangeRole?: (userID: string, roleCode: RoleCode) => void;
projectMembers?: null | Array<TaskUser>;
onSaveProjectName?: (projectName: string) => void;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
onSetTab?: (tab: number) => void;
onRemoveFromBoard?: (userID: string) => void;
};
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
currentTab,
onSetTab,
menuType,
projectID,
teamID,
onChangeProjectOwner,
onChangeRole,
name,
popupContent,
projectMembers,
onInviteUser,
onSaveProjectName,
onRemoveFromBoard,
nameOnly,
}) => {
const { user, setUserRoles, setUser } = useCurrentUser();
const { loading, data } = useMeQuery({
onCompleted: data => {
if (user && user.roles) {
setUserRoles({
org: user.roles.org,
teams: data.me.teamRoles.reduce((map, obj) => {
map.set(obj.teamID, obj.roleCode);
return map;
}, new Map<string, string>()),
projects: data.me.projectRoles.reduce((map, obj) => {
map.set(obj.projectID, obj.roleCode);
return map;
}, new Map<string, string>()),
});
}
},
});
const { showPopup, hidePopup, setTab } = usePopup();
const history = useHistory();
const onLogout = () => {
fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 200) {
cache.reset();
history.replace('/login');
setUser(null);
hidePopup();
}
});
};
const onProfileClick = ($target: React.RefObject<HTMLElement>) => {
showPopup(
$target,
<Popup title={null} tab={0}>
<ProfileMenu
onLogout={onLogout}
showAdminConsole={user ? user.roles.org === 'admin' : false}
onAdminConsole={() => {
history.push('/admin');
hidePopup();
}}
onProfile={() => {
history.push('/profile');
hidePopup();
}}
/>
</Popup>,
195,
);
};
const onOpenSettings = ($target: React.RefObject<HTMLElement>) => {
if (popupContent) {
showPopup($target, popupContent, 185);
}
};
if (!user) {
return null;
}
const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
if (member) {
showPopup(
$targetRef,
<MiniProfile
warning={member.role && member.role.code === 'owner' ? warning : null}
canChangeRole={userIsTeamOrProjectAdmin}
onChangeRole={roleCode => {
if (onChangeRole) {
onChangeRole(member.id, roleCode);
}
}}
onRemoveFromBoard={
member.role && member.role.code === 'owner'
? undefined
: () => {
if (onRemoveFromBoard) {
onRemoveFromBoard(member.id);
}
}
}
user={member}
bio=""
/>,
);
}
};
return (
<>
<TopNavbar
name={name}
menuType={menuType}
onOpenProjectFinder={$target => {
showPopup(
$target,
<Popup tab={0} title={null}>
<ProjectFinder />
</Popup>,
);
}}
currentTab={currentTab}
user={data ? data.me.user : null}
canEditProjectName={userIsTeamOrProjectAdmin}
canInviteUser={userIsTeamOrProjectAdmin}
onMemberProfile={onMemberProfile}
onInviteUser={onInviteUser}
onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner}
onNotificationClick={() => {}}
onSetTab={onSetTab}
onRemoveFromBoard={onRemoveFromBoard}
onDashboardClick={() => {
history.push('/');
}}
projectMembers={projectMembers}
onProfileClick={onProfileClick}
onSaveName={onSaveProjectName}
onOpenSettings={onOpenSettings}
/>
</>
);
};
export default GlobalTopNavbar;

View File

@ -0,0 +1,237 @@
import React, { useState } from 'react';
import styled from 'styled-components/macro';
import { useGetProjectsQuery } from 'shared/generated/graphql';
import { Link } from 'react-router-dom';
import LoadingSpinner from 'shared/components/LoadingSpinner';
import ControlledInput from 'shared/components/ControlledInput';
import { CaretDown, CaretRight } from 'shared/icons';
import useStickyState from 'shared/hooks/useStickyState';
import { usePopup } from 'shared/components/PopupMenu';
const TeamContainer = styled.div`
display: flex;
flex-direction: column;
margin: 0 4px;
`;
const TeamTitle = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const TeamTitleText = styled.span`
font-size: 14px;
font-weight: 700;
`;
const TeamProjects = styled.div`
display: flex;
flex-direction: column;
margin-top: 8px;
margin-bottom: 4px;
`;
const TeamProjectLink = styled(Link)`
display: flex;
font-weight: 700;
height: 36px;
overflow: hidden;
padding: 0;
position: relative;
text-decoration: none;
user-select: none;
`;
const TeamProjectBackground = styled.div<{ idx: number }>`
background-image: url(null);
background-color: ${props => props.theme.colors.multiColors[props.idx % 5]};
background-size: cover;
background-position: 50%;
position: absolute;
width: 100%;
height: 36px;
opacity: 1;
border-radius: 3px;
&:before {
background: ${props => props.theme.colors.bg.secondary};
bottom: 0;
content: '';
left: 0;
opacity: 0.88;
position: absolute;
right: 0;
top: 0;
}
`;
const Empty = styled.div`
display: flex;
align-items: center;
justify-content: center;
`;
const TeamProjectAvatar = styled.div<{ idx: number }>`
background-image: url(null);
background-color: ${props => props.theme.colors.multiColors[props.idx % 5]};
display: inline-block;
flex: 0 0 auto;
background-size: cover;
border-radius: 3px 0 0 3px;
height: 36px;
width: 36px;
position: relative;
opacity: 0.7;
`;
const TeamProjectContent = styled.div`
display: flex;
position: relative;
flex: 1;
width: 100%;
padding: 9px 0 9px 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TeamProjectTitle = styled.div`
font-weight: 700;
display: block;
padding-right: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TeamProjectContainer = styled.div`
box-sizing: border-box;
border-radius: 3px;
position: relative;
margin: 0 4px 4px 0;
min-width: 0;
&:hover ${TeamProjectTitle} {
color: ${props => props.theme.colors.text.secondary};
}
&:hover ${TeamProjectAvatar} {
opacity: 1;
}
&:hover ${TeamProjectBackground}:before {
opacity: 0.78;
}
`;
const Search = styled(ControlledInput)`
margin: 0 4px 4px 4px;
& input {
width: 100%;
}
`;
const Minify = styled.div`
height: 28px;
min-height: 28px;
min-width: 28px;
width: 28px;
border-radius: 6px;
user-select: none;
margin-right: 4px;
align-items: center;
box-sizing: border-box;
display: inline-flex;
justify-content: center;
transition-duration: 0.2s;
transition-property: background, border, box-shadow, fill;
cursor: pointer;
svg {
fill: ${props => props.theme.colors.text.primary};
transition-duration: 0.2s;
transition-property: background, border, box-shadow, fill;
}
&:hover svg {
fill: ${props => props.theme.colors.text.secondary};
}
`;
const ProjectFinder = () => {
const { data } = useGetProjectsQuery({ fetchPolicy: 'cache-and-network' });
const [search, setSearch] = useState('');
const [minified, setMinified] = useStickyState<Array<string>>([], 'project_finder_minified');
const { hidePopup } = usePopup();
if (data) {
const { teams } = data;
const projects = data.projects.filter(p => {
if (search.trim() === '') return true;
return p.name.toLowerCase().startsWith(search.trim().toLowerCase());
});
const personalProjects = projects.filter(p => p.team === null);
const projectTeams = [
{ id: 'personal', name: 'Personal', projects: personalProjects.sort((a, b) => a.name.localeCompare(b.name)) },
...teams.map(team => {
return {
id: team.id,
name: team.name,
projects: projects
.filter(project => project.team && project.team.id === team.id)
.sort((a, b) => a.name.localeCompare(b.name)),
};
}),
];
return (
<>
<Search
autoFocus
variant="alternate"
value={search}
onChange={e => setSearch(e.currentTarget.value)}
placeholder="Find projects by name..."
/>
{projectTeams.map(team => {
const isMinified = minified.find(m => m === team.id);
if (team.projects.length === 0) return null;
return (
<TeamContainer key={team.id}>
<TeamTitle>
<TeamTitleText>{team.name}</TeamTitleText>
{isMinified ? (
<Minify onClick={() => setMinified(prev => prev.filter(m => m !== team.id))}>
<CaretRight width={16} height={16} />
</Minify>
) : (
<Minify onClick={() => setMinified(prev => [...prev, team.id])}>
<CaretDown width={16} height={16} />
</Minify>
)}
</TeamTitle>
{!isMinified && (
<TeamProjects>
{team.projects.map((project, idx) => (
<TeamProjectContainer key={project.id}>
<TeamProjectLink onClick={e => hidePopup()} to={`/projects/${project.id}`}>
<TeamProjectBackground idx={idx} />
<TeamProjectAvatar idx={idx} />
<TeamProjectContent>
<TeamProjectTitle>{project.name}</TeamProjectTitle>
</TeamProjectContent>
</TeamProjectLink>
</TeamProjectContainer>
))}
</TeamProjects>
)}
</TeamContainer>
);
})}
</>
);
}
return (
<Empty>
<LoadingSpinner />
</Empty>
);
};
export default ProjectFinder;

View File

@ -0,0 +1,89 @@
import React, { useState } from 'react';
import ProjectSettings, { DeleteConfirm, DELETE_INFO, PublicConfirm } from 'shared/components/ProjectSettings';
import {
useDeleteProjectMutation,
GetProjectsDocument,
useToggleProjectVisibilityMutation,
} from 'shared/generated/graphql';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer';
type ProjectPopupProps = {
history: any;
name: string;
publicOn: string | null;
projectID: string;
};
const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, projectID, publicOn: initialPublicOn }) => {
const { hidePopup, setTab } = usePopup();
const [publicOn, setPublicOn] = useState(initialPublicOn);
const [toggleProjectVisibility] = useToggleProjectVisibilityMutation({
onCompleted: data => {
setPublicOn(data.toggleProjectVisibility.project.publicOn);
},
});
const [deleteProject] = useDeleteProjectMutation({
update: (client, deleteData) => {
const cacheData: any = client.readQuery({
query: GetProjectsDocument,
});
const newData = produce(cacheData, (draftState: any) => {
draftState.projects = draftState.projects.filter(
(project: any) => project.id !== deleteData.data?.deleteProject.project.id,
);
});
client.writeQuery({
query: GetProjectsDocument,
data: {
...newData,
},
});
},
});
return (
<>
<Popup title={null} tab={0}>
<ProjectSettings
publicOn={publicOn}
onToggleProjectVisible={visible => {
if (visible) {
setTab(2, { width: 300 });
} else {
toggleProjectVisibility({ variables: { projectID, isPublic: false } });
}
}}
onDeleteProject={() => {
setTab(1, { width: 300 });
}}
/>
</Popup>
<Popup title="Change to public?" tab={1}>
<PublicConfirm
onConfirm={() => {
if (projectID) {
toggleProjectVisibility({ variables: { projectID, isPublic: true } });
}
}}
/>
</Popup>
<Popup title={`Delete the "${name}" project?`} tab={1}>
<DeleteConfirm
description={DELETE_INFO.DELETE_PROJECTS.description}
deletedItems={DELETE_INFO.DELETE_PROJECTS.deletedItems}
onConfirmDelete={() => {
if (projectID) {
deleteProject({ variables: { projectID } });
hidePopup();
history.push('/projects');
}
}}
/>
</Popup>
</>
);
};
export default ProjectPopup;

View File

@ -0,0 +1,262 @@
import React from 'react';
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
import LoggedOutNavbar from 'shared/components/TopNavbar/LoggedOut';
import { ProfileMenu } from 'shared/components/DropdownMenu';
import { useHistory, useRouteMatch } from 'react-router';
import { useCurrentUser } from 'App/context';
import { RoleCode, useTopNavbarQuery } from 'shared/generated/graphql';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import MiniProfile from 'shared/components/MiniProfile';
import cache from 'App/cache';
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
import theme from 'App/ThemeStyles';
import ProjectFinder from './ProjectFinder';
// TODO: Move to context based navbar?
type GlobalTopNavbarProps = {
nameOnly?: boolean;
projectID: string | null;
teamID?: string | null;
onChangeProjectOwner?: (userID: string) => void;
name: string | null;
currentTab?: number;
popupContent?: JSX.Element;
menuType?: Array<MenuItem>;
onChangeRole?: (userID: string, roleCode: RoleCode) => void;
projectMembers?: null | Array<TaskUser>;
projectInvitedMembers?: null | Array<InvitedUser>;
onSaveProjectName?: (projectName: string) => void;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
onSetTab?: (tab: number) => void;
onRemoveFromBoard?: (userID: string) => void;
onRemoveInvitedFromBoard?: (email: string) => void;
};
const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
currentTab,
onSetTab,
menuType,
teamID,
onChangeProjectOwner,
onChangeRole,
name,
popupContent,
projectMembers,
projectInvitedMembers,
onInviteUser,
onSaveProjectName,
onRemoveInvitedFromBoard,
onRemoveFromBoard,
}) => {
const { data } = useTopNavbarQuery();
const { showPopup, hidePopup } = usePopup();
const { setUser } = useCurrentUser();
const history = useHistory();
const onLogout = () => {
fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 200) {
cache.reset();
history.replace('/login');
setUser(null);
hidePopup();
}
});
};
const onProfileClick = ($target: React.RefObject<HTMLElement>) => {
showPopup(
$target,
<Popup title={null} tab={0}>
<ProfileMenu
onLogout={onLogout}
showAdminConsole // TODO: add permision check
onAdminConsole={() => {
history.push('/admin');
hidePopup();
}}
onProfile={() => {
history.push('/profile');
hidePopup();
}}
/>
</Popup>,
{ width: 195 },
);
};
const onOpenSettings = ($target: React.RefObject<HTMLElement>) => {
if (popupContent) {
showPopup($target, popupContent, { width: 185 });
}
};
const onNotificationClick = ($target: React.RefObject<HTMLElement>) => {
if (data) {
showPopup(
$target,
<NotificationPopup>
{data.notifications.map(notification => (
<NotificationItem
title={notification.entity.name}
description={`${notification.actor.name} added you as a meber to the task "${notification.entity.name}"`}
createdAt={notification.createdAt}
/>
))}
</NotificationPopup>,
{ width: 415, borders: false, diamondColor: theme.colors.primary },
);
}
};
// TODO: readd permision check
// const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
const userIsTeamOrProjectAdmin = true;
const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => {
const member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null;
if (member) {
showPopup(
$targetRef,
<MiniProfile
onRemoveFromBoard={() => {
if (onRemoveInvitedFromBoard) {
onRemoveInvitedFromBoard(member.email);
}
}}
invited
user={{
id: member.email,
fullName: member.email,
bio: 'Invited',
profileIcon: {
bgColor: '#000',
url: null,
initials: member.email.charAt(0),
},
}}
bio=""
/>,
);
}
};
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
if (member) {
showPopup(
$targetRef,
<MiniProfile
warning={member.role && member.role.code === 'owner' ? warning : null}
canChangeRole={userIsTeamOrProjectAdmin}
onChangeRole={roleCode => {
if (onChangeRole) {
onChangeRole(member.id, roleCode);
}
}}
onRemoveFromBoard={
member.role && member.role.code === 'owner'
? undefined
: () => {
if (onRemoveFromBoard) {
onRemoveFromBoard(member.id);
}
}
}
user={member}
bio=""
/>,
);
}
};
const user = data ? data.me?.user : null;
return (
<>
<TopNavbar
name={name}
menuType={menuType}
onOpenProjectFinder={$target => {
showPopup(
$target,
<Popup tab={0} title={null}>
<ProjectFinder />
</Popup>,
);
}}
currentTab={currentTab}
user={user ?? null}
canEditProjectName={userIsTeamOrProjectAdmin}
canInviteUser={userIsTeamOrProjectAdmin}
onMemberProfile={onMemberProfile}
onInvitedMemberProfile={onInvitedMemberProfile}
onInviteUser={onInviteUser}
onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner}
onNotificationClick={onNotificationClick}
onSetTab={onSetTab}
onRemoveFromBoard={onRemoveFromBoard}
onDashboardClick={() => {
history.push('/');
}}
onMyTasksClick={() => {
history.push('/tasks');
}}
projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
onProfileClick={onProfileClick}
onSaveName={onSaveProjectName}
onOpenSettings={onOpenSettings}
/>
</>
);
};
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
currentTab,
onSetTab,
menuType,
teamID,
onChangeProjectOwner,
onChangeRole,
name,
popupContent,
projectMembers,
projectInvitedMembers,
onInviteUser,
onSaveProjectName,
onRemoveInvitedFromBoard,
onRemoveFromBoard,
}) => {
const { user } = useCurrentUser();
const match = useRouteMatch();
if (user) {
return (
<LoggedInNavbar
currentTab={currentTab}
projectID={null}
onSetTab={onSetTab}
menuType={menuType}
teamID={teamID}
onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner}
name={name}
popupContent={popupContent}
projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
onInviteUser={onInviteUser}
onSaveProjectName={onSaveProjectName}
onRemoveInvitedFromBoard={onRemoveInvitedFromBoard}
onRemoveFromBoard={onRemoveFromBoard}
/>
);
}
return <LoggedOutNavbar match={match.url} name={name} menuType={menuType} />;
};
export default GlobalTopNavbar;

View File

@ -1,4 +1,4 @@
import { InMemoryCache } from 'apollo-cache-inmemory';
import { InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache();

View File

@ -1,79 +1,20 @@
import React, { useContext } from 'react';
export enum PermissionLevel {
ORG,
TEAM,
PROJECT,
}
export enum PermissionObjectType {
ORG,
TEAM,
PROJECT,
TASK,
}
export type CurrentUserRoles = {
org: string;
teams: Map<string, string>;
projects: Map<string, string>;
};
export interface CurrentUserRaw {
id: string;
roles: CurrentUserRoles;
}
type UserContextState = {
user: CurrentUserRaw | null;
setUser: (user: CurrentUserRaw | null) => void;
setUserRoles: (roles: CurrentUserRoles) => void;
user: string | null;
setUser: (user: string | null) => void;
};
export const UserContext = React.createContext<UserContextState>({
user: null,
setUser: _user => null,
setUserRoles: roles => null,
});
export interface CurrentUser extends CurrentUserRaw {
isAdmin: (level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) => boolean;
isVisible: (level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) => boolean;
}
export const useCurrentUser = () => {
const { user, setUser, setUserRoles } = useContext(UserContext);
let currentUser: CurrentUser | null = null;
if (user) {
currentUser = {
...user,
isAdmin(level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) {
if (user.roles.org === 'admin') {
return true;
}
switch (level) {
case PermissionLevel.TEAM:
return subjectID ? this.roles.teams.get(subjectID) === 'admin' : false;
default:
return false;
}
},
isVisible(level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) {
if (user.roles.org === 'admin') {
return true;
}
switch (level) {
case PermissionLevel.TEAM:
return subjectID ? this.roles.teams.get(subjectID) !== null : false;
default:
return false;
}
},
};
}
const { user, setUser } = useContext(UserContext);
return {
user: currentUser,
user,
setUser,
setUserRoles,
};
};

View File

@ -0,0 +1,6 @@
@font-face {
font-family: 'Open Sans';
src: url(../shared/fonts/OpenSans-Regular.ttf) format('truetype');
/* other formats include: 'woff2', 'truetype, 'opentype',
'embedded-opentype', and 'svg' */
}

View File

@ -1,77 +1,43 @@
import React, { useState, useEffect } from 'react';
import jwtDecode from 'jwt-decode';
import { createBrowserHistory } from 'history';
import { Router } from 'react-router';
import { BrowserRouter } from 'react-router-dom';
import { PopupProvider } from 'shared/components/PopupMenu';
import { setAccessToken } from 'shared/utils/accessToken';
import styled, { ThemeProvider } from 'styled-components';
import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles';
import { theme } from './ThemeStyles';
import theme from './ThemeStyles';
import Routes from './Routes';
import { UserContext, CurrentUserRaw, CurrentUserRoles, PermissionLevel, PermissionObjectType } from './context';
import ToastedContainer from './Toast';
import { UserContext } from './context';
const history = createBrowserHistory();
type RefreshTokenResponse = {
accessToken: string;
isInstalled: boolean;
};
import 'react-toastify/dist/ReactToastify.css';
import './fonts.css';
const App = () => {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<CurrentUserRaw | null>(null);
const setUserRoles = (roles: CurrentUserRoles) => {
if (user) {
setUser({
...user,
roles,
});
}
};
useEffect(() => {
fetch('/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
if (!isInstalled) {
history.replace('/install');
}
}
setLoading(false);
});
}, []);
const [user, setUser] = useState<string | null>(null);
return (
<>
<UserContext.Provider value={{ user, setUser, setUserRoles }}>
<UserContext.Provider value={{ user, setUser }}>
<ThemeProvider theme={theme}>
<NormalizeStyles />
<BaseStyles />
<Router history={history}>
<BrowserRouter>
<PopupProvider>
{loading ? (
<div>loading</div>
) : (
<>
<Routes history={history} />
</>
)}
<Routes />
</PopupProvider>
</Router>
</BrowserRouter>
<ToastedContainer
position="bottom-right"
autoClose={5000}
hideProgressBar
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
limit={5}
/>
</ThemeProvider>
</UserContext.Provider>
</>

View File

@ -1,18 +1,15 @@
import React, { useState, useEffect, useContext } from 'react';
import { useForm } from 'react-hook-form';
import { useHistory } from 'react-router';
import { setAccessToken } from 'shared/utils/accessToken';
import { useHistory, useLocation } from 'react-router';
import Login from 'shared/components/Login';
import UserContext from 'App/context';
import { Container, LoginWrapper } from './Styles';
import UserContext, { PermissionLevel, PermissionObjectType } from 'App/context';
import JwtDecode from 'jwt-decode';
const Auth = () => {
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
const history = useHistory();
const location = useLocation<{ redirect: string } | undefined>();
const { setUser } = useContext(UserContext);
console.log('auth');
const login = (
data: LoginFormData,
setComplete: (val: boolean) => void,
@ -25,7 +22,7 @@ const Auth = () => {
username: data.username,
password: data.password,
}),
}).then(async x => {
}).then(async (x) => {
if (x.status === 401) {
setInvalidLoginAttempt(invalidLoginAttempt + 1);
setError('username', { type: 'error', message: 'Invalid username' });
@ -33,28 +30,26 @@ const Auth = () => {
setComplete(true);
} else {
const response = await x.json();
const { accessToken } = response;
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setComplete(true);
setAccessToken(accessToken);
const { userID } = response;
setUser(userID);
if (location.state && location.state.redirect) {
history.push(location.state.redirect);
} else {
history.push('/');
}
}
});
};
useEffect(() => {
fetch('/auth/refresh_token', {
fetch('/auth/validate', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 200) {
}).then(async (x) => {
const response = await x.json();
const { valid, userID } = response;
if (valid) {
setUser(userID);
history.replace('/projects');
}
});

View File

@ -0,0 +1,46 @@
import React from 'react';
import Confirm from 'shared/components/Confirm';
import { useHistory, useLocation } from 'react-router';
import * as QueryString from 'query-string';
import { useCurrentUser } from 'App/context';
import { Container, LoginWrapper } from './Styles';
const UsersConfirm = () => {
const history = useHistory();
const location = useLocation();
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 { userID } = response;
setUser(userID);
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,145 @@
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';
import { MyTasksSort } from 'shared/generated/graphql';
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 MyTasksSortProps = {
sort: MyTasksSort;
onChangeSort: (sort: MyTasksSort) => void;
};
const MyTasksSortPopup: React.FC<MyTasksSortProps> = ({ sort: initialSort, onChangeSort }) => {
const [sort, setSort] = useState(initialSort);
const handleChangeSort = (f: MyTasksSort) => {
setSort(f);
onChangeSort(f);
};
return (
<>
<Popup tab={0} title={null}>
<ActionsList>
<ActionItem onClick={() => handleChangeSort(MyTasksSort.None)}>
{sort === MyTasksSort.None && <ActiveIcon width={16} height={16} />}
<ActionTitle>None</ActionTitle>
</ActionItem>
<ActionItem onClick={() => handleChangeSort(MyTasksSort.Project)}>
{sort === MyTasksSort.Project && <ActiveIcon width={16} height={16} />}
<ActionTitle>Project</ActionTitle>
</ActionItem>
<ActionItem onClick={() => handleChangeSort(MyTasksSort.DueDate)}>
{sort === MyTasksSort.DueDate && <ActiveIcon width={16} height={16} />}
<ActionTitle>Due Date</ActionTitle>
</ActionItem>
</ActionsList>
</Popup>
</>
);
};
export default MyTasksSortPopup;

View File

@ -0,0 +1,151 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { Checkmark } from 'shared/icons';
import { TaskStatusFilter, TaskStatus, TaskSince } from 'shared/components/Lists';
import { MyTasksStatus } from 'shared/generated/graphql';
import { Popup } from 'shared/components/PopupMenu';
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: ${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 MyTasksStatusProps = {
status: MyTasksStatus;
onChangeStatus: (status: MyTasksStatus) => void;
};
const MyTasksStatusPopup: React.FC<MyTasksStatusProps> = ({ status: initialStatus, onChangeStatus }) => {
const [status, setStatus] = useState(initialStatus);
const handleStatusChange = (f: MyTasksStatus) => {
setStatus(f);
onChangeStatus(f);
};
return (
<Popup tab={0} title={null}>
<ActionsList>
<ActionItem onClick={() => handleStatusChange(MyTasksStatus.Incomplete)}>
{status === MyTasksStatus.Incomplete && <ActiveIcon width={12} height={12} />}
<ActionTitle>Incomplete Tasks</ActionTitle>
</ActionItem>
<ActionItem>
{status !== MyTasksStatus.Incomplete && status !== MyTasksStatus.All && <ActiveIcon width={12} height={12} />}
<ActionTitle>Compelete Tasks</ActionTitle>
<ActionExtraMenuContainer>
<ActionExtraMenu>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteAll)}>
{status === MyTasksStatus.CompleteAll && <ActiveIcon width={12} height={12} />}
<ActionTitle>All completed tasks</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuSeparator>Marked complete since</ActionExtraMenuSeparator>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteToday)}>
{status === MyTasksStatus.CompleteToday && <ActiveIcon width={12} height={12} />}
<ActionTitle>Today</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteYesterday)}>
{status === MyTasksStatus.CompleteYesterday && <ActiveIcon width={12} height={12} />}
<ActionTitle>Yesterday</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteOneWeek)}>
{status === MyTasksStatus.CompleteOneWeek && <ActiveIcon width={12} height={12} />}
<ActionTitle>1 week</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteTwoWeek)}>
{status === MyTasksStatus.CompleteTwoWeek && <ActiveIcon width={12} height={12} />}
<ActionTitle>2 weeks</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteThreeWeek)}>
{status === MyTasksStatus.CompleteThreeWeek && <ActiveIcon width={12} height={12} />}
<ActionTitle>3 weeks</ActionTitle>
</ActionExtraMenuItem>
</ActionExtraMenu>
</ActionExtraMenuContainer>
</ActionItem>
<ActionItem onClick={() => handleStatusChange(MyTasksStatus.All)}>
{status === MyTasksStatus.All && <ActiveIcon width={12} height={12} />}
<ActionTitle>All Tasks</ActionTitle>
</ActionItem>
</ActionsList>
</Popup>
);
};
export default MyTasksStatusPopup;

View File

@ -0,0 +1,415 @@
import React, { useState, useRef, useEffect } from 'react';
import styled, { css } from 'styled-components/macro';
import dayjs from 'dayjs';
import { CheckCircleOutline, CheckCircle, Cross, Briefcase, ChevronRight } from 'shared/icons';
import { mixin } from 'shared/utils/styles';
const RIGHT_ROW_WIDTH = 327;
const TaskName = styled.div<{ focused: boolean }>`
flex: 0 1 auto;
min-width: 1px;
overflow: hidden;
margin-right: 4px;
background: transparent;
border: 1px solid transparent;
border-radius: 2px;
height: 20px;
padding: 0 1px;
max-height: 100%;
position: relative;
&:hover {
${props =>
!props.focused &&
css`
border-color: #9ca6af !important;
border: 1px solid ${props.theme.colors.primary} !important;
`}
}
`;
const DueDateCell = styled.div`
align-items: center;
align-self: stretch;
display: flex;
flex-grow: 1;
`;
const CellPlaceholder = styled.div<{ width: number }>`
min-width: ${p => p.width}px;
width: ${p => p.width}px;
`;
const DueDateCellDisplay = styled.div`
align-items: center;
cursor: pointer;
display: flex;
flex-grow: 1;
height: 100%;
`;
const DueDateCellLabel = styled.div`
align-items: center;
color: ${props => props.theme.colors.text.primary};
font-size: 11px;
flex: 0 1 auto;
min-width: 1px;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
flex-flow: row wrap;
white-space: pre-wrap;
`;
const DueDateRemoveButton = styled.div`
align-items: center;
bottom: 0;
cursor: pointer;
display: flex;
height: 100%;
padding-left: 4px;
padding-right: 8px;
position: absolute;
right: 0;
top: 0;
visibility: hidden;
svg {
fill: ${props => props.theme.colors.text.primary};
}
&:hover svg {
fill: ${props => props.theme.colors.text.secondary};
}
`;
const TaskGroupItemCell = styled.div<{ width: number; focused: boolean }>`
width: ${p => p.width}px;
background: transparent;
position: relative;
border: 1px solid #414561;
justify-content: space-between;
margin-right: -1px;
z-index: 0;
padding: 0 8px;
align-items: center;
display: flex;
height: 37px;
overflow: hidden;
&:hover ${DueDateRemoveButton} {
visibility: visible;
}
&:hover ${TaskName} {
${props =>
!props.focused &&
css`
background: ${props.theme.colors.bg.secondary};
border: 1px solid ${mixin.darken(props.theme.colors.bg.secondary, 0.25)};
border-radius: 2px;
cursor: text;
`}
}
`;
const TaskGroupItem = styled.div`
padding-right: 24px;
contain: style;
display: flex;
margin-bottom: -1px;
margin-top: -1px;
height: 37px;
&:hover {
background-color: #161d31;
}
& ${TaskGroupItemCell}:first-child {
position: absolute;
padding: 0 4px 0 0;
margin-left: 24px;
left: 0;
flex: 1 1 auto;
min-width: 1px;
border-right: 0;
border-left: 0;
}
& ${TaskGroupItemCell}:last-child {
border-right: 0;
}
`;
const TaskItemComplete = styled.div`
flex: 0 0 auto;
margin: 0 3px 0 0;
align-items: center;
box-sizing: border-box;
display: inline-flex;
height: 16px;
justify-content: center;
overflow: visible;
width: 16px;
cursor: pointer;
svg {
transition: all 0.2 ease;
}
&:hover svg {
fill: ${props => props.theme.colors.primary};
}
`;
const TaskDetailsButton = styled.div`
align-items: center;
cursor: pointer;
display: flex;
font-size: 12px;
height: 100%;
justify-content: flex-end;
margin-left: auto;
opacity: 0;
padding-left: 4px;
color: ${props => props.theme.colors.text.primary};
svg {
fill: ${props => props.theme.colors.text.primary};
}
`;
const TaskDetailsArea = styled.div`
align-items: center;
cursor: pointer;
display: flex;
flex: 1 0 auto;
height: 100%;
margin-right: 24px;
&:hover ${TaskDetailsButton} {
opacity: 1;
}
`;
const TaskDetailsWorkpace = styled(Briefcase)`
flex: 0 0 auto;
margin-right: 8px;
`;
const TaskDetailsLabel = styled.div`
display: flex;
align-items: center;
`;
const TaskDetailsChevron = styled(ChevronRight)`
margin-left: 4px;
flex: 0 0 auto;
`;
const TaskNameShadow = styled.div`
box-sizing: border-box;
min-height: 1em;
overflow: hidden;
visibility: hidden;
white-space: pre;
border: 0;
font-size: 13px;
line-height: 20px;
margin: 0;
min-width: 20px;
padding: 0 4px;
text-rendering: optimizeSpeed;
`;
const TaskNameInput = styled.textarea`
white-space: pre;
background: transparent;
border-radius: 0;
display: block;
color: ${props => props.theme.colors.text.primary};
height: 100%;
outline: 0;
overflow: hidden;
position: absolute;
resize: none;
top: 0;
width: 100%;
border: 0;
font-size: 13px;
line-height: 20px;
margin: 0;
min-width: 20px;
padding: 0 4px;
text-rendering: optimizeSpeed;
`;
const ProjectPill = styled.div`
background-color: ${props => props.theme.colors.bg.primary};
text-overflow: ellipsis;
border-radius: 10px;
box-sizing: border-box;
display: block;
font-size: 12px;
font-weight: 400;
height: 20px;
line-height: 20px;
overflow: hidden;
padding: 0 8px;
text-align: left;
white-space: nowrap;
`;
const ProjectPillContents = styled.div`
align-items: center;
display: flex;
`;
const ProjectPillName = styled.span`
flex: 0 1 auto;
min-width: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: ${props => props.theme.colors.text.primary};
`;
const ProjectPillColor = styled.svg`
overflow: hidden;
flex: 0 0 auto;
margin-right: 4px;
fill: #0064fb;
height: 12px;
width: 12px;
`;
type TaskEntryProps = {
name: string;
dueDate?: string | null;
onEditName: (name: string) => void;
project: string;
hasTime: boolean;
autoFocus?: boolean;
onEditProject: ($target: React.RefObject<HTMLElement>) => void;
onToggleComplete: (complete: boolean) => void;
complete: boolean;
onEditDueDate: ($target: React.RefObject<HTMLElement>) => void;
onTaskDetails: () => void;
onRemoveDueDate: () => void;
};
const TaskEntry: React.FC<TaskEntryProps> = ({
autoFocus = false,
onToggleComplete,
onEditName,
onTaskDetails,
name: initialName,
complete,
project,
dueDate,
hasTime,
onEditProject,
onEditDueDate,
onRemoveDueDate,
}) => {
const leftRow = window.innerWidth - RIGHT_ROW_WIDTH;
const [focused, setFocused] = useState(autoFocus);
const [name, setName] = useState(initialName);
useEffect(() => {
setName(initialName);
}, [initialName]);
const $projects = useRef<HTMLDivElement>(null);
const $dueDate = useRef<HTMLDivElement>(null);
const $nameInput = useRef<HTMLTextAreaElement>(null);
return (
<TaskGroupItem>
<TaskGroupItemCell focused={focused} width={leftRow}>
<TaskItemComplete onClick={() => onToggleComplete(!complete)}>
{complete ? <CheckCircle width={16} height={16} /> : <CheckCircleOutline width={16} height={16} />}
</TaskItemComplete>
<TaskName focused={focused}>
<TaskNameShadow>{name}</TaskNameShadow>
<TaskNameInput
autoFocus={autoFocus}
onFocus={() => setFocused(true)}
ref={$nameInput}
onBlur={() => {
setFocused(false);
onEditName(name);
}}
onKeyDown={e => {
if (e.keyCode === 13) {
e.preventDefault();
if ($nameInput.current) {
$nameInput.current.blur();
}
}
}}
onChange={e => setName(e.currentTarget.value)}
wrap="off"
value={name}
rows={1}
/>
</TaskName>
<TaskDetailsArea onClick={() => onTaskDetails()}>
<TaskDetailsButton>
<TaskDetailsWorkpace width={16} height={16} />
<TaskDetailsLabel>
Details
<TaskDetailsChevron width={12} height={12} />
</TaskDetailsLabel>
</TaskDetailsButton>
</TaskDetailsArea>
</TaskGroupItemCell>
<CellPlaceholder width={leftRow} />
<TaskGroupItemCell width={120} focused={false} ref={$dueDate}>
<DueDateCell onClick={() => onEditDueDate($dueDate)}>
<DueDateCellDisplay>
<DueDateCellLabel>
{dueDate ? dayjs(dueDate).format(hasTime ? 'MMM D [at] h:mm A' : 'MMM D') : ''}
</DueDateCellLabel>
</DueDateCellDisplay>
</DueDateCell>
{dueDate && (
<DueDateRemoveButton onClick={() => onRemoveDueDate()}>
<Cross width={12} height={12} />
</DueDateRemoveButton>
)}
</TaskGroupItemCell>
<TaskGroupItemCell width={120} focused={false} ref={$projects}>
<ProjectPill
onClick={() => {
onEditProject($projects);
}}
>
<ProjectPillContents>
<ProjectPillColor viewBox="0 0 24 24" focusable={false}>
<path d="M10.4,4h3.2c2.2,0,3,0.2,3.9,0.7c0.8,0.4,1.5,1.1,1.9,1.9s0.7,1.6,0.7,3.9v3.2c0,2.2-0.2,3-0.7,3.9c-0.4,0.8-1.1,1.5-1.9,1.9s-1.6,0.7-3.9,0.7h-3.2c-2.2,0-3-0.2-3.9-0.7c-0.8-0.4-1.5-1.1-1.9-1.9c-0.4-1-0.6-1.8-0.6-4v-3.2c0-2.2,0.2-3,0.7-3.9C5.1,5.7,5.8,5,6.6,4.6C7.4,4.2,8.2,4,10.4,4z" />
</ProjectPillColor>
<ProjectPillName>{project}</ProjectPillName>
</ProjectPillContents>
</ProjectPill>
</TaskGroupItemCell>
<TaskGroupItemCell width={50} focused={false} />
</TaskGroupItem>
);
};
export default TaskEntry;
type NewTaskEntryProps = {
onClick: () => void;
};
const AddTaskLabel = styled.span`
font-size: 14px;
position: relative;
color: ${props => props.theme.colors.text.primary};
justify-content: space-between;
z-index: 0;
padding: 0 8px;
align-items: center;
display: flex;
height: 37px;
flex: 1 1;
cursor: pointer;
margin-left: 24px;
`;
const NewTaskEntry: React.FC<NewTaskEntryProps> = ({ onClick }) => {
const leftRow = window.innerWidth - RIGHT_ROW_WIDTH;
return (
<TaskGroupItem>
<AddTaskLabel onClick={onClick}>Add task...</AddTaskLabel>
</TaskGroupItem>
);
};
export { NewTaskEntry };

View File

@ -0,0 +1,913 @@
import React, { useState, useEffect, useRef } from 'react';
import styled, { css } from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import Details from 'Projects/Project/Details';
import {
useMyTasksQuery,
MyTasksSort,
MyTasksStatus,
useCreateTaskMutation,
MyTasksQuery,
MyTasksDocument,
useUpdateTaskNameMutation,
useSetTaskCompleteMutation,
useUpdateTaskDueDateMutation,
} from 'shared/generated/graphql';
import { Route, useRouteMatch, useHistory, RouteComponentProps } from 'react-router-dom';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import NOOP from 'shared/utils/noop';
import { Sort, Cogs, CaretDown, CheckCircle, CaretRight, CheckCircleOutline } from 'shared/icons';
import Select from 'react-select';
import { editorColourStyles } from 'shared/components/Select';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import DueDateManager from 'shared/components/DueDateManager';
import dayjs from 'dayjs';
import useStickyState from 'shared/hooks/useStickyState';
import MyTasksSortPopup from './MyTasksSort';
import MyTasksStatusPopup from './MyTasksStatus';
import TaskEntry from './TaskEntry';
import { StaticContext } from 'react-router';
type TaskRouteProps = {
taskID: string;
};
function prettyStatus(status: MyTasksStatus) {
switch (status) {
case MyTasksStatus.All:
return 'All tasks';
case MyTasksStatus.Incomplete:
return 'Incomplete tasks';
case MyTasksStatus.CompleteAll:
return 'All completed tasks';
case MyTasksStatus.CompleteToday:
return 'Completed tasks: today';
case MyTasksStatus.CompleteYesterday:
return 'Completed tasks: yesterday';
case MyTasksStatus.CompleteOneWeek:
return 'Completed tasks: 1 week';
case MyTasksStatus.CompleteTwoWeek:
return 'Completed tasks: 2 weeks';
case MyTasksStatus.CompleteThreeWeek:
return 'Completed tasks: 3 weeks';
default:
return 'unknown tasks';
}
}
function prettySort(sort: MyTasksSort) {
if (sort === MyTasksSort.None) {
return 'Sort';
}
return `Sort: ${sort.charAt(0) + sort.slice(1).toLowerCase().replace(/_/gi, ' ')}`;
}
type Group = {
id: string;
name: string | null;
tasks: Array<Task>;
};
const DueDateEditorLabel = styled.div`
align-items: center;
color: ${(props) => props.theme.colors.text.primary};
font-size: 11px;
padding: 0 8px;
flex: 0 1 auto;
min-width: 1px;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
flex-flow: row wrap;
white-space: pre-wrap;
height: 35px;
`;
const ProjectBar = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 12px;
`;
const ProjectActions = styled.div`
display: flex;
align-items: center;
`;
const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
cursor: pointer;
display: flex;
align-items: center;
font-size: 15px;
color: ${(props) => props.theme.colors.text.primary};
&:not(:last-of-type) {
margin-right: 16px;
}
&:hover {
color: ${(props) => props.theme.colors.text.secondary};
}
${(props) =>
props.disabled &&
css`
opacity: 0.5;
cursor: default;
pointer-events: none;
`}
`;
const ProjectActionText = styled.span`
padding-left: 4px;
`;
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>
);
};
const EditorPositioner = styled.div<{ top: number; left: number }>`
position: absolute;
top: ${(p) => p.top}px;
justify-content: flex-end;
margin-left: -100vw;
z-index: 10000;
align-items: flex-start;
display: flex;
font-size: 13px;
height: 0;
position: fixed;
width: 100vw;
left: ${(p) => p.left}px;
`;
const EditorPositionerContents = styled.div`
position: relative;
`;
const EditorContainer = styled.div<{ width: number }>`
border: 1px solid ${(props) => props.theme.colors.primary};
background: ${(props) => props.theme.colors.bg.secondary};
position: relative;
width: ${(p) => p.width}px;
`;
const EditorCell = styled.div<{ width: number }>`
display: flex;
width: ${(p) => p.width}px;
`;
// TABLE
const VerticalScoller = styled.div`
contain: strict;
flex: 1 1 auto;
overflow-x: hidden;
padding-bottom: 1px;
position: relative;
min-height: 1px;
overflow-y: auto;
`;
const VerticalScollerInner = styled.div`
min-height: 100%;
overflow-y: hidden;
min-width: 1px;
overflow-x: auto;
`;
const VerticalScollerInnerBar = styled.div`
display: flex;
margin: 0 24px;
margin-bottom: 1px;
border-top: 1px solid #414561;
`;
const TableContents = styled.div`
box-sizing: border-box;
display: inline-block;
margin-bottom: 32px;
min-width: 100%;
`;
const TaskGroupContainer = styled.div``;
const TaskGroupHeader = styled.div`
height: 50px;
width: 100%;
`;
const TaskGroupItems = styled.div`
overflow: unset;
`;
const ProjectPill = styled.div`
background-color: ${(props) => props.theme.colors.bg.primary};
text-overflow: ellipsis;
border-radius: 10px;
box-sizing: border-box;
display: block;
font-size: 12px;
font-weight: 400;
height: 20px;
line-height: 20px;
overflow: hidden;
padding: 0 8px;
text-align: left;
white-space: nowrap;
`;
const ProjectPillContents = styled.div`
align-items: center;
display: flex;
`;
const ProjectPillName = styled.span`
flex: 0 1 auto;
min-width: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: ${(props) => props.theme.colors.text.primary};
`;
const ProjectPillColor = styled.svg`
overflow: hidden;
flex: 0 0 auto;
margin-right: 4px;
fill: #0064fb;
height: 12px;
width: 12px;
`;
const SingleValue = ({ children, ...props }: any) => {
return (
<ProjectPill>
<ProjectPillContents>
<ProjectPillColor viewBox="0 0 24 24" focusable={false}>
<path d="M10.4,4h3.2c2.2,0,3,0.2,3.9,0.7c0.8,0.4,1.5,1.1,1.9,1.9s0.7,1.6,0.7,3.9v3.2c0,2.2-0.2,3-0.7,3.9c-0.4,0.8-1.1,1.5-1.9,1.9s-1.6,0.7-3.9,0.7h-3.2c-2.2,0-3-0.2-3.9-0.7c-0.8-0.4-1.5-1.1-1.9-1.9c-0.4-1-0.6-1.8-0.6-4v-3.2c0-2.2,0.2-3,0.7-3.9C5.1,5.7,5.8,5,6.6,4.6C7.4,4.2,8.2,4,10.4,4z" />
</ProjectPillColor>
<ProjectPillName>{children}</ProjectPillName>
</ProjectPillContents>
</ProjectPill>
);
};
const OptionWrapper = styled.div`
align-items: center;
display: flex;
height: 40px;
padding: 0 16px;
cursor: pointer;
&:hover {
background: #414561;
}
`;
const OptionLabel = styled.div`
align-items: baseline;
display: flex;
min-width: 1px;
`;
const OptionTitle = styled.div`
min-width: 50px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const OptionSubTitle = styled.div`
color: ${(props) => props.theme.colors.text.primary};
font-size: 11px;
margin-left: 8px;
min-width: 50px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const Option = ({ innerProps, data }: any) => {
return (
<OptionWrapper {...innerProps}>
<OptionLabel>
<OptionTitle>{data.label}</OptionTitle>
<OptionSubTitle>{data.label}</OptionSubTitle>
</OptionLabel>
</OptionWrapper>
);
};
const TaskGroupHeaderContents = styled.div<{ width: number }>`
width: ${(p) => p.width}px;
left: 0;
position: absolute;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 16px;
font-weight: 500;
margin-left: 24px;
line-height: 20px;
align-items: center;
box-sizing: border-box;
display: flex;
flex: 1 1 auto;
min-height: 30px;
padding-right: 32px;
position: relative;
border-bottom: 1px solid transparent;
border-top: 1px solid transparent;
`;
const TaskGroupMinify = styled.div`
height: 28px;
min-height: 28px;
min-width: 28px;
width: 28px;
border-radius: 6px;
user-select: none;
margin-right: 4px;
align-items: center;
box-sizing: border-box;
display: inline-flex;
justify-content: center;
transition-duration: 0.2s;
transition-property: background, border, box-shadow, fill;
cursor: pointer;
svg {
fill: ${(props) => props.theme.colors.text.primary};
transition-duration: 0.2s;
transition-property: background, border, box-shadow, fill;
}
&:hover svg {
fill: ${(props) => props.theme.colors.text.secondary};
}
`;
const TaskGroupName = styled.div`
flex-grow: 1;
align-items: center;
display: flex;
height: 50px;
min-width: 1px;
color: ${(props) => props.theme.colors.text.secondary};
font-weight: 400;
`;
// HEADER
const ScrollContainer = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 1px;
position: relative;
width: 100%;
`;
const Row = styled.div`
box-sizing: border-box;
flex: 0 0 auto;
height: 37px;
position: relative;
`;
const RowHeaderLeft = styled.div<{ width: number }>`
width: ${(p) => p.width}px;
align-items: stretch;
display: flex;
flex-direction: column;
height: 37px;
left: 0;
position: absolute;
z-index: 100;
`;
const RowHeaderLeftInner = styled.div`
align-items: stretch;
color: ${(props) => props.theme.colors.text.primary};
display: flex;
flex: 1 0 auto;
font-size: 12px;
margin-right: -1px;
padding-left: 24px;
`;
const RowHeaderLeftName = styled.div`
position: relative;
align-items: center;
border-right: 1px solid #414561;
border-top: 1px solid #414561;
border-bottom: 1px solid #414561;
display: flex;
flex: 1 0 auto;
justify-content: space-between;
`;
const RowHeaderLeftNameText = styled.div`
align-items: center;
display: flex;
`;
const RowHeaderRight = styled.div<{ left: number }>`
left: ${(p) => p.left}px;
right: 0px;
height: 37px;
position: absolute;
`;
const RowScrollable = styled.div`
min-width: 1px;
overflow-x: auto;
overflow-y: hidden;
width: 100%;
`;
const RowScrollContent = styled.div`
align-items: center;
display: inline-flex;
height: 37px;
width: 100%;
`;
const RowHeaderRightContainer = styled.div`
padding-right: 24px;
align-items: stretch;
display: flex;
flex: 1 0 auto;
height: 37px;
justify-content: flex-end;
margin: -1px 0;
`;
const ItemWrapper = styled.div<{ width: number }>`
width: ${(p) => p.width}px;
align-items: center;
border: 1px solid #414561;
border-bottom: 0;
box-sizing: border-box;
cursor: pointer;
display: inline-flex;
flex: 0 0 auto;
font-size: 12px;
justify-content: space-between;
margin-right: -1px;
padding: 0 8px;
position: relative;
color: ${(props) => props.theme.colors.text.primary};
border-bottom: 1px solid #414561;
&:hover {
background: ${(props) => props.theme.colors.primary};
color: ${(props) => props.theme.colors.text.secondary};
}
`;
const ItemsContainer = styled.div`
display: flex;
flex-direction: row;
height: 100%;
width: 100%;
& ${ItemWrapper}:last-child {
border-right: 0;
}
`;
const ItemName = styled.div`
align-items: center;
display: flex;
overflow: hidden;
`;
type DateEditorState = {
open: boolean;
pos: { top: number; left: number } | null;
task: null | Task;
};
type ProjectEditorState = {
open: boolean;
pos: { top: number; left: number } | null;
task: null | Task;
};
const RIGHT_ROW_WIDTH = 327;
const Projects = () => {
const leftRow = window.innerWidth - RIGHT_ROW_WIDTH;
const [menuOpen, setMenuOpen] = useState(false);
const [filters, setFilters] = useStickyState<{ sort: MyTasksSort; status: MyTasksStatus }>(
{ sort: MyTasksSort.None, status: MyTasksStatus.All },
'my_tasks_filter',
);
const { data } = useMyTasksQuery({
variables: { sort: filters.sort, status: filters.status },
fetchPolicy: 'cache-and-network',
});
const [dateEditor, setDateEditor] = useState<DateEditorState>({ open: false, pos: null, task: null });
const onEditDueDate = (task: Task, $target: React.RefObject<HTMLElement>) => {
if ($target && $target.current && data) {
const pos = $target.current.getBoundingClientRect();
setDateEditor({
open: true,
pos: {
top: pos.top,
left: pos.right,
},
task,
});
}
};
const [newTask, setNewTask] = useState<{ open: boolean }>({ open: false });
const match = useRouteMatch();
const history = useHistory();
const [projectEditor, setProjectEditor] = useState<ProjectEditorState>({ open: false, pos: null, task: null });
const onEditProject = ($target: React.RefObject<HTMLElement>) => {
if ($target && $target.current) {
const pos = $target.current.getBoundingClientRect();
setProjectEditor({
open: true,
pos: {
top: pos.top,
left: pos.right,
},
task: null,
});
}
};
const { showPopup, hidePopup } = usePopup();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
const $editorContents = useRef<HTMLDivElement>(null);
const $dateContents = useRef<HTMLDivElement>(null);
useEffect(() => {
if (dateEditor.open && $dateContents.current && dateEditor.task) {
showPopup(
$dateContents,
<Popup tab={0} title={null}>
<DueDateManager
task={dateEditor.task}
onCancel={() => null}
onDueDateChange={(task, dueDate, hasTime) => {
if (dateEditor.task) {
updateTaskDueDate({ variables: { taskID: dateEditor.task.id, dueDate, hasTime } });
setDateEditor((prev) => ({ ...prev, task: { ...task, dueDate: dueDate.toISOString(), hasTime } }));
}
}}
onRemoveDueDate={(task) => {
if (dateEditor.task) {
updateTaskDueDate({ variables: { taskID: dateEditor.task.id, dueDate: null, hasTime: false } });
setDateEditor((prev) => ({ ...prev, task: { ...task, hasTime: false } }));
}
}}
/>
</Popup>,
{ onClose: () => setDateEditor({ open: false, task: null, pos: null }) },
);
}
}, [dateEditor]);
const [createTask] = useCreateTaskMutation({
update: (client, newTaskData) => {
updateApolloCache<MyTasksQuery>(
client,
MyTasksDocument,
(cache) =>
produce(cache, (draftCache) => {
if (newTaskData.data) {
draftCache.myTasks.tasks.unshift(newTaskData.data.createTask);
}
}),
{ status: MyTasksStatus.All, sort: MyTasksSort.None },
);
},
});
const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskName] = useUpdateTaskNameMutation();
const [minified, setMinified] = useStickyState<Array<string>>([], 'my_tasks_minified');
useOnOutsideClick(
$editorContents,
projectEditor.open,
() =>
setProjectEditor({
open: false,
task: null,
pos: null,
}),
null,
);
if (data) {
const groups: Array<Group> = [];
if (filters.sort === MyTasksSort.None) {
groups.push({
id: 'recently-assigned',
name: 'Recently Assigned',
tasks: data.myTasks.tasks.map((task) => ({
...task,
labels: [],
position: 0,
})),
});
} else {
let { tasks } = data.myTasks;
if (filters.sort === MyTasksSort.DueDate) {
const group: Group = { id: 'due_date', name: null, tasks: [] };
data.myTasks.tasks.forEach((task) => {
if (task.dueDate) {
group.tasks.push({ ...task, labels: [], position: 0 });
}
});
groups.push(group);
tasks = tasks.filter((t) => t.dueDate === null);
}
const projects = new Map<string, Array<Task>>();
data.myTasks.projects.forEach((p) => {
if (!projects.has(p.projectID)) {
projects.set(p.projectID, []);
}
const prev = projects.get(p.projectID);
const task = tasks.find((t) => t.id === p.taskID);
if (prev && task) {
projects.set(p.projectID, [...prev, { ...task, labels: [], position: 0 }]);
}
});
for (const [id, pTasks] of projects) {
const project = data.projects.find((c) => c.id === id);
if (pTasks.length === 0) continue;
if (project) {
groups.push({
id,
name: project.name,
tasks: pTasks.sort((a, b) => {
if (a.dueDate === null && b.dueDate === null) return 0;
if (a.dueDate === null && b.dueDate !== null) return 1;
if (a.dueDate !== null && b.dueDate === null) return -1;
const first = dayjs(a.dueDate);
const second = dayjs(b.dueDate);
if (first.isSame(second, 'minute')) return 0;
if (first.isAfter(second)) return -1;
return 1;
}),
});
}
}
groups.sort((a, b) => {
if (a.name === null && b.name === null) return 0;
if (a.name === null) return -1;
if (b.name === null) return 1;
return a.name.localeCompare(b.name);
});
}
return (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<ProjectBar>
<ProjectActions />
<ProjectActions>
<ProjectAction
onClick={($target) => {
showPopup(
$target,
<MyTasksStatusPopup
status={filters.status}
onChangeStatus={(status) => {
setFilters((prev) => ({ ...prev, status }));
hidePopup();
}}
/>,
{ width: 185 },
);
}}
>
<CheckCircleOutline width={13} height={13} />
<ProjectActionText>{prettyStatus(filters.status)}</ProjectActionText>
</ProjectAction>
<ProjectAction
onClick={($target) => {
showPopup(
$target,
<MyTasksSortPopup
sort={filters.sort}
onChangeSort={(sort) => {
setFilters((prev) => ({ ...prev, sort }));
hidePopup();
}}
/>,
{ width: 185 },
);
}}
>
<Sort width={13} height={13} />
<ProjectActionText>{prettySort(filters.sort)}</ProjectActionText>
</ProjectAction>
<ProjectAction disabled>
<Cogs width={13} height={13} />
<ProjectActionText>Customize</ProjectActionText>
</ProjectAction>
</ProjectActions>
</ProjectBar>
<ScrollContainer>
<Row>
<RowHeaderLeft width={leftRow}>
<RowHeaderLeftInner>
<RowHeaderLeftName>
<RowHeaderLeftNameText>Task name</RowHeaderLeftNameText>
</RowHeaderLeftName>
</RowHeaderLeftInner>
</RowHeaderLeft>
<RowHeaderRight left={leftRow}>
<RowScrollable>
<RowScrollContent>
<RowHeaderRightContainer>
<ItemsContainer>
<ItemWrapper width={120}>
<ItemName>Due date</ItemName>
</ItemWrapper>
<ItemWrapper width={120}>
<ItemName>Project</ItemName>
</ItemWrapper>
<ItemWrapper width={50} />
</ItemsContainer>
</RowHeaderRightContainer>
</RowScrollContent>
</RowScrollable>
</RowHeaderRight>
</Row>
<VerticalScoller>
<VerticalScollerInner>
<TableContents>
{groups.map((group) => {
const isMinified = minified.find((m) => m === group.id) ?? false;
return (
<TaskGroupContainer key={group.id}>
{group.name && (
<TaskGroupHeader>
<TaskGroupHeaderContents width={leftRow}>
<TaskGroupMinify
onClick={() => {
setMinified((prev) => {
if (isMinified) {
return prev.filter((c) => c !== group.id);
}
return [...prev, group.id];
});
}}
>
{isMinified ? (
<CaretRight width={16} height={16} />
) : (
<CaretDown width={16} height={16} />
)}
</TaskGroupMinify>
<TaskGroupName>{group.name}</TaskGroupName>
</TaskGroupHeaderContents>
</TaskGroupHeader>
)}
<TaskGroupItems>
{!isMinified &&
group.tasks.map((task) => {
const projectID = data.myTasks.projects.find((t) => t.taskID === task.id)?.projectID;
const projectName = data.projects.find((p) => p.id === projectID)?.name;
return (
<TaskEntry
key={task.id}
complete={task.complete ?? false}
onToggleComplete={(complete) => {
setTaskComplete({ variables: { taskID: task.id, complete } });
}}
onTaskDetails={() => {
history.push(`${match.url}/c/${task.id}`);
}}
onRemoveDueDate={() => {
updateTaskDueDate({ variables: { taskID: task.id, dueDate: null, hasTime: false } });
}}
project={projectName ?? 'none'}
dueDate={task.dueDate}
hasTime={task.hasTime ?? false}
name={task.name}
onEditName={(name) => updateTaskName({ variables: { taskID: task.id, name } })}
onEditProject={onEditProject}
onEditDueDate={($target) =>
onEditDueDate({ ...task, position: 0, labels: [] }, $target)
}
/>
);
})}
</TaskGroupItems>
</TaskGroupContainer>
);
})}
</TableContents>
</VerticalScollerInner>
</VerticalScoller>
</ScrollContainer>
{dateEditor.open && dateEditor.pos !== null && dateEditor.task && (
<EditorPositioner left={dateEditor.pos.left} top={dateEditor.pos.top}>
<EditorPositionerContents ref={$dateContents}>
<EditorContainer width={120}>
<EditorCell width={120}>
<DueDateEditorLabel>
{dateEditor.task.dueDate
? dayjs(dateEditor.task.dueDate).format(dateEditor.task.hasTime ? 'MMM D [at] h:mm A' : 'MMM D')
: ''}
</DueDateEditorLabel>
</EditorCell>
</EditorContainer>
</EditorPositionerContents>
</EditorPositioner>
)}
{projectEditor.open && projectEditor.pos !== null && (
<EditorPositioner left={projectEditor.pos.left} top={projectEditor.pos.top}>
<EditorPositionerContents ref={$editorContents}>
<EditorContainer width={300}>
<EditorCell width={300}>
<Select
components={{ SingleValue, Option }}
autoFocus
styles={editorColourStyles}
options={[{ label: 'hello', value: '1' }]}
onInputChange={(query, { action }) => {
if (action === 'input-change') {
setMenuOpen(true);
}
}}
onChange={() => setMenuOpen(false)}
onBlur={() => setMenuOpen(false)}
menuIsOpen={menuOpen}
/>
</EditorCell>
</EditorContainer>
</EditorPositionerContents>
</EditorPositioner>
)}
<Route
path={`${match.path}/c/:taskID`}
render={() => {
return (
<Details
refreshCache={NOOP}
availableMembers={[]}
projectURL={`${match.url}`}
onTaskNameChange={(updatedTask, newName) => {
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
}}
onTaskDescriptionChange={(updatedTask, newDescription) => {
/*
updateTaskDescription({
variables: { taskID: updatedTask.id, description: newDescription },
optimisticResponse: {
__typename: 'Mutation',
updateTaskDescription: {
__typename: 'Task',
id: updatedTask.id,
description: newDescription,
},
},
});
*/
}}
onDeleteTask={(deletedTask) => {
// deleteTask({ variables: { taskID: deletedTask.id } });
history.push(`${match.url}`);
}}
onOpenAddLabelPopup={(task, $targetRef) => {
/*
taskLabelsRef.current = task.labels;
showPopup(
$targetRef,
<LabelManagerEditor
onLabelToggle={labelID => {
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
}}
labelColors={data.labelColors}
labels={labelsRef}
taskLabels={taskLabelsRef}
projectID={projectID}
/>,
);
*/
}}
/>
);
}}
/>
</>
);
}
return null;
};
export default Projects;

View File

@ -1,12 +1,21 @@
import React, { useRef, useEffect } from 'react';
import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import { Link } from 'react-router-dom';
import { getAccessToken } from 'shared/utils/accessToken';
import Settings from 'shared/components/Settings';
import { useMeQuery, useClearProfileAvatarMutation, useUpdateUserPasswordMutation } from 'shared/generated/graphql';
import {
useMeQuery,
useClearProfileAvatarMutation,
useUpdateUserPasswordMutation,
useUpdateUserInfoMutation,
MeQuery,
MeDocument,
} from 'shared/generated/graphql';
import axios from 'axios';
import { useCurrentUser } from 'App/context';
import NOOP from 'shared/utils/noop';
import { toast } from 'react-toastify';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
const MainContent = styled.div`
padding: 0 0 50px 80px;
@ -18,6 +27,7 @@ const Projects = () => {
const $fileUpload = useRef<HTMLInputElement>(null);
const [clearProfileAvatar] = useClearProfileAvatarMutation();
const { user } = useCurrentUser();
const [updateUserInfo] = useUpdateUserInfoMutation();
const [updateUserPassword] = useUpdateUserPasswordMutation();
const { loading, data, refetch } = useMeQuery();
useEffect(() => {
@ -38,12 +48,9 @@ const Projects = () => {
if (e.target.files) {
const fileData = new FormData();
fileData.append('file', e.target.files[0]);
const accessToken = getAccessToken();
axios
.post('/users/me/avatar', fileData, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
withCredentials: true,
})
.then(res => {
if ($fileUpload && $fileUpload.current) {
@ -54,8 +61,8 @@ const Projects = () => {
}
}}
/>
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />
{!loading && data && (
<GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
{!loading && data && data.me && (
<Settings
profile={data.me.user}
onProfileAvatarChange={() => {
@ -64,7 +71,15 @@ const Projects = () => {
}
}}
onResetPassword={(password, done) => {
updateUserPassword({ variables: { userID: user.id, password } });
updateUserPassword({ variables: { userID: user, password } });
toast('Password was changed!');
done();
}}
onChangeUserInfo={(d, done) => {
updateUserInfo({
variables: { name: d.full_name, bio: d.bio, email: d.email, initials: d.initials },
});
toast('User info was saved!');
done();
}}
onProfileAvatarRemove={() => {

View File

@ -0,0 +1,324 @@
import React, { useState, useEffect } from 'react';
import styled, { css } from 'styled-components';
import { Checkmark, User, Calendar, Tags, Clock } from 'shared/icons';
import { TaskMetaFilters, TaskMeta, TaskMetaMatch, DueDateFilterType } from 'shared/components/Lists';
import Input from 'shared/components/ControlledInput';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import produce from 'immer';
import { 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}
autoFocus
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: ${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 { MENU_TYPES } from 'shared/components/TopNavbar';
import React, { useState, useRef } from 'react';
import updateApolloCache from 'shared/utils/cache';
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
import LabelManagerEditor from '../LabelManagerEditor';
import styled, { css } from 'styled-components/macro';
import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom';
import { useRouteMatch, useHistory } from 'react-router-dom';
import {
useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation,
useDeleteProjectMemberMutation,
useSetTaskCompleteMutation,
useToggleTaskLabelMutation,
useUpdateProjectNameMutation,
useFindProjectQuery,
useSortTaskGroupMutation,
useUpdateTaskGroupNameMutation,
useUpdateTaskNameMutation,
useUpdateProjectLabelMutation,
useCreateTaskMutation,
useDeleteProjectLabelMutation,
useDeleteTaskMutation,
useUpdateTaskLocationMutation,
useUpdateTaskGroupLocationMutation,
useCreateTaskGroupMutation,
useDeleteTaskGroupMutation,
useUpdateTaskDescriptionMutation,
useAssignTaskMutation,
DeleteTaskDocument,
FindProjectDocument,
useCreateProjectLabelMutation,
useUnassignTaskMutation,
useUpdateTaskDueDateMutation,
FindProjectQuery,
useUsersQuery,
useDuplicateTaskGroupMutation,
DuplicateTaskGroupMutation,
DuplicateTaskGroupDocument,
useDeleteTaskGroupTasksMutation,
} from 'shared/generated/graphql';
import QuickCardEditor from 'shared/components/QuickCardEditor';
import ListActions from 'shared/components/ListActions';
import MemberManager from 'shared/components/MemberManager';
import SimpleLists from 'shared/components/Lists';
import SimpleLists, {
TaskStatus,
TaskSince,
TaskStatusFilter,
TaskMeta,
TaskMetaMatch,
TaskMetaFilters,
} from 'shared/components/Lists';
import { TaskSorting, TaskSortingType, TaskSortingDirection, sortTasks } from 'shared/utils/sorting';
import produce from 'immer';
import MiniProfile from 'shared/components/MiniProfile';
import DueDateManager from 'shared/components/DueDateManager';
import UserContext, { useCurrentUser } from 'App/context';
import LabelManager from 'shared/components/PopupMenu/LabelManager';
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
import EmptyBoard from 'shared/components/EmptyBoard';
import NOOP from 'shared/utils/noop';
import LabelManagerEditor from 'Projects/Project/LabelManagerEditor';
import Chip from 'shared/components/Chip';
import { toast } from 'react-toastify';
import { useCurrentUser } from 'App/context';
import FilterStatus from './FilterStatus';
import FilterMeta from './FilterMeta';
import SortPopup from './SortPopup';
const FilterChip = styled(Chip)`
margin-right: 4px;
`;
type MetaFilterCloseFn = (meta: TaskMeta, key: string) => void;
const renderTaskSortingLabel = (sorting: TaskSorting) => {
if (sorting.type === TaskSortingType.TASK_TITLE) {
return 'Sort: Card title';
}
if (sorting.type === TaskSortingType.MEMBERS) {
return 'Sort: Members';
}
if (sorting.type === TaskSortingType.DUE_DATE) {
return 'Sort: Due Date';
}
if (sorting.type === TaskSortingType.LABELS) {
return 'Sort: Labels';
}
return 'Sort';
};
const renderMetaFilters = (filters: TaskMetaFilters, onClose: MetaFilterCloseFn) => {
const filterChips = [];
if (filters.taskName) {
filterChips.push(
<FilterChip
key="task-name"
label={`Title: ${filters.taskName.name}`}
onClose={() => onClose(TaskMeta.TITLE, 'task-name')}
/>,
);
}
if (filters.dueDate) {
filterChips.push(
<FilterChip
key="due-date"
label={filters.dueDate.label}
onClose={() => onClose(TaskMeta.DUE_DATE, 'due-date')}
/>,
);
}
for (const memberFilter of filters.members) {
filterChips.push(
<FilterChip
key={`member-${memberFilter.id}`}
label={`Member: ${memberFilter.username}`}
onClose={() => onClose(TaskMeta.MEMBER, memberFilter.id)}
/>,
);
}
for (const labelFilter of filters.labels) {
filterChips.push(
<FilterChip
key={`label-${labelFilter.id}`}
label={labelFilter.name === '' ? 'Label' : `Label: ${labelFilter.name}`}
color={labelFilter.color}
onClose={() => onClose(TaskMeta.LABEL, labelFilter.id)}
/>,
);
}
return filterChips;
};
const ProjectBar = styled.div`
display: flex;
@ -61,19 +131,19 @@ const ProjectActions = styled.div`
align-items: center;
`;
const ProjectAction = styled.div<{ disabled?: boolean }>`
const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
cursor: pointer;
display: flex;
align-items: center;
font-size: 15px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
&:not(:last-child) {
&:not(:last-of-type) {
margin-right: 16px;
}
&:hover {
color: rgba(${props => props.theme.colors.text.secondary});
color: ${props => props.theme.colors.text.secondary};
}
${props =>
props.disabled &&
@ -88,6 +158,25 @@ const ProjectActionText = styled.span`
padding-left: 4px;
`;
type ProjectActionProps = {
onClick?: (target: React.RefObject<HTMLElement>) => void;
disabled?: boolean;
};
const ProjectAction: React.FC<ProjectActionProps> = ({ onClick, disabled = false, children }) => {
const $container = useRef<HTMLDivElement>(null);
const handleClick = () => {
if (onClick) {
onClick($container);
}
};
return (
<ProjectActionWrapper ref={$container} onClick={handleClick} disabled={disabled}>
{children}
</ProjectActionWrapper>
);
};
interface QuickCardEditorState {
isOpen: boolean;
target: React.RefObject<HTMLElement> | null;
@ -109,23 +198,25 @@ type ProjectBoardProps = {
};
export const BoardLoading = () => {
const { user } = useCurrentUser();
return (
<>
<ProjectBar>
<ProjectActions>
<ProjectAction disabled>
<ProjectAction>
<CheckCircle width={13} height={13} />
<ProjectActionText>All Tasks</ProjectActionText>
</ProjectAction>
<ProjectAction disabled>
<Filter width={13} height={13} />
<ProjectActionText>Filter</ProjectActionText>
</ProjectAction>
<ProjectAction disabled>
<ProjectAction>
<Sort width={13} height={13} />
<ProjectActionText>Sort</ProjectActionText>
</ProjectAction>
<ProjectAction>
<Filter width={13} height={13} />
<ProjectActionText>Filter</ProjectActionText>
</ProjectAction>
</ProjectActions>
{user && (
<ProjectActions>
<ProjectAction>
<Tags width={13} height={13} />
@ -140,24 +231,50 @@ export const BoardLoading = () => {
<ProjectActionText>Rules</ProjectActionText>
</ProjectAction>
</ProjectActions>
)}
</ProjectBar>
<EmptyBoard />
</>
);
};
const initTaskStatusFilter: TaskStatusFilter = {
status: TaskStatus.ALL,
since: TaskSince.ALL,
};
const initTaskMetaFilters: TaskMetaFilters = {
match: TaskMetaMatch.MATCH_ANY,
dueDate: null,
taskName: null,
labels: [],
members: [],
};
const initTaskSorting: TaskSorting = {
type: TaskSortingType.NONE,
direction: TaskSortingDirection.ASC,
};
const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick, cardLabelVariant }) => {
const [assignTask] = useAssignTaskMutation();
const [unassignTask] = useUnassignTaskMutation();
const $labelsRef = useRef<HTMLDivElement>(null);
const match = useRouteMatch();
const labelsRef = useRef<Array<ProjectLabel>>([]);
const membersRef = useRef<Array<TaskUser>>([]);
const { showPopup, hidePopup } = usePopup();
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
const { user } = useCurrentUser();
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
const [taskStatusFilter, setTaskStatusFilter] = useState(initTaskStatusFilter);
const [taskMetaFilters, setTaskMetaFilters] = useState(initTaskMetaFilters);
const [taskSorting, setTaskSorting] = useState(initTaskSorting);
const history = useHistory();
const [sortTaskGroup] = useSortTaskGroupMutation({
onCompleted: () => {
toast('List was sorted');
},
});
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
update: (client, deletedTaskGroupData) => {
updateApolloCache<FindProjectQuery>(
@ -166,7 +283,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache =>
produce(cache, draftCache => {
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 },
@ -182,10 +299,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache =>
produce(cache, draftCache => {
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 (newTaskData.data) {
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
}
}
}),
{ projectID },
);
@ -199,7 +318,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (newTaskGroupData.data) {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
}
}),
{ projectID },
);
@ -210,6 +331,38 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
const { loading, data } = useFindProjectQuery({
variables: { projectID },
});
const [deleteTaskGroupTasks] = useDeleteTaskGroupTasksMutation({
update: (client, resp) =>
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const idx = cache.findProject.taskGroups.findIndex(
t => t.id === resp.data?.deleteTaskGroupTasks.taskGroupID,
);
if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks = [];
}
}),
{ projectID },
),
});
const [duplicateTaskGroup] = useDuplicateTaskGroupMutation({
update: (client, resp) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (resp.data) {
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
}
}),
{ projectID },
);
},
});
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
const [setTaskComplete] = useSetTaskCompleteMutation();
@ -220,26 +373,32 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (newTask.data) {
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
if (previousTaskGroupID !== task.taskGroup.id) {
const { taskGroups } = cache.findProject;
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
const previousTask = cache.findProject.taskGroups[oldTaskGroupIdx].tasks.find(t => t.id === task.id);
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
(t: Task) => t.id !== task.id,
);
if (previousTask) {
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
...taskGroups[newTaskGroupIdx].tasks,
{ ...task },
{ ...previousTask },
];
}
}
}
}
}),
{ projectID },
);
},
});
const { user } = useCurrentUser();
const [deleteTask] = useDeleteTaskMutation();
const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => {
@ -269,6 +428,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
id: `${Math.round(Math.random() * -1000000)}`,
name,
complete: false,
completedAt: null,
hasTime: false,
taskGroup: {
__typename: 'TaskGroup',
id: taskGroup.id,
@ -302,18 +463,19 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
}
};
if (loading) {
return <BoardLoading />;
const getTaskStatusFilterLabel = (filter: TaskStatusFilter) => {
if (filter.status === TaskStatus.COMPLETE) {
return 'Complete';
}
if (filter.status === TaskStatus.INCOMPLETE) {
return 'Incomplete';
}
return 'All Tasks';
};
if (data) {
labelsRef.current = data.findProject.labels;
membersRef.current = data.findProject.members;
const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => {
if ($target && $target.current) {
const pos = $target.current.getBoundingClientRect();
const height = 120;
if (window.innerHeight - pos.bottom < height) {
}
}
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
const currentTask = taskGroup ? taskGroup.tasks.find(t => t.id === taskID) : null;
if (currentTask) {
@ -336,23 +498,85 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<>
<ProjectBar>
<ProjectActions>
<ProjectAction disabled>
<ProjectAction
onClick={target => {
showPopup(
target,
<Popup tab={0} title={null}>
<FilterStatus
filter={taskStatusFilter}
onChangeTaskStatusFilter={filter => {
setTaskStatusFilter(filter);
hidePopup();
}}
/>
</Popup>,
{ width: 185 },
);
}}
>
<CheckCircle width={13} height={13} />
<ProjectActionText>All Tasks</ProjectActionText>
<ProjectActionText>{getTaskStatusFilterLabel(taskStatusFilter)}</ProjectActionText>
</ProjectAction>
<ProjectAction disabled>
<ProjectAction
onClick={target => {
showPopup(
target,
<Popup tab={0} title={null}>
<SortPopup
sorting={taskSorting}
onChangeTaskSorting={sorting => {
setTaskSorting(sorting);
}}
/>
</Popup>,
{ width: 185 },
);
}}
>
<Sort width={13} height={13} />
<ProjectActionText>{renderTaskSortingLabel(taskSorting)}</ProjectActionText>
</ProjectAction>
<ProjectAction
onClick={target => {
showPopup(
target,
<FilterMeta
filters={taskMetaFilters}
onChangeTaskMetaFilter={filter => {
setTaskMetaFilters(filter);
}}
userID={user ?? ''}
labels={labelsRef}
members={membersRef}
/>,
{ width: 200 },
);
}}
>
<Filter width={13} height={13} />
<ProjectActionText>Filter</ProjectActionText>
</ProjectAction>
<ProjectAction disabled>
<Sort width={13} height={13} />
<ProjectActionText>Sort</ProjectActionText>
</ProjectAction>
{renderMetaFilters(taskMetaFilters, (meta, id) => {
setTaskMetaFilters(
produce(taskMetaFilters, draftFilters => {
if (meta === TaskMeta.MEMBER) {
draftFilters.members = draftFilters.members.filter(m => m.id !== id);
} else if (meta === TaskMeta.LABEL) {
draftFilters.labels = draftFilters.labels.filter(m => m.id !== id);
} else if (meta === TaskMeta.TITLE) {
draftFilters.taskName = null;
} else if (meta === TaskMeta.DUE_DATE) {
draftFilters.dueDate = null;
}
}),
);
})}
</ProjectActions>
{user && (
<ProjectActions>
<ProjectAction
ref={$labelsRef}
onClick={() => {
onClick={$labelsRef => {
showPopup(
$labelsRef,
<LabelManagerEditor
@ -376,12 +600,14 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<ProjectActionText>Rules</ProjectActionText>
</ProjectAction>
</ProjectActions>
)}
</ProjectBar>
<SimpleLists
isPublic={user === null}
onTaskClick={task => {
history.push(`${match.url}/c/${task.id}`);
}}
onCardLabelClick={onCardLabelClick ?? (() => {})}
onCardLabelClick={onCardLabelClick ?? NOOP}
cardLabelVariant={cardLabelVariant ?? 'large'}
onTaskDrop={(droppedTask, previousTaskGroupID) => {
updateTaskLocation({
@ -425,9 +651,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
});
}}
taskGroups={data.findProject.taskGroups}
taskStatusFilter={taskStatusFilter}
taskMetaFilters={taskMetaFilters}
taskSorting={taskSorting}
onCreateTask={onCreateTask}
onCreateTaskGroup={onCreateList}
onCardMemberClick={($targetRef, taskID, memberID) => {
onCardMemberClick={($targetRef, _taskID, memberID) => {
const member = data.findProject.members.find(m => m.id === memberID);
if (member) {
showPopup(
@ -449,15 +678,44 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
showPopup(
$targetRef,
<Popup title="List actions" tab={0} onClose={() => hidePopup()}>
<ListActions
taskGroupID={taskGroupID}
onDeleteTaskGroupTasks={() => {
deleteTaskGroupTasks({ variables: { taskGroupID } });
hidePopup();
}}
onSortTaskGroup={taskSort => {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
if (taskGroup) {
const tasks: Array<{ taskID: string; position: number }> = taskGroup.tasks
.sort((a, b) => sortTasks(a, b, taskSort))
.reduce((prevTasks: Array<{ taskID: string; position: number }>, t, idx) => {
prevTasks.push({ taskID: t.id, position: (idx + 1) * 2048 });
return tasks;
}, []);
sortTaskGroup({ variables: { taskGroupID, tasks } });
hidePopup();
}
}}
onDuplicateTaskGroup={newName => {
const idx = data.findProject.taskGroups.findIndex(t => t.id === taskGroupID);
if (idx !== -1) {
const taskGroups = data.findProject.taskGroups.sort((a, b) => a.position - b.position);
const prevPos = taskGroups[idx].position;
const next = taskGroups[idx + 1];
let newPos = prevPos * 2;
if (next) {
newPos = (prevPos + next.position) / 2.0;
}
duplicateTaskGroup({ variables: { projectID, taskGroupID, name: newName, position: newPos } });
hidePopup();
}
}}
onArchiveTaskGroup={tgID => {
deleteTaskGroup({ variables: { taskGroupID: tgID } });
hidePopup();
}}
/>
</Popup>,
/>,
);
}}
/>
@ -486,7 +744,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
</Popup>,
);
}}
onCardMemberClick={($targetRef, taskID, memberID) => {
onCardMemberClick={($targetRef, _taskID, memberID) => {
const member = data.findProject.members.find(m => m.id === memberID);
if (member) {
showPopup(
@ -509,6 +767,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onLabelToggle={labelID => {
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
}}
taskID={task.id}
labelColors={data.labelColors}
labels={labelsRef}
taskLabels={taskLabelsRef}
@ -516,8 +775,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
/>,
);
}}
onArchiveCard={(_listId: string, cardId: string) =>
deleteTask({
onArchiveCard={(_listId: string, cardId: string) => {
return deleteTask({
variables: { taskID: cardId },
update: client => {
updateApolloCache<FindProjectQuery>(
@ -533,8 +792,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
{ projectID },
);
},
})
}
});
}}
onOpenDueDatePopup={($targetRef, task) => {
showPopup(
$targetRef,
@ -542,14 +801,14 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<DueDateManager
task={task}
onRemoveDueDate={t => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
hidePopup();
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } });
// hidePopup();
}}
onDueDateChange={(t, newDueDate) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup();
onDueDateChange={(t, newDueDate, hasTime) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } });
// hidePopup();
}}
onCancel={() => {}}
onCancel={NOOP}
/>
</Popup>,
);
@ -564,7 +823,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
);
}
return <span>Error</span>;
return <BoardLoading />;
};
export default ProjectBoard;

View File

@ -1,9 +1,10 @@
import React, { useState, useContext, useEffect } from 'react';
import React, { useState } from 'react';
import Modal from 'shared/components/Modal';
import TaskDetails from 'shared/components/TaskDetails';
import PopupMenu, { Popup, usePopup } from 'shared/components/PopupMenu';
import TaskDetailsLoading from 'shared/components/TaskDetails/Loading';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import MemberManager from 'shared/components/MemberManager';
import { useRouteMatch, useHistory } from 'react-router';
import { useRouteMatch, useHistory, useParams } from 'react-router';
import {
useDeleteTaskChecklistMutation,
useUpdateTaskChecklistNameMutation,
@ -21,8 +22,11 @@ import {
useCreateTaskChecklistItemMutation,
FindTaskDocument,
FindTaskQuery,
useCreateTaskCommentMutation,
useDeleteTaskCommentMutation,
useUpdateTaskCommentMutation,
} from 'shared/generated/graphql';
import UserContext, { useCurrentUser } from 'App/context';
import { useCurrentUser } from 'App/context';
import MiniProfile from 'shared/components/MiniProfile';
import DueDateManager from 'shared/components/DueDateManager';
import produce from 'immer';
@ -31,6 +35,75 @@ import Button from 'shared/components/Button';
import Input from 'shared/components/Input';
import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache';
import NOOP from 'shared/utils/noop';
import polling from 'shared/utils/polling';
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 WarningLabel = styled.p`
font-size: 14px;
margin: 8px 12px;
`;
const DeleteConfirm = styled(Button)`
width: 100%;
padding: 8px 12px;
margin-bottom: 6px;
`;
type TaskCommentActionsProps = {
onDeleteComment: () => void;
onEditComment: () => void;
};
const TaskCommentActions: React.FC<TaskCommentActionsProps> = ({ onDeleteComment, onEditComment }) => {
const { setTab } = usePopup();
return (
<>
<Popup tab={0} title={null}>
<ActionsList>
<ActionItem>
<ActionTitle>Pin to top</ActionTitle>
</ActionItem>
<ActionItem onClick={() => onEditComment()}>
<ActionTitle>Edit comment</ActionTitle>
</ActionItem>
<ActionItem onClick={() => setTab(1)}>
<ActionTitle>Delete comment</ActionTitle>
</ActionItem>
</ActionsList>
</Popup>
<Popup tab={1} title="Delete comment?">
<WarningLabel>Deleting a comment can not be undone.</WarningLabel>
<DeleteConfirm onClick={() => onDeleteComment()} color="danger">
Delete comment
</DeleteConfirm>
</Popup>
</>
);
};
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
const total = checklists.reduce((prev: any, next: any) => {
@ -75,15 +148,12 @@ const CreateChecklistInput = styled(Input)`
margin-bottom: 8px;
`;
const InputError = styled.span`
color: rgba(${props => props.theme.colors.danger});
font-size: 12px;
`;
type CreateChecklistPopupProps = {
onCreateChecklist: (data: CreateChecklistData) => void;
};
const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => {
const { register, handleSubmit, errors } = useForm<CreateChecklistData>();
const { register, handleSubmit } = useForm<CreateChecklistData>();
const createUser = (data: CreateChecklistData) => {
onCreateChecklist(data);
};
@ -96,10 +166,8 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
defaultValue="Checklist"
width="100%"
label="Name"
id="name"
name="name"
variant="alternate"
ref={register({ required: 'Checklist name is required' })}
{...register('name', { required: 'Checklist name is required' })}
/>
<CreateChecklistButton type="submit">Create</CreateChecklistButton>
</CreateChecklistForm>
@ -107,7 +175,6 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
};
type DetailsProps = {
taskID: string;
projectURL: string;
onTaskNameChange: (task: Task, newName: string) => void;
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
@ -121,7 +188,6 @@ const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 };
const Details: React.FC<DetailsProps> = ({
projectURL,
taskID,
onTaskNameChange,
onTaskDescriptionChange,
onDeleteTask,
@ -130,49 +196,83 @@ const Details: React.FC<DetailsProps> = ({
refreshCache,
}) => {
const { user } = useCurrentUser();
const { taskID } = useParams<{ taskID: string }>();
const { showPopup, hidePopup } = usePopup();
const history = useHistory();
const match = useRouteMatch();
const [currentMemberTask, setCurrentMemberTask] = useState('');
const [memberPopupData, setMemberPopupData] = useState(initialMemberPopupState);
const [deleteTaskComment] = useDeleteTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
draftCache.findTask.comments = cache.findTask.comments.filter(
(c) => c.id !== response.data?.deleteTaskComment.commentID,
);
}
}),
{ taskID },
);
},
});
const [createTaskComment] = useCreateTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
draftCache.findTask.comments.push({
...response.data.createTaskComment.comment,
});
}
}),
{ taskID },
);
},
});
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
if (checklistID !== prevChecklistID) {
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID);
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
const { prevChecklistID, taskChecklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
if (taskChecklistID !== prevChecklistID) {
const oldIdx = cache.findTask.checklists.findIndex((c) => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex((c) => c.id === taskChecklistID);
if (oldIdx > -1 && newIdx > -1) {
const item = cache.findTask.checklists[oldIdx].items.find(item => item.id === checklistItem.id);
const item = cache.findTask.checklists[oldIdx].items.find((i) => i.id === checklistItem.id);
if (item) {
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
i => i.id !== checklistItem.id,
(i) => i.id !== checklistItem.id,
);
draftCache.findTask.checklists[newIdx].items.push({
...item,
position: checklistItem.position,
taskChecklistID: checklistID,
taskChecklistID,
});
}
}
}
}
}),
{ taskID },
);
},
});
const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({
update: client => {
update: (client) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
@ -189,11 +289,11 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
const { checklists } = cache.findTask;
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);
draftCache.findTask.badges.checklist = {
@ -215,10 +315,12 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (createData.data) {
const item = createData.data.createTaskChecklist;
draftCache.findTask.checklists.push({ ...item });
}
}),
{ taskID },
);
@ -230,13 +332,14 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (deleteData.data) {
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
const targetIdx = cache.findTask.checklists.findIndex((c) => c.id === item.taskChecklistID);
if (targetIdx > -1) {
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
c => item.id !== c.id,
(c) => item.id !== c.id,
);
}
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
@ -245,6 +348,7 @@ const Details: React.FC<DetailsProps> = ({
complete,
total,
};
}
}),
{ taskID },
);
@ -255,11 +359,12 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(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);
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);
@ -269,12 +374,17 @@ const Details: React.FC<DetailsProps> = ({
total,
};
}
}
}),
{ taskID },
);
},
});
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
const { loading, data, refetch } = useFindTaskQuery({
variables: { taskID },
pollInterval: polling.TASK_DETAILS,
fetchPolicy: 'cache-and-network',
});
const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
onCompleted: () => {
@ -294,26 +404,48 @@ const Details: React.FC<DetailsProps> = ({
refreshCache();
},
});
if (loading) {
return <div>loading</div>;
}
if (!data) {
return <div>loading</div>;
}
const [updateTaskComment] = useUpdateTaskCommentMutation();
const [editableComment, setEditableComment] = useState<null | string>(null);
const isLoading = true;
return (
<>
<Modal
width={768}
width={1070}
onClose={() => {
history.push(projectURL);
hidePopup();
}}
renderContent={() => {
return (
return data ? (
<TaskDetails
onCancelCommentEdit={() => setEditableComment(null)}
onUpdateComment={(commentID, message) => {
updateTaskComment({ variables: { commentID, message } });
}}
editableComment={editableComment}
me={data.me ? data.me.user : null}
onCommentShowActions={(commentID, $targetRef) => {
showPopup(
$targetRef,
<TaskCommentActions
onDeleteComment={() => {
deleteTaskComment({ variables: { commentID } });
hidePopup();
}}
onEditComment={() => {
setEditableComment(commentID);
hidePopup();
}}
/>,
);
}}
task={data.findTask}
onChecklistDrop={checklist => {
onCreateComment={(task, message) => {
createTaskComment({ variables: { taskID: task.id, message } });
}}
onChecklistDrop={(checklist) => {
updateTaskChecklistLocation({
variables: { checklistID: checklist.id, position: checklist.position },
variables: { taskChecklistID: checklist.id, position: checklist.position },
optimisticResponse: {
__typename: 'Mutation',
@ -328,20 +460,24 @@ const Details: React.FC<DetailsProps> = ({
},
});
}}
onChecklistItemDrop={(prevChecklistID, checklistID, checklistItem) => {
onChecklistItemDrop={(prevChecklistID, taskChecklistID, checklistItem) => {
updateTaskChecklistItemLocation({
variables: { checklistID, checklistItemID: checklistItem.id, position: checklistItem.position },
variables: {
taskChecklistID,
taskChecklistItemID: checklistItem.id,
position: checklistItem.position,
},
optimisticResponse: {
__typename: 'Mutation',
updateTaskChecklistItemLocation: {
__typename: 'UpdateTaskChecklistItemLocationPayload',
prevChecklistID,
checklistID,
taskChecklistID,
checklistItem: {
__typename: 'TaskChecklistItem',
position: checklistItem.position,
id: checklistItem.id,
taskChecklistID: checklistID,
taskChecklistID,
},
},
},
@ -349,7 +485,7 @@ const Details: React.FC<DetailsProps> = ({
}}
onTaskNameChange={onTaskNameChange}
onTaskDescriptionChange={onTaskDescriptionChange}
onToggleTaskComplete={task => {
onToggleTaskComplete={(task) => {
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
}}
onDeleteTask={onDeleteTask}
@ -394,17 +530,17 @@ const Details: React.FC<DetailsProps> = ({
createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
}}
onMemberProfile={($targetRef, memberID) => {
const member = data.findTask.assigned.find(m => m.id === memberID);
const member = data.findTask.assigned.find((m) => m.id === memberID);
if (member) {
showPopup(
$targetRef,
<Popup title={null} onClose={() => {}} tab={0}>
<Popup title={null} onClose={NOOP} tab={0}>
<MiniProfile
user={member}
bio="None"
onRemoveFromTask={() => {
if (user) {
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
unassignTask({ variables: { taskID: data.findTask.id, userID: user ?? '' } });
}
}}
/>
@ -412,19 +548,19 @@ const Details: React.FC<DetailsProps> = ({
);
}
}}
onOpenAddMemberPopup={(task, $targetRef) => {
onOpenAddMemberPopup={(_task, $targetRef) => {
showPopup(
$targetRef,
<Popup title="Members" tab={0} onClose={() => {}}>
<Popup title="Members" tab={0} onClose={NOOP}>
<MemberManager
availableMembers={availableMembers}
activeMembers={data.findTask.assigned}
onMemberChange={(member, isActive) => {
if (user) {
if (isActive) {
assignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
assignTask({ variables: { taskID: data.findTask.id, userID: member.id } });
} else {
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
unassignTask({ variables: { taskID: data.findTask.id, userID: member.id } });
}
}
}}
@ -444,7 +580,7 @@ const Details: React.FC<DetailsProps> = ({
}}
>
<CreateChecklistPopup
onCreateChecklist={checklistData => {
onCreateChecklist={(checklistData) => {
let position = 65535;
if (data.findTask.checklists) {
const [lastChecklist] = data.findTask.checklists.slice(-1);
@ -486,7 +622,7 @@ const Details: React.FC<DetailsProps> = ({
showPopup(
$targetRef,
<Popup
title={'Change Due Date'}
title="Change Due Date"
tab={0}
onClose={() => {
hidePopup();
@ -494,20 +630,23 @@ const Details: React.FC<DetailsProps> = ({
>
<DueDateManager
task={task}
onRemoveDueDate={t => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
hidePopup();
onRemoveDueDate={(t) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } });
// hidePopup();
}}
onDueDateChange={(t, newDueDate) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup();
onDueDateChange={(t, newDueDate, hasTime) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } });
// hidePopup();
}}
onCancel={() => {}}
onCancel={NOOP}
/>
</Popup>,
{ showDiamond: false, targetPadding: '0' },
);
}}
/>
) : (
<TaskDetailsLoading />
);
}}
/>

View File

@ -3,37 +3,18 @@ import updateApolloCache from 'shared/utils/cache';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer';
import {
useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation,
useDeleteProjectMemberMutation,
useSetTaskCompleteMutation,
useToggleTaskLabelMutation,
useUpdateProjectNameMutation,
useFindProjectQuery,
useUpdateTaskGroupNameMutation,
useUpdateTaskNameMutation,
useUpdateProjectLabelMutation,
useCreateTaskMutation,
useDeleteProjectLabelMutation,
useDeleteTaskMutation,
useUpdateTaskLocationMutation,
useUpdateTaskGroupLocationMutation,
useCreateTaskGroupMutation,
useDeleteTaskGroupMutation,
useUpdateTaskDescriptionMutation,
useAssignTaskMutation,
DeleteTaskDocument,
FindProjectDocument,
useCreateProjectLabelMutation,
useUnassignTaskMutation,
useUpdateTaskDueDateMutation,
FindProjectQuery,
useUsersQuery,
useToggleTaskLabelMutation,
} from 'shared/generated/graphql';
import LabelManager from 'shared/components/PopupMenu/LabelManager';
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
type LabelManagerEditorProps = {
taskID?: string;
labels: React.RefObject<Array<ProjectLabel>>;
taskLabels: null | React.RefObject<Array<TaskLabel>>;
projectID: string;
@ -42,6 +23,7 @@ type LabelManagerEditorProps = {
};
const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
taskID,
labels: labelsRef,
projectID,
labelColors,
@ -50,14 +32,22 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
}) => {
const [currentLabel, setCurrentLabel] = useState('');
const { setTab, hidePopup } = usePopup();
const [toggleTaskLabel] = useToggleTaskLabelMutation();
const [createProjectLabel] = useCreateProjectLabelMutation({
onCompleted: data => {
if (taskID) {
toggleTaskLabel({ variables: { taskID, projectLabelID: data.createProjectLabel.id } });
}
},
update: (client, newLabelData) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (newLabelData.data) {
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel });
}
}),
{
projectID,
@ -74,7 +64,7 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
cache =>
produce(cache, draftCache => {
draftCache.findProject.labels = cache.findProject.labels.filter(
label => label.id !== newLabelData.data.deleteProjectLabel.id,
label => label.id !== newLabelData.data?.deleteProjectLabel.id,
);
}),
{ projectID },

View File

@ -0,0 +1,16 @@
import React from 'react';
import { Cross } from 'shared/icons';
import * as S from './Styles';
const OptionValue = ({ data, removeProps }: any) => {
return (
<S.OptionValueWrapper>
<S.OptionValueLabel>{data.label}</S.OptionValueLabel>
<S.OptionValueRemove {...removeProps}>
<Cross width={14} height={14} />
</S.OptionValueRemove>
</S.OptionValueWrapper>
);
};
export default OptionValue;

View File

@ -0,0 +1,64 @@
import styled from 'styled-components';
import Button from 'shared/components/Button';
export 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;
`;
export const OptionContent = styled.div`
display: flex;
flex-direction: column;
margin-left: 12px;
`;
export 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)});
`;
export 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;
`;
export const OptionValueLabel = styled.span`
font-size: 12px;
color: rgba(${props => props.theme.colors.text.secondary});
`;
export 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;
`;
export const InviteButton = styled(Button)`
margin-top: 12px;
height: 32px;
padding: 4px 12px;
width: 100%;
justify-content: center;
`;
export const InviteContainer = styled.div`
min-height: 300px;
display: flex;
flex-direction: column;
`;

View File

@ -0,0 +1,39 @@
import React from 'react';
import TaskAssignee from 'shared/components/TaskAssignee';
import * as S from './Styles';
type UserOptionProps = {
innerProps: any;
isDisabled: boolean;
isFocused: boolean;
label: string;
data: any;
getValue: any;
};
const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
return !isDisabled ? (
<S.OptionWrapper {...innerProps} isFocused={isFocused}>
<TaskAssignee
size={32}
member={{
id: '',
fullName: data.value.label,
profileIcon: data.value.profileIcon,
}}
/>
<S.OptionContent>
<S.OptionLabel fontSize={16} quiet={false}>
{label}
</S.OptionLabel>
{data.value.type === 2 && (
<S.OptionLabel fontSize={14} quiet>
Joined
</S.OptionLabel>
)}
</S.OptionContent>
</S.OptionWrapper>
) : null;
};
export default UserOption;

View File

@ -0,0 +1,82 @@
import gql from 'graphql-tag';
import isValidEmail from 'shared/utils/email';
type MemberFilterOptions = {
projectID?: null | string;
teamID?: null | string;
organization?: boolean;
};
export default async function(client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) {
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> = [];
if (res.data && res.data.searchMembers) {
results = [
...res.data.searchMembers.map((m: any) => {
if (m.status === 'INVITED') {
return {
label: m.id,
value: {
id: m.id,
type: 2,
profileIcon: {
bgColor: '#ccc',
initials: m.id.charAt(0),
},
},
};
}
emails.push(m.user.email);
return {
label: m.user.fullName,
value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon },
};
}),
];
}
if (isValidEmail(input) && !emails.find(e => e === input)) {
results = [
...results,
{
label: input,
value: {
id: input,
type: 1,
profileIcon: {
bgColor: '#ccc',
initials: input.charAt(0),
},
},
},
];
}
return results;
}

View File

@ -0,0 +1,82 @@
import React, { useState } from 'react';
import AsyncSelect from 'react-select/async';
import { useApolloClient } from '@apollo/react-hooks';
import { colourStyles } from 'shared/components/Select';
import { Popup } from 'shared/components/PopupMenu';
import OptionValue from './OptionValue';
import UserOption from './UserOption';
import fetchMembers from './fetchMembers';
import * as S from './Styles';
type InviteUserData = {
email?: string;
userID?: string;
};
type UserManagementPopupProps = {
projectID: string;
users: Array<User>;
projectMembers: Array<TaskUser>;
onInviteProjectMembers: (data: Array<InviteUserData>) => void;
};
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({
projectID,
users,
projectMembers,
onInviteProjectMembers,
}) => {
const client = useApolloClient();
const [invitedUsers, setInvitedUsers] = useState<Array<any> | null>(null);
return (
<Popup tab={0} title="Invite a user">
<S.InviteContainer>
<AsyncSelect
getOptionValue={option => option.value.id}
placeholder="Email address or username"
noOptionsMessage={() => null}
onChange={(e: any) => {
setInvitedUsers(e);
}}
isMulti
autoFocus
cacheOptions
styles={colourStyles}
defaultOption
components={{
MultiValue: OptionValue,
Option: UserOption,
IndicatorSeparator: null,
DropdownIndicator: null,
}}
loadOptions={(i, cb) => fetchMembers(client, projectID, {}, i, cb)}
/>
</S.InviteContainer>
<S.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
</S.InviteButton>
</Popup>
);
};
export default UserManagementPopup;

View File

@ -1,10 +1,9 @@
// LOC830
import React, { useState, useRef, useEffect, useContext } from 'react';
import React, { useRef, useEffect } from 'react';
import updateApolloCache from 'shared/utils/cache';
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
import styled from 'styled-components/macro';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import LabelManagerEditor from './LabelManagerEditor';
import GlobalTopNavbar from 'App/TopNavbar';
import ProjectPopup from 'App/TopNavbar/ProjectPopup';
import { usePopup } from 'shared/components/PopupMenu';
import {
useParams,
Route,
@ -16,129 +15,80 @@ import {
} from 'react-router-dom';
import {
useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation,
useInviteProjectMembersMutation,
useDeleteProjectMemberMutation,
useToggleTaskLabelMutation,
useUpdateProjectNameMutation,
useFindProjectQuery,
useDeleteInvitedProjectMemberMutation,
useUpdateTaskNameMutation,
useCreateTaskMutation,
useDeleteTaskMutation,
useUpdateTaskLocationMutation,
useUpdateTaskGroupLocationMutation,
useCreateTaskGroupMutation,
useUpdateTaskDescriptionMutation,
FindProjectDocument,
FindProjectQuery,
} from 'shared/generated/graphql';
import produce from 'immer';
import UserContext, { useCurrentUser } from 'App/context';
import Input from 'shared/components/Input';
import Member from 'shared/components/Member';
import NOOP from 'shared/utils/noop';
import useStateWithLocalStorage from 'shared/hooks/useStateWithLocalStorage';
import localStorage from 'shared/utils/localStorage';
import Board, { BoardLoading } from './Board';
import Details from './Details';
import EmptyBoard from 'shared/components/EmptyBoard';
const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant';
const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => {
const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || '');
React.useEffect(() => {
localStorage.setItem(localStorageKey, value);
}, [value]);
return [value, setValue];
};
const SearchInput = styled(Input)`
margin: 0;
`;
const UserMember = styled(Member)`
padding: 4px 0;
cursor: pointer;
&:hover {
background: rgba(${props => props.theme.colors.bg.primary}, 0.4);
}
border-radius: 6px;
`;
const MemberList = styled.div`
margin: 8px 0;
`;
type UserManagementPopupProps = {
users: Array<User>;
projectMembers: Array<TaskUser>;
onAddProjectMember: (userID: string) => void;
};
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, projectMembers, onAddProjectMember }) => {
return (
<Popup tab={0} title="Invite a user">
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
<MemberList>
{users
.filter(u => u.id !== projectMembers.find(p => p.id === u.id)?.id)
.map(user => (
<UserMember
key={user.id}
onCardMemberClick={() => onAddProjectMember(user.id)}
showName
member={user}
taskID=""
/>
))}
</MemberList>
</Popup>
);
};
import LabelManagerEditor from './LabelManagerEditor';
import UserManagementPopup from './UserManagementPopup';
import polling from 'shared/utils/polling';
type TaskRouteProps = {
taskID: string;
};
interface QuickCardEditorState {
isOpen: boolean;
target: React.RefObject<HTMLElement> | null;
taskID: string | null;
taskGroupID: string | null;
}
interface ProjectParams {
projectID: string;
}
const initialQuickCardEditorState: QuickCardEditorState = {
taskID: null,
taskGroupID: null,
isOpen: false,
target: null,
};
const Project = () => {
const { projectID } = useParams<ProjectParams>();
const history = useHistory();
const match = useRouteMatch();
const location = useLocation();
const { showPopup, hidePopup } = usePopup();
const labelsRef = useRef<Array<ProjectLabel>>([]);
const [value, setValue] = useStateWithLocalStorage(localStorage.CARD_LABEL_VARIANT_STORAGE_KEY);
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
const [updateTaskDescription] = useUpdateTaskDescriptionMutation();
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
const [updateTaskName] = useUpdateTaskNameMutation();
const { data, error } = useFindProjectQuery({
variables: { projectID },
pollInterval: polling.PROJECT,
});
const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => {
onCompleted: (newTaskLabel) => {
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
},
});
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,
);
const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY);
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
const [deleteTask] = useDeleteTaskMutation();
const [updateTaskName] = useUpdateTaskNameMutation();
const { loading, data } = useFindProjectQuery({
variables: { projectID },
if (taskGroupIdx !== -1) {
draftCache.findProject.taskGroups[taskGroupIdx].tasks = cache.findProject.taskGroups[
taskGroupIdx
].tasks.filter((t) => t.id !== resp.data?.deleteTask.taskID);
}
}
}),
{ projectID },
),
});
const [updateProjectName] = useUpdateProjectNameMutation({
@ -146,23 +96,47 @@ const Project = () => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.name = newName.data.updateProjectName.name;
(cache) =>
produce(cache, (draftCache) => {
draftCache.findProject.name = newName.data?.updateProjectName.name ?? '';
}),
{ projectID },
);
},
});
const [createProjectMember] = useCreateProjectMemberMutation({
const [inviteProjectMembers] = useInviteProjectMembersMutation({
update: (client, response) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.members.push({ ...response.data.createProjectMember.member });
(cache) =>
produce(cache, (draftCache) => {
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 },
);
@ -173,10 +147,10 @@ const Project = () => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
draftCache.findProject.members = cache.findProject.members.filter(
m => m.id !== response.data.deleteProjectMember.member.id,
(m) => m.id !== response.data?.deleteProjectMember.member.id,
);
}),
{ projectID },
@ -184,26 +158,12 @@ const Project = () => {
},
});
const { user } = useCurrentUser();
const location = useLocation();
const { showPopup, hidePopup } = usePopup();
const $labelsRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<Array<ProjectLabel>>([]);
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
useEffect(() => {
if (data) {
document.title = `${data.findProject.name} | Taskcafé`;
}
}, [data]);
if (loading) {
return (
<>
<GlobalTopNavbar onSaveProjectName={projectName => {}} name="" projectID={null} />
<BoardLoading />
</>
);
}
if (data) {
labelsRef.current = data.findProject.labels;
@ -213,34 +173,48 @@ const Project = () => {
onChangeRole={(userID, roleCode) => {
updateProjectMemberRole({ variables: { userID, roleCode, projectID } });
}}
onChangeProjectOwner={uid => {
onChangeProjectOwner={() => {
hidePopup();
}}
onRemoveFromBoard={userID => {
onRemoveFromBoard={(userID) => {
deleteProjectMember({ variables: { userID, projectID } });
hidePopup();
}}
onSaveProjectName={projectName => {
onRemoveInvitedFromBoard={(email) => {
deleteInvitedProjectMember({ variables: { projectID, email } });
hidePopup();
}}
onSaveProjectName={(projectName) => {
updateProjectName({ variables: { projectID, name: projectName } });
}}
onInviteUser={$target => {
onInviteUser={($target) => {
showPopup(
$target,
<UserManagementPopup
onAddProjectMember={userID => {
createProjectMember({ variables: { userID, projectID } });
projectID={projectID}
onInviteProjectMembers={(members) => {
inviteProjectMembers({ variables: { projectID, members } });
hidePopup();
}}
users={data.users}
projectMembers={data.findProject.members}
/>,
);
}}
popupContent={<ProjectPopup history={history} name={data.findProject.name} projectID={projectID} />}
popupContent={
<ProjectPopup // eslint-disable-line
history={history}
publicOn={data.findProject.publicOn}
name={data.findProject.name}
projectID={projectID}
/>
}
menuType={[{ name: 'Board', link: location.pathname }]}
currentTab={0}
projectMembers={data.findProject.members}
projectInvitedMembers={data.findProject.invitedMembers}
projectID={projectID}
teamID={data.findProject.team.id}
teamID={data.findProject.team ? data.findProject.team.id : null}
name={data.findProject.name}
/>
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />
@ -259,29 +233,41 @@ const Project = () => {
/>
<Route
path={`${match.path}/board/c/:taskID`}
render={(routeProps: RouteComponentProps<TaskRouteProps>) => (
render={() => {
return (
<Details
refreshCache={() => {}}
refreshCache={NOOP}
availableMembers={data.findProject.members}
projectURL={`${match.url}/board`}
taskID={routeProps.match.params.taskID}
onTaskNameChange={(updatedTask, newName) => {
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
}}
onTaskDescriptionChange={(updatedTask, newDescription) => {
updateTaskDescription({ variables: { taskID: updatedTask.id, description: newDescription } });
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 } });
history.push(`${match.url}/board`);
}}
onOpenAddLabelPopup={(task, $targetRef) => {
taskLabelsRef.current = task.labels;
showPopup(
$targetRef,
<LabelManagerEditor
onLabelToggle={labelID => {
onLabelToggle={(labelID) => {
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
}}
taskID={task.id}
labelColors={data.labelColors}
labels={labelsRef}
taskLabels={taskLabelsRef}
@ -290,12 +276,18 @@ const Project = () => {
);
}}
/>
)}
);
}}
/>
</>
);
}
return <div>Error</div>;
return (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
<BoardLoading />
</>
);
};
export default Project;

View File

@ -1,4 +1,4 @@
import React, { useState, useContext, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import Empty from 'shared/undraw/Empty';
@ -10,68 +10,56 @@ import {
GetProjectsQuery,
} from 'shared/generated/graphql';
import ProjectGridItem, { AddProjectItem } from 'shared/components/ProjectGridItem';
import { Link } from 'react-router-dom';
import NewProject from 'shared/components/NewProject';
import UserContext, { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import { useCurrentUser } from 'App/context';
import Button from 'shared/components/Button';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useForm } from 'react-hook-form';
import Input from 'shared/components/Input';
import ControlledInput from 'shared/components/ControlledInput';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
const EmptyStateContent = styled.div`
display: flex;
justy-content: center;
align-items: center;
flex-direction: column;
`;
import NOOP from 'shared/utils/noop';
import theme from 'App/ThemeStyles';
import polling from 'shared/utils/polling';
import { mixin } from '../shared/utils/styles';
const EmptyStateTitle = styled.h3`
color: #fff;
font-size: 18px;
`;
type CreateTeamData = { name: string };
const EmptyStatePrompt = styled.span`
color: rgba(${props => props.theme.colors.text.primary});
font-size: 16px;
margin-top: 8px;
`;
const EmptyState = styled(Empty)`
display: block;
margin: 0 auto;
`;
const CreateTeamButton = styled(Button)`
width: 100%;
`;
type CreateTeamData = { teamName: string };
type CreateTeamFormProps = {
onCreateTeam: (teamName: string) => void;
};
const CreateTeamFormContainer = styled.form``;
const CreateTeamButton = styled(Button)`
width: 100%;
`;
const ErrorText = styled.span`
font-size: 14px;
color: ${(props) => props.theme.colors.danger};
`;
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
const { register, handleSubmit, errors } = useForm<CreateTeamData>();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateTeamData>();
const createTeam = (data: CreateTeamData) => {
onCreateTeam(data.teamName);
onCreateTeam(data.name);
};
return (
<CreateTeamFormContainer onSubmit={handleSubmit(createTeam)}>
<Input
width="100%"
label="Team name"
id="teamName"
name="teamName"
variant="alternate"
ref={register({ required: 'Team name is required' })}
/>
{errors.name && <ErrorText>{errors.name.message}</ErrorText>}
<ControlledInput width="100%" label="Team name" variant="alternate" {...register('name')} />
<CreateTeamButton type="submit">Create</CreateTeamButton>
</CreateTeamFormContainer>
);
};
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-position: 50%;
color: #fff;
@ -85,7 +73,7 @@ const ProjectAddTile = styled.div`
`;
const ProjectTile = styled(Link)<{ color: string }>`
background-color: ${props => props.color};
background-color: ${(props) => props.color};
background-size: cover;
background-position: 50%;
color: #fff;
@ -156,7 +144,7 @@ const ProjectTileName = styled.div<{ centered?: boolean }>`
max-height: 40px;
width: 100%;
word-wrap: break-word;
${props => props.centered && 'text-align: center;'}
${(props) => props.centered && 'text-align: center;'}
`;
const Wrapper = styled.div`
@ -165,6 +153,7 @@ const Wrapper = styled.div`
flex-direction: row;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
`;
const ProjectSectionTitleWrapper = styled.div`
@ -186,13 +175,14 @@ const SectionActions = styled.div`
const SectionAction = styled(Button)`
padding: 6px 12px;
`;
const SectionActionLink = styled(Link)`
margin-right: 8px;
`;
const ProjectSectionTitle = styled.h3`
font-size: 16px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${(props) => props.theme.colors.text.primary};
`;
const ProjectsContainer = styled.div`
@ -201,12 +191,7 @@ const ProjectsContainer = styled.div`
max-width: 825px;
min-width: 288px;
`;
const ProjectGrid = styled.div`
max-width: 780px;
display: grid;
grid-template-columns: 240px 240px 240px;
gap: 20px 10px;
`;
const AddTeamButton = styled(Button)`
padding: 6px 12px;
position: absolute;
@ -214,83 +199,82 @@ const AddTeamButton = styled(Button)`
right: 12px;
`;
const CreateFirstTeam = styled(Button)`
margin-top: 8px;
`;
type ShowNewProject = {
open: boolean;
initialTeamID: null | string;
};
const ProjectLink = styled(Link)``;
const Projects = () => {
const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' });
const { loading, data } = useGetProjectsQuery({ pollInterval: polling.PROJECTS, fetchPolicy: 'cache-and-network' });
useEffect(() => {
document.title = 'Taskcafé';
}, []);
const [createProject] = useCreateProjectMutation({
update: (client, newProject) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, (cache) =>
produce(cache, (draftCache) => {
if (newProject.data) {
draftCache.projects.push({ ...newProject.data.createProject });
}
}),
);
},
});
const [showNewProject, setShowNewProject] = useState<ShowNewProject>({ open: false, initialTeamID: null });
const { user, setUser } = useCurrentUser();
const { user } = useCurrentUser();
const [createTeam] = useCreateTeamMutation({
update: (client, createData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
draftCache.teams.push({ ...createData.data.createTeam });
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, (cache) =>
produce(cache, (draftCache) => {
if (createData.data) {
draftCache.teams.push({ ...createData.data?.createTeam });
}
}),
);
},
});
if (loading) {
return (
<>
<span>loading</span>
</>
);
}
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
const colors = theme.colors.multiColors;
if (data && user) {
const { projects, teams, organizations } = data;
const organizationID = organizations[0].id ?? null;
const personalProjects = projects
.filter((p) => p.team === null)
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
});
const projectTeams = teams
.sort((a, b) => {
const textA = a.name.toUpperCase();
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 {
id: team.id,
name: team.name,
projects: projects
.filter(project => project.team.id === team.id)
.filter((project) => project.team && project.team.id === team.id)
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
}),
};
});
return (
<>
<GlobalTopNavbar onSaveProjectName={() => {}} projectID={null} name={null} />
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<Wrapper>
<ProjectsContainer>
{user.roles.org === 'admin' && (
{true && ( // TODO: add permision check
<AddTeamButton
variant="outline"
onClick={$target => {
onClick={($target) => {
showPopup(
$target,
<Popup
@ -301,7 +285,7 @@ const Projects = () => {
}}
>
<CreateTeamForm
onCreateTeam={teamName => {
onCreateTeam={(teamName) => {
if (organizationID) {
createTeam({ variables: { name: teamName, organizationID } });
hidePopup();
@ -315,45 +299,41 @@ const Projects = () => {
Add Team
</AddTeamButton>
)}
{projectTeams.length === 0 && (
<EmptyStateContent>
<EmptyState width={425} height={425} />
<EmptyStateTitle>No teams exist</EmptyStateTitle>
<EmptyStatePrompt>Create a new team to get started</EmptyStatePrompt>
<CreateFirstTeam
variant="outline"
onClick={$target => {
showPopup(
$target,
<Popup
title="Create team"
tab={0}
onClose={() => {
hidePopup();
<div>
<ProjectSectionTitleWrapper>
<ProjectSectionTitle>Personal Projects</ProjectSectionTitle>
</ProjectSectionTitleWrapper>
<ProjectList>
{personalProjects.map((project, idx) => (
<ProjectListItem key={project.id}>
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
<ProjectTileFade />
<ProjectTileDetails>
<ProjectTileName>{project.name}</ProjectTileName>
</ProjectTileDetails>
</ProjectTile>
</ProjectListItem>
))}
<ProjectListItem>
<ProjectAddTile
onClick={() => {
setShowNewProject({ open: true, initialTeamID: 'no-team' });
}}
>
<CreateTeamForm
onCreateTeam={teamName => {
if (organizationID) {
createTeam({ variables: { name: teamName, organizationID } });
hidePopup();
}
}}
/>
</Popup>,
);
}}
>
Create new team
</CreateFirstTeam>
</EmptyStateContent>
)}
{projectTeams.map(team => {
<ProjectTileFade />
<ProjectAddTileDetails>
<ProjectTileName centered>Create new project</ProjectTileName>
</ProjectAddTileDetails>
</ProjectAddTile>
</ProjectListItem>
</ProjectList>
</div>
{projectTeams.map((team) => {
return (
<div key={team.id}>
<ProjectSectionTitleWrapper>
<ProjectSectionTitle>{team.name}</ProjectSectionTitle>
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
{true && ( // TODO: add permision check
<SectionActions>
<SectionActionLink to={`/teams/${team.id}`}>
<SectionAction variant="outline">Projects</SectionAction>
@ -378,7 +358,7 @@ const Projects = () => {
</ProjectTile>
</ProjectListItem>
))}
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
{true && ( // TODO: add permision check
<ProjectListItem>
<ProjectAddTile
onClick={() => {
@ -401,7 +381,7 @@ const Projects = () => {
initialTeamID={showNewProject.initialTeamID}
onCreateProject={(name, teamID) => {
if (user) {
createProject({ variables: { teamID, name, userID: user.id } });
createProject({ variables: { teamID, name } });
setShowNewProject({ open: false, initialTeamID: null });
}
}}
@ -416,7 +396,7 @@ const Projects = () => {
</>
);
}
return <div>Error!</div>;
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
};
export default Projects;

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,10 @@
import React, { useState, useContext } from 'react';
import React, { useState } from 'react';
import Input from 'shared/components/Input';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import polling from 'shared/utils/polling';
import Button from 'shared/components/Button';
import UserContext, { useCurrentUser, PermissionLevel, PermissionObjectType } from 'App/context';
import { useCurrentUser } from 'App/context';
import Select from 'shared/components/Select';
import {
useGetTeamQuery,
@ -13,8 +14,6 @@ import {
useUpdateTeamMemberRoleMutation,
GetTeamQuery,
GetTeamDocument,
MeDocument,
MeQuery,
} from 'shared/generated/graphql';
import { UserPlus, Checkmark } from 'shared/icons';
import styled, { css } from 'styled-components/macro';
@ -22,6 +21,8 @@ import { usePopup, Popup } from 'shared/components/PopupMenu';
import TaskAssignee from 'shared/components/TaskAssignee';
import Member from 'shared/components/Member';
import ControlledInput from 'shared/components/ControlledInput';
import NOOP from 'shared/utils/noop';
import { mixin } from 'shared/utils/styles';
const MemberListWrapper = styled.div`
flex: 1 1;
@ -35,7 +36,7 @@ const UserMember = styled(Member)`
padding: 4px 0;
cursor: pointer;
&: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;
`;
@ -56,8 +57,8 @@ const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, teamMe
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
<TeamMemberList>
{users
.filter(u => u.id !== teamMembers.find(p => p.id === u.id)?.id)
.map(user => (
.filter((u) => u.id !== teamMembers.find((p) => p.id === u.id)?.id)
.map((user) => (
<UserMember
key={user.id}
onCardMemberClick={() => onAddTeamMember(user.id)}
@ -115,27 +116,28 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
position: relative;
text-decoration: none;
${props =>
${(props) =>
props.disabled
? css`
user-select: none;
pointer-events: none;
color: rgba(${props.theme.colors.text.primary}, 0.4);
color: ${mixin.rgba(props.theme.colors.text.primary, 0.4)};
`
: css`
cursor: pointer;
&:hover {
background: rgb(115, 103, 240);
background: ${props.theme.colors.primary};
}
`}
`;
export const Content = styled.div`
padding: 0 12px 12px;
`;
export const CurrentPermission = styled.span`
margin-left: 4px;
color: rgba(${props => props.theme.colors.text.secondary}, 0.4);
color: ${(props) => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
`;
export const Separator = styled.div`
@ -146,13 +148,13 @@ export const Separator = styled.div`
export const WarningText = styled.span`
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;
`;
export const DeleteDescription = styled.div`
font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${(props) => props.theme.colors.text.primary};
`;
export const RemoveMemberButton = styled(Button)`
@ -160,6 +162,7 @@ export const RemoveMemberButton = styled(Button)`
padding: 6px 12px;
width: 100%;
`;
type TeamRoleManagerPopupProps = {
currentUserID: string;
subject: User;
@ -218,13 +221,13 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
<MiniProfileActions>
<MiniProfileActionWrapper>
{permissions
.filter(p => (subject.role && subject.role.code === 'owner') || p.code !== 'owner')
.map(perm => (
.filter((p) => (subject.role && subject.role.code === 'owner') || p.code !== 'owner')
.map((perm) => (
<MiniProfileActionItem
disabled={subject.role && perm.code !== subject.role.code && !canChangeRole}
key={perm.code}
onClick={() => {
if (onChangeRole && subject.role && perm.code !== subject.role.code) {
if (subject.role && perm.code !== subject.role.code) {
switch (perm.code) {
case 'owner':
onChangeRole(RoleCode.Owner);
@ -253,7 +256,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
{subject.role && subject.role.code === 'owner' && (
<>
<Separator />
<WarningText>You can't change roles because there must be an owner.</WarningText>
<WarningText>You can not change roles because there must be an owner.</WarningText>
</>
)}
</MiniProfileActions>
@ -273,8 +276,8 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
<Select
label="New projects owner"
value={orphanedProjectOwner}
onChange={value => setOrphanedProjectOwner(value)}
options={members.filter(m => m.id !== subject.id).map(m => ({ label: m.fullName, value: m.id }))}
onChange={(value) => setOrphanedProjectOwner(value)}
options={members.filter((m) => m.id !== subject.id).map((m) => ({ label: m.fullName, value: m.id }))}
/>
</>
)}
@ -304,14 +307,14 @@ const MemberItemOption = styled(Button)`
`;
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`
display: flex;
flex-flow: row wrap;
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;
padding: 12px 0 12px 40px;
position: relative;
@ -335,11 +338,11 @@ const MemberProfile = styled(TaskAssignee)`
`;
const MemberItemName = styled.p`
color: rgba(${props => props.theme.colors.text.secondary});
color: ${(props) => props.theme.colors.text.secondary};
`;
const MemberItemUsername = styled.p`
color: rgba(${props => props.theme.colors.text.primary});
color: ${(props) => props.theme.colors.text.primary};
`;
const MemberListHeader = styled.div`
@ -348,12 +351,12 @@ const MemberListHeader = styled.div`
`;
const ListTitle = styled.h3`
font-size: 18px;
color: rgba(${props => props.theme.colors.text.secondary});
color: ${(props) => props.theme.colors.text.secondary};
margin-bottom: 12px;
`;
const ListDesc = styled.span`
font-size: 16px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${(props) => props.theme.colors.text.primary};
`;
const FilterSearch = styled(Input)`
margin: 0;
@ -385,11 +388,11 @@ const FilterTabItem = styled.li`
font-weight: 700;
text-decoration: none;
padding: 6px 8px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${(props) => props.theme.colors.text.primary};
&:hover {
border-radius: 6px;
background: rgba(${props => props.theme.colors.primary});
color: rgba(${props => props.theme.colors.text.secondary});
background: ${(props) => props.theme.colors.primary};
color: ${(props) => props.theme.colors.text.secondary};
}
`;
@ -417,8 +420,12 @@ type MembersProps = {
const Members: React.FC<MembersProps> = ({ teamID }) => {
const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
const { user, setUserRoles } = useCurrentUser();
const { loading, data } = useGetTeamQuery({
variables: { teamID },
fetchPolicy: 'cache-and-network',
pollInterval: polling.MEMBERS,
});
const { user } = useCurrentUser();
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”.';
const [createTeamMember] = useCreateTeamMemberMutation({
@ -426,47 +433,36 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
updateApolloCache<GetTeamQuery>(
client,
GetTeamDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
draftCache.findTeam.members.push({
...response.data.createTeamMember.teamMember,
member: { __typename: 'MemberList', projects: [], teams: [] },
owned: { __typename: 'OwnedList', projects: [], teams: [] },
});
}
}),
{ teamID },
);
},
});
const [updateTeamMemberRole] = useUpdateTeamMemberRoleMutation({
onCompleted: r => {
if (user) {
setUserRoles(
produce(user.roles, draftRoles => {
draftRoles.teams.set(r.updateTeamMemberRole.teamID, r.updateTeamMemberRole.member.role.code);
}),
);
}
},
});
const [updateTeamMemberRole] = useUpdateTeamMemberRoleMutation();
const [deleteTeamMember] = useDeleteTeamMemberMutation({
update: (client, response) => {
updateApolloCache<GetTeamQuery>(
client,
GetTeamDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
draftCache.findTeam.members = cache.findTeam.members.filter(
member => member.id !== response.data.deleteTeamMember.userID,
(member) => member.id !== response.data?.deleteTeamMember.userID,
);
}),
{ teamID },
);
},
});
if (loading) {
return <span>loading</span>;
}
if (data && user) {
return (
@ -486,15 +482,15 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
</ListDesc>
<ListActions>
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, data.findTeam.id) && (
{true && ( // TODO: add permission check
<InviteMemberButton
onClick={$target => {
onClick={($target) => {
showPopup(
$target,
<UserManagementPopup
users={data.users}
teamMembers={data.findTeam.members}
onAddTeamMember={userID => {
onAddTeamMember={(userID) => {
createTeamMember({ variables: { userID, teamID } });
}}
/>,
@ -508,9 +504,9 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
</ListActions>
</MemberListHeader>
<MemberList>
{data.findTeam.members.map(member => (
{data.findTeam.members.map((member) => (
<MemberListItem>
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} />
<MemberProfile showRoleIcons size={32} onMemberProfile={NOOP} member={member} />
<MemberListItemDetails>
<MemberItemName>{member.fullName}</MemberItemName>
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
@ -519,22 +515,23 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
<MemberItemOption variant="flat">On 2 projects</MemberItemOption>
<MemberItemOption
variant="outline"
onClick={$target => {
onClick={($target) => {
showPopup(
$target,
<TeamRoleManagerPopup
currentUserID={user.id ?? ''}
currentUserID={user ?? ''}
subject={member}
members={data.findTeam.members}
warning={member.role && member.role.code === 'owner' ? warning : null}
canChangeRole={user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)}
onChangeRole={roleCode => {
// canChangeRole={user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)} TODO: add permission check
canChangeRole={true}
onChangeRole={(roleCode) => {
updateTeamMemberRole({ variables: { userID: member.id, teamID, roleCode } });
}}
onRemoveFromTeam={
member.role && member.role.code === 'owner'
? undefined
: newOwnerID => {
: (newOwnerID) => {
deleteTeamMember({ variables: { teamID, newOwnerID, userID: member.id } });
hidePopup();
}
@ -554,7 +551,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
);
}
return <div>error</div>;
return <div>loading</div>;
};
export default Members;

View File

@ -8,6 +8,8 @@ import {
} from 'shared/generated/graphql';
import { Link } from 'react-router-dom';
import Input from 'shared/components/Input';
import theme from 'App/ThemeStyles';
import polling from 'shared/utils/polling';
const FilterSearch = styled(Input)`
margin: 0;
@ -34,11 +36,11 @@ const FilterTabItem = styled.li`
font-weight: 700;
text-decoration: none;
padding: 6px 8px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
&:hover {
border-radius: 6px;
background: rgba(${props => props.theme.colors.primary});
color: rgba(${props => props.theme.colors.text.secondary});
background: ${props => props.theme.colors.primary};
color: ${props => props.theme.colors.text.secondary};
}
`;
@ -55,7 +57,7 @@ const FilterTabTitle = styled.h2`
`;
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-position: 50%;
color: #fff;
@ -147,17 +149,18 @@ const ProjectListWrapper = styled.div`
flex: 1 1;
`;
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
const colors = theme.colors.multiColors;
type TeamProjectsProps = {
teamID: string;
};
const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
if (loading) {
return <span>loading</span>;
}
const { loading, data } = useGetTeamQuery({
variables: { teamID },
fetchPolicy: 'cache-and-network',
pollInterval: polling.TEAM_PROJECTS,
});
if (data) {
return (
<ProjectsContainer>
@ -188,7 +191,7 @@ const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
</ProjectsContainer>
);
}
return <span>error</span>;
return <span>loading</span>;
};
export default TeamProjects;

View File

@ -1,24 +1,22 @@
import React, { useState, useContext, useEffect } from 'react';
import styled, { css } from 'styled-components/macro';
import { MENU_TYPES } from 'shared/components/TopNavbar';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import updateApolloCache from 'shared/utils/cache';
import { Route, Switch, useRouteMatch, Redirect } from 'react-router';
import Members from './Members';
import Projects from './Projects';
import { Route, Switch, useRouteMatch, Redirect, useParams, useHistory } from 'react-router';
import {
useGetTeamQuery,
useDeleteTeamMutation,
GetProjectsDocument,
GetProjectsQuery,
} from 'shared/generated/graphql';
import { useParams, useHistory, useLocation } from 'react-router';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { History } from 'history';
import produce from 'immer';
import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import UserContext, { PermissionObjectType, PermissionLevel, useCurrentUser } from 'App/context';
import { useCurrentUser } from 'App/context';
import NOOP from 'shared/utils/noop';
import Members from './Members';
import Projects from './Projects';
const OuterWrapper = styled.div`
display: flex;
@ -35,7 +33,7 @@ const Wrapper = styled.div`
`;
type TeamPopupProps = {
history: History<History.PoorMansUnknown>;
history: History<any>;
name: string;
teamID: string;
};
@ -46,9 +44,9 @@ export const TeamPopup: React.FC<TeamPopupProps> = ({ history, name, teamID }) =
update: (client, deleteData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data.deleteTeam.team.id);
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data?.deleteTeam.team.id);
draftCache.projects = cache.projects.filter(
(project: any) => project.team.id !== deleteData.data.deleteTeam.team.id,
(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}>
<TeamSettings
onDeleteTeam={() => {
setTab(1, 340);
setTab(1, { width: 340 });
}}
/>
</Popup>
@ -87,24 +85,22 @@ type TeamsRouteProps = {
const Teams = () => {
const { teamID } = useParams<TeamsRouteProps>();
const history = useHistory();
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
const { loading, data } = useGetTeamQuery({
variables: { teamID },
onCompleted: resp => {
document.title = `${resp.findTeam.name} | Taskcafé`;
},
});
const { user } = useCurrentUser();
const [currentTab, setCurrentTab] = useState(0);
const match = useRouteMatch();
useEffect(() => {
document.title = 'Teams | Taskcafé';
}, []);
if (loading) {
return (
<>
<span>loading</span>
</>
);
}
if (data && user) {
/*
TODO: re-add permission check
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
return <Redirect to="/" />;
}
*/
return (
<>
<GlobalTopNavbar
@ -117,7 +113,7 @@ const Teams = () => {
setCurrentTab(tab);
}}
popupContent={<TeamPopup history={history} name={data.findTeam.name} teamID={teamID} />}
onSaveProjectName={() => {}}
onSaveProjectName={NOOP}
projectID={null}
name={data.findTeam.name}
/>
@ -136,7 +132,21 @@ const Teams = () => {
</>
);
}
return <div>Error!</div>;
return (
<GlobalTopNavbar
menuType={[
{ name: 'Projects', link: `${match.url}` },
{ name: 'Members', link: `${match.url}/members` },
]}
currentTab={currentTab}
onSetTab={tab => {
setCurrentTab(tab);
}}
onSaveProjectName={NOOP}
projectID={null}
name={null}
/>
);
};
export default Teams;

View File

@ -1,145 +1,37 @@
import React from 'react';
import ReactDOM from 'react-dom';
import axios from 'axios';
import createAuthRefreshInterceptor from 'axios-auth-refresh';
import { ApolloProvider } from '@apollo/react-hooks';
import { ApolloClient } from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error';
import { ApolloClient } from '@apollo/client';
import { ApolloProvider } from '@apollo/client/react';
import { enableMapSet } from 'immer';
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
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 cache from './App/cache';
import App from './App';
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
enableMapSet();
let forward$;
let isRefreshing = false;
let pendingRequests: any = [];
const refreshAuthLogic = (failedRequest: any) =>
axios.post('/auth/refresh_token', {}, { withCredentials: true }).then(tokenRefreshResponse => {
setAccessToken(tokenRefreshResponse.data.accessToken);
failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`;
return Promise.resolve();
});
createAuthRefreshInterceptor(axios, refreshAuthLogic);
const resolvePendingRequests = () => {
pendingRequests.map((callback: any) => callback());
pendingRequests = [];
};
const resolvePromise = (resolve: () => void) => {
pendingRequests.push(() => resolve());
};
const resetPendingRequests = () => {
pendingRequests = [];
};
const setRefreshing = (newVal: boolean) => {
isRefreshing = newVal;
};
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
if (err.extensions && err.extensions.code) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
if (!isRefreshing) {
setRefreshing(true);
forward$ = fromPromise(
getNewToken()
.then((response: any) => {
setAccessToken(response.accessToken);
resolvePendingRequests();
return response.accessToken;
})
.catch(() => {
resetPendingRequests();
// TODO
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
return undefined;
})
.finally(() => {
setRefreshing(false);
}),
).filter(value => Boolean(value));
} else {
forward$ = fromPromise(new Promise(resolvePromise));
}
return forward$.flatMap(() => forward(operation));
default:
// pass
}
}
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`);
}
return undefined;
});
const requestLink = new ApolloLink(
(operation, forward) =>
new Observable((observer: any) => {
let handle: any;
Promise.resolve(operation)
.then((op: any) => {
const accessToken = getAccessToken();
if (accessToken) {
op.setContext({
headers: {
Authorization: `Bearer ${accessToken}`,
dayjs.extend(isSameOrAfter);
dayjs.extend(weekday);
dayjs.extend(isBetween);
dayjs.extend(customParseFormat);
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)
},
});
}
})
.then(() => {
handle = forward(operation).subscribe({
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
})
.catch(observer.error.bind(observer));
return () => {
if (handle) {
handle.unsubscribe();
}
};
}),
);
const client = new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
);
}
if (networkError) {
console.log(`[Network error]: ${networkError}`);
}
}),
errorLink,
requestLink,
new HttpLink({
uri: '/graphql',
credentials: 'same-origin',
}),
]),
cache,
});
const client = new ApolloClient({ uri: '/graphql', cache });
console.log('cloient', client);
ReactDOM.render(
<ApolloProvider client={client}>
<App />

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

View File

@ -1,18 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import AddList from '.';
export default {
component: AddList,
title: 'AddList',
parameters: {
backgrounds: [
{ name: 'gray', value: '#262c49', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
export const Default = () => {
return <AddList onSave={action('on save')} />;
};

View File

@ -1,5 +1,5 @@
import styled, { css } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea/lib';
import TextareaAutosize from 'react-autosize-textarea';
import { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button';
@ -67,7 +67,7 @@ export const ListNameEditorWrapper = styled.div`
display: flex;
`;
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;
box-shadow: inset 0 0 0 2px #0079bf;
transition: margin 85ms ease-in, background 85ms ease-in;
@ -91,7 +91,7 @@ export const ListNameEditor = styled(TextareaAutosize)`
color: #c2c6dc;
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';
type NameEditorProps = {
buttonLabel?: string;
onSave: (listName: string) => void;
onCancel: () => void;
};
const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCancel, buttonLabel = 'Save' }) => {
const $editorRef = useRef<HTMLTextAreaElement>(null);
const [listName, setListName] = useState('');
useEffect(() => {
@ -28,6 +29,11 @@ const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
$editorRef.current.focus();
}
});
const onSave = (newName: string) => {
if (newName.replace(/\s+/g, '') !== '') {
handleSave(newName);
}
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
@ -43,6 +49,7 @@ const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
<ListNameEditorWrapper>
<ListNameEditor
ref={$editorRef}
height={40}
onKeyDown={onKeyDown}
value={listName}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setListName(e.currentTarget.value)}
@ -60,7 +67,7 @@ const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
}
}}
>
Save
{buttonLabel}
</AddListButton>
<CancelAdd onClick={() => onCancel()}>
<Cross width={16} height={16} />
@ -98,7 +105,7 @@ const AddList: React.FC<AddListProps> = ({ onSave }) => {
) : (
<Placeholder>
<AddIconWrapper>
<Plus size={12} color="#c2c6dc" />
<Plus width={12} height={12} />
</AddIconWrapper>
Add another list
</Placeholder>

View File

@ -1,59 +0,0 @@
import React, { useRef } from 'react';
import Admin from '.';
import { theme } from 'App/ThemeStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import { ThemeProvider } from 'styled-components';
import { action } from '@storybook/addon-actions';
export default {
component: Admin,
title: 'Admin',
parameters: {
backgrounds: [
{ name: 'gray', value: '#f8f8f8', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<ThemeProvider theme={theme}>
<Admin
onInviteUser={action('invite user')}
canInviteUser
initialTab={1}
onUpdateUserPassword={action('update user password')}
onDeleteUser={action('delete user')}
users={[
{
id: '1',
username: 'jordanthedev',
email: 'jordan@jordanthedev.com',
role: { code: 'admin', name: 'Admin' },
fullName: 'Jordan Knott',
profileIcon: {
bgColor: '#fff',
initials: 'JK',
url: null,
},
owned: {
teams: [{ id: '1', name: 'Team' }],
projects: [{ id: '2', name: 'Project' }],
},
member: {
teams: [],
projects: [],
},
},
]}
onAddUser={action('add user')}
/>
</ThemeProvider>
</>
);
};

View File

@ -1,15 +1,14 @@
import React, { useState, useRef } from 'react';
import { UserPlus, Checkmark } from 'shared/icons';
import styled, { css } from 'styled-components';
import TaskAssignee from 'shared/components/TaskAssignee';
import Select from 'shared/components/Select';
import { User, Plus, Lock, Pencil, Trash } from 'shared/icons';
import { User, UserPlus, Checkmark } from 'shared/icons';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { RoleCode, useUpdateUserRoleMutation } from 'shared/generated/graphql';
import Input from 'shared/components/Input';
import Member from 'shared/components/Member';
import Button from 'shared/components/Button';
import NOOP from 'shared/utils/noop';
import { mixin } from 'shared/utils/styles';
export const RoleCheckmark = styled(Checkmark)`
padding-left: 4px;
@ -60,22 +59,23 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
? css`
user-select: none;
pointer-events: none;
color: rgba(${props.theme.colors.text.primary}, 0.4);
color: ${mixin.rgba(props.theme.colors.text.primary, 0.4)};
`
: css`
cursor: pointer;
&:hover {
background: rgb(115, 103, 240);
background: ${props.theme.colors.primary};
}
`}
`;
export const Content = styled.div`
padding: 0 12px 12px;
`;
export const CurrentPermission = styled.span`
margin-left: 4px;
color: rgba(${props => props.theme.colors.text.secondary}, 0.4);
color: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
`;
export const Separator = styled.div`
@ -86,13 +86,13 @@ export const Separator = styled.div`
export const WarningText = styled.span`
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;
`;
export const DeleteDescription = styled.div`
font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
`;
export const RemoveMemberButton = styled(Button)`
@ -100,12 +100,13 @@ export const RemoveMemberButton = styled(Button)`
padding: 6px 12px;
width: 100%;
`;
type TeamRoleManagerPopupProps = {
user: User;
users: Array<User>;
warning?: string | null;
canChangeRole: boolean;
onChangeRole: (roleCode: RoleCode) => void;
canChangeRole?: boolean;
onChangeRole?: (roleCode: RoleCode) => void;
updateUserPassword?: (user: TaskUser, password: string) => void;
onDeleteUser?: (userID: string, newOwnerID: string | null) => void;
};
@ -120,7 +121,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
onChangeRole,
}) => {
const { hidePopup, setTab } = usePopup();
const [userPass, setUserPass] = useState({ pass: '', passConfirm: '' });
const [userPass] = useState({ pass: '', passConfirm: '' });
const [deleteUser, setDeleteUser] = useState<{ label: string; value: string } | null>(null);
const hasOwned = user.owned.projects.length !== 0 || user.owned.teams.length !== 0;
return (
@ -195,7 +196,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
{user.role && user.role.code === 'owner' && (
<>
<Separator />
<WarningText>You can't change roles because there must be an owner.</WarningText>
<WarningText>You can not change roles because there must be an owner.</WarningText>
</>
)}
</MiniProfileActions>
@ -209,7 +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>
Choose a new user to take over ownership of this user's teams & projects.
Choose a new user to take over ownership of the users teams & projects.
</DeleteDescription>
<UserSelect
onChange={v => setDeleteUser(v)}
@ -239,7 +240,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
</DeleteDescription>
<DeleteDescription>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} teams.`}</DeleteDescription>
<UserSelect onChange={() => {}} value={null} options={users.map(u => ({ label: u.fullName, value: u.id }))} />
<UserSelect onChange={NOOP} value={null} options={users.map(u => ({ label: u.fullName, value: u.id }))} />
<UserPassConfirmButton
onClick={() => {
// onDeleteUser();
@ -253,7 +254,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
<Content>
<DeleteDescription>
You can either set the user's new password directly or send the user an email allowing them to reset their
You can either set the users new password directly or send the user an email allowing them to reset their
own password.
</DeleteDescription>
<UserPassBar>
@ -291,6 +292,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
</>
);
};
const UserSelect = styled(Select)`
margin: 8px 0;
padding: 8px 0;
@ -299,6 +301,7 @@ const UserSelect = styled(Select)`
const NewUserPassInput = styled(Input)`
margin: 8px 0;
`;
const InviteMemberButton = styled(Button)`
padding: 7px 12px;
`;
@ -307,6 +310,7 @@ const UserPassBar = styled.div`
display: flex;
padding-top: 8px;
`;
const UserPassConfirmButton = styled(Button)`
width: 100%;
padding: 7px 12px;
@ -330,14 +334,14 @@ const MemberItemOption = styled(Button)`
`;
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`
display: flex;
flex-flow: row wrap;
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;
padding: 12px 0 12px 40px;
position: relative;
@ -361,11 +365,11 @@ const MemberProfile = styled(TaskAssignee)`
`;
const MemberItemName = styled.p`
color: rgba(${props => props.theme.colors.text.secondary});
color: ${props => props.theme.colors.text.secondary};
`;
const MemberItemUsername = styled.p`
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
`;
const MemberListHeader = styled.div`
@ -374,12 +378,12 @@ const MemberListHeader = styled.div`
`;
const ListTitle = styled.h3`
font-size: 18px;
color: rgba(${props => props.theme.colors.text.secondary});
color: ${props => props.theme.colors.text.secondary};
margin-bottom: 12px;
`;
const ListDesc = styled.span`
font-size: 16px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
`;
const FilterSearch = styled(Input)`
margin: 0;
@ -397,104 +401,6 @@ const MemberListWrapper = styled.div`
flex: 1 1;
`;
const Root = styled.div`
.ag-theme-material {
--ag-foreground-color: #c2c6dc;
--ag-secondary-foreground-color: #c2c6dc;
--ag-background-color: transparent;
--ag-header-background-color: transparent;
--ag-header-foreground-color: #c2c6dc;
--ag-border-color: #414561;
--ag-row-hover-color: #262c49;
--ag-header-cell-hover-background-color: #262c49;
--ag-checkbox-unchecked-color: #c2c6dc;
--ag-checkbox-indeterminate-color: rgba(115, 103, 240);
--ag-selected-row-background-color: #262c49;
--ag-material-primary-color: rgba(115, 103, 240);
--ag-material-accent-color: rgba(115, 103, 240);
}
.ag-theme-material ::-webkit-scrollbar {
width: 12px;
}
.ag-theme-material ::-webkit-scrollbar-track {
background: #262c49;
border-radius: 20px;
}
.ag-theme-material ::-webkit-scrollbar-thumb {
background: #7367f0;
border-radius: 20px;
}
.ag-header-cell-text {
color: #fff;
font-weight: 700;
}
`;
const Header = styled.div`
border-bottom: 1px solid #e2e2e2;
flex-direction: row;
box-sizing: border-box;
display: flex;
white-space: nowrap;
width: 100%;
overflow: hidden;
background: transparent;
border-bottom-color: #414561;
color: #fff;
height: 112px;
min-height: 112px;
`;
const EditUserIcon = styled(Pencil)``;
const LockUserIcon = styled(Lock)``;
const DeleteUserIcon = styled(Trash)``;
type ActionButtonProps = {
onClick: ($target: React.RefObject<HTMLElement>) => void;
};
const ActionButtonWrapper = styled.div`
margin-right: 8px;
cursor: pointer;
display: inline-flex;
`;
const ActionButton: React.FC<ActionButtonProps> = ({ onClick, children }) => {
const $wrapper = useRef<HTMLDivElement>(null);
return (
<ActionButtonWrapper onClick={() => onClick($wrapper)} ref={$wrapper}>
{children}
</ActionButtonWrapper>
);
};
const ActionButtons = (params: any) => {
return (
<>
<ActionButton onClick={() => {}}>
<EditUserIcon width={16} height={16} />
</ActionButton>
<ActionButton onClick={$target => params.onDeleteUser($target, params.value)}>
<DeleteUserIcon width={16} height={16} />
</ActionButton>
</>
);
};
const Wrapper = styled.div`
background: #eff2f7;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
`;
const Container = styled.div`
padding: 2.2rem;
display: flex;
@ -525,6 +431,7 @@ const TabNavItem = styled.li`
display: block;
position: relative;
`;
const TabNavItemButton = styled.button<{ active: boolean }>`
cursor: pointer;
display: flex;
@ -537,14 +444,18 @@ const TabNavItemButton = styled.button<{ active: boolean }>`
width: 100%;
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 {
color: rgba(115, 103, 240);
color: ${props => `${props.theme.colors.primary}`};
}
&: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`
text-align: left;
@ -560,8 +471,8 @@ const TabNavLine = styled.span<{ top: number }>`
transform: scaleX(1);
top: ${props => props.top}px;
background: linear-gradient(30deg, rgba(115, 103, 240), rgba(115, 103, 240));
box-shadow: 0 0 8px 0 rgba(115, 103, 240);
background: linear-gradient(30deg, ${props => props.theme.colors.primary}, ${props => props.theme.colors.primary});
box-shadow: 0 0 8px 0 ${props => props.theme.colors.primary};
display: block;
position: absolute;
transition: all 0.2s ease;
@ -607,7 +518,7 @@ const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
}}
>
<TabNavItemButton active={active}>
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
<TabItemUser width={14} height={14} active={active} />
<TabNavItemSpan>{name}</TabNavItemSpan>
</TabNavItemButton>
</TabNavItem>
@ -620,8 +531,10 @@ type AdminProps = {
onDeleteUser: (userID: string, newOwnerID: string | null) => void;
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
users: Array<User>;
invitedUsers: Array<InvitedUserAccount>;
canInviteUser: boolean;
onUpdateUserPassword: (user: TaskUser, password: string) => void;
onDeleteInvitedUser: (invitedUserID: string) => void;
};
const Admin: React.FC<AdminProps> = ({
@ -630,14 +543,16 @@ const Admin: React.FC<AdminProps> = ({
onUpdateUserPassword,
canInviteUser,
onDeleteUser,
onDeleteInvitedUser,
onInviteUser,
invitedUsers,
users,
}) => {
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”.';
const [currentTop, setTop] = useState(initialTab * 48);
const [currentTab, setTab] = useState(initialTab);
const { showPopup, hidePopup } = usePopup();
const { showPopup } = usePopup();
const $tabNav = useRef<HTMLDivElement>(null);
const [updateUserRole] = useUpdateUserRoleMutation();
@ -647,6 +562,7 @@ const Admin: React.FC<AdminProps> = ({
<TabNavContent>
{items.map((item, idx) => (
<NavItem
key={item.name}
onClick={(tab, top) => {
if ($tabNav && $tabNav.current) {
const pos = $tabNav.current.getBoundingClientRect();
@ -666,7 +582,7 @@ const Admin: React.FC<AdminProps> = ({
<TabContent>
<MemberListWrapper>
<MemberListHeader>
<ListTitle>{`Members (${users.length})`}</ListTitle>
<ListTitle>{`Members (${users.length + invitedUsers.length})`}</ListTitle>
<ListDesc>
Organization admins can create / manage / delete all projects & teams. Members only have access to teams
or projects they have been added to.
@ -690,7 +606,7 @@ const Admin: React.FC<AdminProps> = ({
const projectTotal = member.owned.projects.length + member.member.projects.length;
return (
<MemberListItem>
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} />
<MemberProfile showRoleIcons size={32} onMemberProfile={NOOP} member={member} />
<MemberListItemDetails>
<MemberItemName>{member.fullName}</MemberItemName>
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
@ -724,6 +640,65 @@ const Admin: React.FC<AdminProps> = ({
</MemberListItem>
);
})}
{invitedUsers.map(member => {
return (
<MemberListItem>
<MemberProfile
showRoleIcons
size={32}
onMemberProfile={NOOP}
member={{
id: member.id,
fullName: member.email,
profileIcon: {
bgColor: '#fff',
url: null,
initials: member.email.charAt(0),
},
}}
/>
<MemberListItemDetails>
<MemberItemName>{member.email}</MemberItemName>
<MemberItemUsername>Invited</MemberItemUsername>
</MemberListItemDetails>
<MemberItemOptions>
<MemberItemOption
variant="outline"
onClick={$target => {
showPopup(
$target,
<TeamRoleManagerPopup
user={{
id: member.id,
fullName: member.email,
profileIcon: {
bgColor: '#fff',
url: null,
initials: member.email.charAt(0),
},
member: {
teams: [],
projects: [],
},
owned: {
teams: [],
projects: [],
},
}}
users={users}
onDeleteUser={() => {
onDeleteInvitedUser(member.id);
}}
/>,
);
}}
>
Manage
</MemberItemOption>
</MemberItemOptions>
</MemberListItem>
);
})}
</MemberList>
</MemberListWrapper>
</TabContent>

View File

@ -1,138 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles';
import styled, { ThemeProvider } from 'styled-components';
import Button from '.';
export default {
component: Button,
title: 'Button',
parameters: {
backgrounds: [
{ name: 'gray', value: '#f8f8f8', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
const ButtonRow = styled.div`
display: flex;
align-items: center;
justify-items: center;
margin: 25px;
width: 100%;
& > button {
margin-right: 1.5rem;
}
`;
export const Default = () => {
return (
<>
<BaseStyles />
<NormalizeStyles />
<ThemeProvider theme={theme}>
<ButtonRow>
<Button>Primary</Button>
<Button color="success">Success</Button>
<Button color="danger">Danger</Button>
<Button color="warning">Warning</Button>
<Button color="dark">Dark</Button>
<Button disabled>Disabled</Button>
</ButtonRow>
<ButtonRow>
<Button variant="outline">Primary</Button>
<Button variant="outline" color="success">
Success
</Button>
<Button variant="outline" color="danger">
Danger
</Button>
<Button variant="outline" color="warning">
Warning
</Button>
<Button variant="outline" color="dark">
Dark
</Button>
<Button variant="outline" disabled>
Disabled
</Button>
</ButtonRow>
<ButtonRow>
<Button variant="flat">Primary</Button>
<Button variant="flat" color="success">
Success
</Button>
<Button variant="flat" color="danger">
Danger
</Button>
<Button variant="flat" color="warning">
Warning
</Button>
<Button variant="flat" color="dark">
Dark
</Button>
<Button variant="flat" disabled>
Disabled
</Button>
</ButtonRow>
<ButtonRow>
<Button variant="lineDown">Primary</Button>
<Button variant="lineDown" color="success">
Success
</Button>
<Button variant="lineDown" color="danger">
Danger
</Button>
<Button variant="lineDown" color="warning">
Warning
</Button>
<Button variant="lineDown" color="dark">
Dark
</Button>
<Button variant="lineDown" disabled>
Disabled
</Button>
</ButtonRow>
<ButtonRow>
<Button variant="gradient">Primary</Button>
<Button variant="gradient" color="success">
Success
</Button>
<Button variant="gradient" color="danger">
Danger
</Button>
<Button variant="gradient" color="warning">
Warning
</Button>
<Button variant="gradient" color="dark">
Dark
</Button>
<Button variant="gradient" disabled>
Disabled
</Button>
</ButtonRow>
<ButtonRow>
<Button variant="relief">Primary</Button>
<Button variant="relief" color="success">
Success
</Button>
<Button variant="relief" color="danger">
Danger
</Button>
<Button variant="relief" color="warning">
Warning
</Button>
<Button variant="relief" color="dark">
Dark
</Button>
<Button variant="relief" disabled>
Disabled
</Button>
</ButtonRow>
</ThemeProvider>
</>
);
};

View File

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

View File

@ -1,174 +0,0 @@
import React, { useRef } from 'react';
import { action } from '@storybook/addon-actions';
import LabelColors from 'shared/constants/labelColors';
import Card from '.';
export default {
component: Card,
title: 'Card',
parameters: {
backgrounds: [
{ name: 'gray', value: '#f8f8f8', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
const labelData: Array<ProjectLabel> = [
{
id: 'development',
name: 'Development',
createdDate: new Date().toString(),
labelColor: {
id: '1',
colorHex: LabelColors.BLUE,
name: 'blue',
position: 1,
},
},
];
export const Default = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
taskID="1"
taskGroupID="1"
description=""
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
/>
);
};
export const Labels = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
taskID="1"
taskGroupID="1"
description=""
ref={$ref}
title="Hello, world"
labels={labelData}
onClick={action('on click')}
onContextMenu={action('on context click')}
/>
);
};
export const Badges = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
taskID="1"
taskGroupID="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
);
};
export const PastDue = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
taskID="1"
taskGroupID="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: true, formattedDate: 'Oct 26, 2020' }}
/>
);
};
export const Everything = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
taskID="1"
taskGroupID="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
members={[
{
id: '1',
fullName: 'Jordan Knott',
profileIcon: {
bgColor: '#0079bf',
initials: 'JK',
url: null,
},
},
]}
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
);
};
export const Members = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
description={null}
taskID="1"
taskGroupID="1"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
members={[
{
id: '1',
fullName: 'Jordan Knott',
profileIcon: {
bgColor: '#0079bf',
initials: 'JK',
url: null,
},
},
]}
labels={[]}
/>
);
};
export const Editable = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
taskID="1"
taskGroupID="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
editable
onEditCard={action('edit card')}
/>
);
};

View File

@ -1,32 +1,34 @@
import styled, { css, keyframes } from 'styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mixin } from 'shared/utils/styles';
import TextareaAutosize from 'react-autosize-textarea';
import { CheckCircle, CheckSquareOutline } from 'shared/icons';
import { RefObject } from 'react';
import { CheckCircle, CheckSquareOutline, Clock } from 'shared/icons';
import TaskAssignee from 'shared/components/TaskAssignee';
export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
box-shadow: 0 0 0 2px rgba(${props => props.theme.colors.bg.secondary}),
inset 0 0 0 1px rgba(${props => props.theme.colors.bg.secondary}, 0.07);
box-shadow: 0 0 0 2px ${props => props.theme.colors.bg.secondary},
inset 0 0 0 1px ${props => mixin.rgba(props.theme.colors.bg.secondary, 0.07)};
z-index: ${props => props.zIndex};
position: relative;
`;
export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'normal' }>`
${props =>
props.color === 'success' &&
css`
fill: rgba(${props.theme.colors.success});
stroke: rgba(${props.theme.colors.success});
fill: ${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)`
overflow: hidden;
overflow-wrap: break-word;
resize: none;
height: 90px;
height: 54px;
width: 100%;
background: none;
@ -38,7 +40,7 @@ export const EditorTextarea = styled(TextareaAutosize)`
padding: 0;
font-size: 14px;
line-height: 18px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
&:focus {
border: none;
outline: none;
@ -89,7 +91,7 @@ export const ListCardBadgeText = styled.span<{ color?: 'success' | 'normal' }>`
padding: 0 4px 0 6px;
vertical-align: top;
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 }>`
@ -101,7 +103,9 @@ export const ListCardContainer = styled.div<{ isActive: boolean; editable: boole
position: relative;
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`
@ -147,6 +151,11 @@ export const ListCardLabelText = styled.span`
line-height: 16px;
`;
export const ListCardLabelsWrapper = styled.div`
overflow: auto;
position: relative;
`;
export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
${props =>
props.variant === 'small'
@ -178,8 +187,6 @@ export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
`;
export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>`
overflow: auto;
position: relative;
&:hover {
opacity: 0.8;
}
@ -218,22 +225,23 @@ export const ListCardOperation = styled.span`
top: 2px;
z-index: 100;
&:hover {
background-color: ${props => mixin.darken('#262c49', 0.25)};
background-color: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.25)};
}
`;
export const CardTitle = styled.span`
export const CardTitle = styled.div`
clear: both;
margin: 0 0 4px;
overflow: hidden;
text-decoration: none;
color: ${props => props.theme.colors.text.primary};
display: block;
align-items: center;
`;
export const CardTitleText = styled.span`
word-wrap: break-word;
line-height: 18px;
font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary});
display: flex;
align-items: center;
`;
export const CardMembers = styled.div`
@ -243,9 +251,10 @@ export const CardMembers = styled.div`
`;
export const CompleteIcon = styled(CheckCircle)`
fill: rgba(${props => props.theme.colors.success});
fill: ${props => props.theme.colors.success};
margin-right: 4px;
flex-shrink: 0;
margin-bottom: -2px;
`;
export const EditorContent = styled.div`

View File

@ -1,7 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons';
import { Pencil, Eye, List } from 'shared/icons';
import {
EditorTextarea,
CardMember,
@ -20,11 +18,12 @@ import {
ListCardLabels,
ListCardLabel,
ListCardLabelText,
ListCardLabelsWrapper,
ListCardOperation,
CardTitle,
CardMembers,
CardTitleText,
} from './Styles';
import { CheckSquare } from 'shared/icons';
type DueDate = {
isPastDue: boolean;
@ -59,12 +58,14 @@ type Props = {
onCardTitleChange?: (name: string) => void;
labelVariant?: CardLabelVariant;
toggleLabels?: boolean;
isPublic?: boolean;
toggleDirection?: 'shrink' | 'expand';
};
const Card = React.forwardRef(
(
{
isPublic = false,
wrapperProps,
onContextMenu,
taskID,
@ -121,9 +122,11 @@ const Card = React.forwardRef(
}
};
const onTaskContext = (e: React.MouseEvent) => {
if (!isPublic) {
e.preventDefault();
e.stopPropagation();
onOpenComposer();
}
};
const onOperationClick = (e: React.MouseEvent<HTMLOrSVGElement>) => {
e.preventDefault();
@ -146,7 +149,7 @@ const Card = React.forwardRef(
{...wrapperProps}
>
<ListCardInnerContainer ref={$innerCardRef}>
{isActive && !editable && (
{!isPublic && isActive && !editable && (
<ListCardOperation
onClick={e => {
e.stopPropagation();
@ -155,10 +158,12 @@ const Card = React.forwardRef(
}
}}
>
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
<Pencil width={8} height={8} />
</ListCardOperation>
)}
<ListCardDetails complete={complete ?? false}>
{labels && labels.length !== 0 && (
<ListCardLabelsWrapper>
<ListCardLabels
toggleLabels={toggleLabels}
toggleDirection={toggleDirection}
@ -169,8 +174,7 @@ const Card = React.forwardRef(
}
}}
>
{labels &&
labels
{labels
.slice()
.sort((a, b) => a.labelColor.position - b.labelColor.position)
.map(label => (
@ -188,6 +192,8 @@ const Card = React.forwardRef(
</ListCardLabel>
))}
</ListCardLabels>
</ListCardLabelsWrapper>
)}
{editable ? (
<EditorContent>
{complete && <CompleteIcon width={16} height={16} />}
@ -209,24 +215,24 @@ const Card = React.forwardRef(
) : (
<CardTitle>
{complete && <CompleteIcon width={16} height={16} />}
{`${title}${position ? ' - ' + position : ''}`}
<CardTitleText>{`${title}${position ? ` - ${position}` : ''}`}</CardTitleText>
</CardTitle>
)}
<ListCardBadges>
{watched && (
<ListCardBadge>
<FontAwesomeIcon color="#6b778c" icon={faEye} size="xs" />
<Eye width={8} height={8} />
</ListCardBadge>
)}
{dueDate && (
<DueDateCardBadge isPastDue={dueDate.isPastDue}>
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} icon={faClock} size="xs" />
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} width={8} height={8} />
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
</DueDateCardBadge>
)}
{description && (
<DescriptionBadge>
<FontAwesomeIcon color="#6b778c" icon={faList} size="xs" />
<List width={8} height={8} />
</DescriptionBadge>
)}
{checklists && (
@ -236,9 +242,9 @@ const Card = React.forwardRef(
width={8}
height={8}
/>
<ListCardBadgeText
color={checklists.complete === checklists.total ? 'success' : 'normal'}
>{`${checklists.complete}/${checklists.total}`}</ListCardBadgeText>
<ListCardBadgeText color={checklists.complete === checklists.total ? 'success' : 'normal'}>
{`${checklists.complete}/${checklists.total}`}
</ListCardBadgeText>
</ListCardBadge>
)}
</ListCardBadges>

View File

@ -1,18 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import CardComposer from '.';
export default {
component: CardComposer,
title: 'CardComposer',
parameters: {
backgrounds: [
{ name: 'gray', value: '#f8f8f8', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
export const Default = () => {
return <CardComposer isOpen onClose={action('on close')} onCreateCard={action('on create card')} />;
};

View File

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

View File

@ -1,12 +1,12 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { Cross } from 'shared/icons';
import {
CardComposerWrapper,
CancelIcon,
CancelIconWrapper,
AddCardButton,
ComposerControls,
ComposerControlsSaveSection,
@ -25,17 +25,23 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
const $cardRef = useRef<HTMLDivElement>(null);
useOnOutsideClick($cardRef, true, onClose, null);
useOnEscapeKeyDown(isOpen, onClose);
useEffect(() => {
if ($cardRef.current) {
$cardRef.current.scrollIntoView();
}
});
return (
<CardComposerWrapper isOpen={isOpen}>
<CardComposerWrapper isOpen={isOpen} ref={$cardRef}>
<Card
title={cardName}
ref={$cardRef}
taskID=""
taskGroupID=""
editable
onEditCard={(_taskGroupID, _taskID, name) => {
onCreateCard(name);
if (cardName.trim() !== '') {
onCreateCard(name.trim());
setCardName('');
}
}}
onCardTitleChange={name => {
setCardName(name);
@ -46,13 +52,17 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
<AddCardButton
variant="relief"
onClick={() => {
onCreateCard(cardName);
if (cardName.trim() !== '') {
onCreateCard(cardName.trim());
setCardName('');
}
}}
>
Add Card
</AddCardButton>
<CancelIcon onClick={onClose} icon={faTimes} color="#42526e" />
<CancelIconWrapper onClick={() => onClose()}>
<Cross width={12} height={12} />
</CancelIconWrapper>
</ComposerControlsSaveSection>
<ComposerControlsActionsSection />
</ComposerControls>

View File

@ -1,154 +0,0 @@
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles';
import produce from 'immer';
import styled, { ThemeProvider } from 'styled-components';
import Checklist, { ChecklistItem } from '.';
export default {
component: Checklist,
title: 'Checklist',
parameters: {
backgrounds: [
{ name: 'gray', value: '#f8f8f8', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
const Container = styled.div`
width: 552px;
margin: 25px;
border: 1px solid rgba(${props => props.theme.colors.bg.primary});
`;
const defaultItems = [
{
id: '1',
position: 1,
taskChecklistID: '1',
complete: false,
name: 'Tasks',
assigned: null,
dueDate: null,
},
{
id: '2',
taskChecklistID: '1',
position: 2,
complete: false,
name: 'Projects',
assigned: null,
dueDate: null,
},
{
id: '3',
position: 3,
taskChecklistID: '1',
complete: false,
name: 'Teams',
assigned: null,
dueDate: null,
},
{
id: '4',
position: 4,
complete: false,
taskChecklistID: '1',
name: 'Organizations',
assigned: null,
dueDate: null,
},
];
export const Default = () => {
const [checklistName, setChecklistName] = useState('Checklist');
const [items, setItems] = useState(defaultItems);
const onToggleItem = (itemID: string, complete: boolean) => {
setItems(
produce(items, draftState => {
const idx = items.findIndex(item => item.id === itemID);
if (idx !== -1) {
draftState[idx] = {
...draftState[idx],
complete,
};
}
}),
);
};
return (
<>
<BaseStyles />
<NormalizeStyles />
<ThemeProvider theme={theme}>
<Container>
<Checklist
wrapperProps={{}}
handleProps={{}}
name={checklistName}
checklistID="checklist-one"
items={items}
onDeleteChecklist={action('delete checklist')}
onChangeName={currentName => {
setChecklistName(currentName);
}}
onAddItem={itemName => {
let position = 1;
const lastItem = items[-1];
if (lastItem) {
position = lastItem.position * 2 + 1;
}
setItems([
...items,
{
id: `${Math.random()}`,
name: itemName,
complete: false,
assigned: null,
dueDate: null,
position,
taskChecklistID: '1',
},
]);
}}
onDeleteItem={itemID => {
setItems(items.filter(item => item.id !== itemID));
}}
onChangeItemName={(itemID, currentName) => {
setItems(
produce(items, draftState => {
const idx = items.findIndex(item => item.id === itemID);
if (idx !== -1) {
draftState[idx] = {
...draftState[idx],
name: currentName,
};
}
}),
);
}}
onToggleItem={onToggleItem}
>
{items.map((item, idx) => (
<ChecklistItem
key={item.id}
wrapperProps={{}}
handleProps={{}}
checklistID="id"
itemID={item.id}
name={item.name}
complete={item.complete}
onDeleteItem={() => {}}
onChangeName={() => {}}
onToggleItem={() => {}}
/>
))}
</Checklist>
</Container>
</ThemeProvider>
</>
);
};

View File

@ -12,6 +12,7 @@ import Button from 'shared/components/Button';
import TextareaAutosize from 'react-autosize-textarea';
import Control from 'react-select/src/components/Control';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { mixin } from 'shared/utils/styles';
const Wrapper = styled.div`
margin-bottom: 24px;
@ -25,7 +26,7 @@ const WindowTitle = styled.div`
const WindowTitleIcon = styled(CheckSquareOutline)`
top: 10px;
left: -40px;
left: -32px;
position: absolute;
`;
@ -38,7 +39,7 @@ const WindowChecklistTitle = styled.div`
const WindowTitleText = styled.h3`
cursor: pointer;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
margin: 6px 0;
display: inline-block;
width: auto;
@ -73,7 +74,7 @@ const ChecklistProgressPercent = styled.span`
`;
const ChecklistProgressBar = styled.div`
background: rgba(${props => props.theme.colors.bg.primary});
background: ${props => props.theme.colors.bg.primary};
border-radius: 4px;
clear: both;
height: 8px;
@ -83,7 +84,7 @@ const ChecklistProgressBar = styled.div`
`;
const ChecklistProgressBarCurrent = styled.div<{ width: number }>`
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;
left: 0;
position: absolute;
@ -111,7 +112,7 @@ const ChecklistIcon = styled.div`
`;
const ChecklistItemCheckedIcon = styled(CheckSquare)`
fill: rgba(${props => props.theme.colors.primary});
fill: ${props => props.theme.colors.primary};
`;
const ChecklistItemDetails = styled.div`
@ -133,7 +134,7 @@ const ChecklistItemTextControls = styled.div`
`;
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;'}
line-height: 20px;
font-size: 16px;
@ -155,14 +156,14 @@ const ControlButton = styled.div`
margin-left: 4px;
padding: 4px 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;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
&: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;
font-size: 16px;
line-height: 20px;
border: 1px solid rgba(${props => props.theme.colors.primary});
border: 1px solid ${props => props.theme.colors.primary};
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});
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4);
border-color: ${props => props.theme.colors.border};
background-color: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
&:focus {
border-color: rgba(${props => props.theme.colors.primary});
border-color: ${props => props.theme.colors.primary};
}
`;
const AssignUserButton = styled(AccountPlus)`
fill: rgba(${props => props.theme.colors.text.primary});
fill: ${props => props.theme.colors.text.primary};
`;
const ClockButton = styled(Clock)`
fill: rgba(${props => props.theme.colors.text.primary});
fill: ${props => props.theme.colors.text.primary};
`;
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 }>`
@ -224,7 +225,7 @@ const ChecklistItemWrapper = styled.div<{ ref: any }>`
}
&: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} {
opacity: 1;
@ -246,10 +247,10 @@ const CancelButton = styled.div`
cursor: pointer;
margin: 5px;
& svg {
fill: rgba(${props => props.theme.colors.text.primary});
fill: ${props => props.theme.colors.text.primary};
}
&: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;
&: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,43 +0,0 @@
import React from 'react';
import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles';
import styled, { ThemeProvider } from 'styled-components';
import Input from '.';
import { User } from 'shared/icons';
export default {
component: Input,
title: 'Input',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const Wrapper = styled.div`
background: rgba(${props => props.theme.colors.bg.primary});
padding: 45px;
margin: 25px;
display: flex;
flex-direction: column;
`;
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<ThemeProvider theme={theme}>
<Wrapper>
<Input label="Label placeholder" />
<Input width="100%" placeholder="Placeholder" />
<Input icon={<User size={20} />} width="100%" placeholder="Placeholder" />
</Wrapper>
</ThemeProvider>
</>
);
};

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import styled, { css } from 'styled-components/macro';
import theme from '../../../App/ThemeStyles';
const InputWrapper = styled.div<{ width: string }>`
position: relative;
@ -15,7 +16,7 @@ const InputWrapper = styled.div<{ width: string }>`
`;
const InputLabel = styled.span<{ width: string }>`
width: ${props => props.width};
width: ${(props) => props.width};
padding: 0.7rem !important;
color: #c2c6dc;
left: 0;
@ -39,13 +40,13 @@ const InputInput = styled.input<{
focusBg: string;
borderColor: string;
}>`
width: ${props => props.width};
width: ${(props) => props.width};
font-size: 14px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-color: ${props => props.borderColor};
border-color: ${(props) => props.borderColor};
background: #262c49;
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
${props => (props.hasIcon ? 'padding: 0.7rem 1rem 0.7rem 3rem;' : 'padding: 0.7rem;')}
${(props) => (props.hasIcon ? 'padding: 0.7rem 1rem 0.7rem 3rem;' : 'padding: 0.7rem;')}
line-height: 16px;
color: #c2c6dc;
position: relative;
@ -54,17 +55,17 @@ const InputInput = styled.input<{
&:focus {
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
border: 1px solid rgba(115, 103, 240);
background: ${props => props.focusBg};
background: ${(props) => props.focusBg};
}
&:focus ~ ${InputLabel} {
color: rgba(115, 103, 240);
color: ${(props) => props.theme.colors.primary};
transform: translate(-3px, -90%);
}
${props =>
${(props) =>
props.hasValue &&
css`
& ~ ${InputLabel} {
color: rgba(115, 103, 240);
color: ${props.theme.colors.primary};
transform: translate(-3px, -90%);
}
`}
@ -93,11 +94,13 @@ type ControlledInputProps = {
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
value?: string;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
disabled?: boolean;
};
const ControlledInput = ({
width = 'auto',
variant = 'normal',
disabled = false,
type = 'text',
autocomplete,
autoFocus = false,
@ -115,8 +118,8 @@ const ControlledInput = ({
}: ControlledInputProps) => {
const $input = useRef<HTMLInputElement>(null);
const [hasValue, setHasValue] = useState(false);
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561';
const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)';
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : theme.colors.alternate;
const focusBg = variant === 'normal' ? theme.colors.bg.secondary : theme.colors.bg.primary;
useEffect(() => {
if (autoFocus && $input && $input.current) {
$input.current.focus();
@ -125,8 +128,9 @@ const ControlledInput = ({
return (
<InputWrapper className={className} width={width}>
<InputInput
disabled={disabled}
hasValue={hasValue}
onChange={e => {
onChange={(e) => {
if (onChange) {
setHasValue(e.currentTarget.value !== '' || floatingLabel);
onChange(e);

View File

@ -1,64 +0,0 @@
import React, { createRef, useState } from 'react';
import styled from 'styled-components';
import { action } from '@storybook/addon-actions';
import DropdownMenu from '.';
export default {
component: DropdownMenu,
title: 'DropdownMenu',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#f8f8f8' },
{ name: 'darkBlue', value: '#262c49', default: true },
],
},
};
const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
`;
const Button = styled.div`
font-size: 18px;
padding: 15px 20px;
color: #fff;
background: #000;
`;
export const Default = () => {
const [menu, setMenu] = useState({
top: 0,
left: 0,
isOpen: false,
});
const $buttonRef: any = createRef();
const onClick = () => {
setMenu({
isOpen: !menu.isOpen,
left: $buttonRef.current.getBoundingClientRect().right,
top: $buttonRef.current.getBoundingClientRect().bottom,
});
};
return (
<>
<Container>
<Button onClick={onClick} ref={$buttonRef}>
Click me
</Button>
</Container>
{menu.isOpen && (
<DropdownMenu
onAdminConsole={action('admin')}
onCloseDropdown={() => {
setMenu({ top: 0, left: 0, isOpen: false });
}}
onLogout={action('on logout')}
left={menu.left}
top={menu.top}
/>
)}
</>
);
};

View File

@ -59,7 +59,7 @@ export const ActionItem = styled.li`
align-items: center;
font-size: 14px;
&: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}>
<Wrapper>
<ActionItem onClick={onAdminConsole}>
<User size={16} color="#c2c6dc" />
<User width={16} height={16} />
<ActionTitle>Profile</ActionTitle>
</ActionItem>
<Separator />
@ -54,7 +54,7 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ showAdminConsole, onAdminCons
</>
)}
<ActionItem onClick={onProfile}>
<User size={16} color="#c2c6dc" />
<User width={16} height={16} />
<ActionTitle>Profile</ActionTitle>
</ActionItem>
<ActionsList>

View File

@ -1,74 +0,0 @@
import React, { useRef } from 'react';
import { action } from '@storybook/addon-actions';
import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles';
import styled, { ThemeProvider } from 'styled-components';
import { Popup } from '../PopupMenu';
import DueDateManager from '.';
const PopupWrapper = styled.div`
width: 310px;
`;
export default {
component: DueDateManager,
title: 'DueDateManager',
parameters: {
backgrounds: [
{ name: 'gray', value: '#f8f8f8', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<ThemeProvider theme={theme}>
<PopupWrapper>
<Popup title={null} tab={0}>
<DueDateManager
task={{
id: '1',
taskGroup: { name: 'General', id: '1', position: 1 },
name: 'Hello, world',
position: 1,
labels: [
{
id: 'soft-skills',
assignedDate: new Date().toString(),
projectLabel: {
createdDate: new Date().toString(),
id: 'label-soft-skills',
name: 'Soft Skills',
labelColor: {
id: '1',
name: 'white',
colorHex: '#fff',
position: 1,
},
},
},
],
description: 'hello!',
assigned: [
{
id: '1',
profileIcon: { url: null, initials: null, bgColor: null },
fullName: 'Jordan Knott',
},
],
}}
onCancel={action('cancel')}
onDueDateChange={action('due date change')}
onRemoveDueDate={action('remove due date')}
/>
</Popup>
</PopupWrapper>
</ThemeProvider>
</>
);
};

View File

@ -2,6 +2,8 @@ import styled from 'styled-components';
import Button from 'shared/components/Button';
import { mixin } from 'shared/utils/styles';
import Input from 'shared/components/Input';
import ControlledInput from 'shared/components/ControlledInput';
import { Clock } from 'shared/icons';
export const Wrapper = styled.div`
display: flex
@ -17,25 +19,30 @@ display: flex
z-index: 10000;
margin-top: 0;
}
& .react-datepicker__close-icon::after {
background: none;
font-size: 16px;
color: ${props => props.theme.colors.text.primary};
}
& .react-datepicker-time__header {
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
}
& .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-box ul.react-datepicker__time-list
li.react-datepicker__time-list-item:hover {
color: rgba(${props => props.theme.colors.text.secondary});
background: rgba(${props => props.theme.colors.bg.secondary});
color: ${props => props.theme.colors.text.secondary};
background: ${props => props.theme.colors.bg.secondary};
}
& .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 {
background: rgba(${props => props.theme.colors.bg.primary});
border: 1px solid rgba(${props => props.theme.colors.border});
background: ${props => props.theme.colors.bg.primary};
border: 1px solid ${props => props.theme.colors.border};
}
& .react-datepicker * {
@ -75,12 +82,12 @@ display: flex
}
& .react-datepicker__day--selected {
border-radius: 50%;
background: rgba(115, 103, 240);
background: ${props => props.theme.colors.primary};
color: #fff;
}
& .react-datepicker__day--selected:hover {
border-radius: 50%;
background: rgba(115, 103, 240);
background: ${props => props.theme.colors.primary};
color: #fff;
}
& .react-datepicker__header {
@ -88,9 +95,27 @@ display: flex
border: none;
}
& .react-datepicker__header--time {
border-bottom: 1px solid rgba(${props => props.theme.colors.border});
border-bottom: 1px solid ${props => props.theme.colors.border};
}
& .react-datepicker__input-container input {
border: 1px solid rgba(0, 0, 0, 0.2);
border-color: ${props => props.theme.colors.alternate};
background: #262c49;
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
padding: 0.7rem;
color: #c2c6dc;
position: relative;
border-radius: 5px;
transition: all 0.3s ease;
font-size: 13px;
line-height: 20px;
padding: 0 12px;
&:focus {
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
border: 1px solid rgba(115, 103, 240);
background: ${props => props.theme.colors.bg.primary};
}
`;
export const DueDatePickerWrapper = styled.div`
@ -110,6 +135,44 @@ export const RemoveDueDate = styled(Button)`
margin: 0 0 0 4px;
`;
export const AddDateRange = styled.div`
opacity: 0.6;
display: flex;
align-items: center;
width: 100%;
font-size: 12px;
line-height: 16px;
color: ${props => mixin.rgba(props.theme.colors.primary, 0.8)};
&:hover {
color: ${props => mixin.rgba(props.theme.colors.primary, 1)};
text-decoration: underline;
}
`;
export const DateRangeInputs = styled.div`
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-left: -4px;
& > div:first-child,
& > div:last-child {
flex: 1 1 92px;
margin-bottom: 4px;
margin-left: 4px;
min-width: 92px;
width: initial;
}
& > ${AddDateRange} {
margin-left: 4px;
padding-left: 4px;
}
& > .react-datepicker-wrapper input {
padding-bottom: 4px;
padding-top: 4px;
width: 100%;
}
`;
export const CancelDueDate = styled.div`
display: flex;
align-items: center;
@ -119,15 +182,86 @@ export const CancelDueDate = styled.div`
cursor: pointer;
`;
export const DueDateInput = styled(Input)`
export const DueDateInput = styled(ControlledInput)`
margin-top: 15px;
margin-bottom: 5px;
padding-right: 10px;
`;
export const ActionWrapper = styled.div`
padding-top: 8px;
export const ActionsSeparator = styled.div`
margin-top: 8px;
height: 1px;
width: 100%;
background: #414561;
display: flex;
justify-content: space-between;
`;
export const ActionsWrapper = styled.div`
margin-top: 8px;
display: flex;
align-items: center;
& .react-datepicker-wrapper {
margin-left: auto;
width: 82px;
}
& .react-datepicker__input-container input {
padding-bottom: 4px;
padding-top: 4px;
width: 100%;
}
`;
export const ActionClock = styled(Clock)`
align-self: center;
fill: ${props => props.theme.colors.primary};
margin: 0 8px;
flex: 0 0 auto;
`;
export const ActionLabel = styled.div`
font-size: 12px;
line-height: 14px;
`;
export const ActionIcon = styled.div`
height: 36px;
min-height: 36px;
min-width: 36px;
width: 36px;
border-radius: 6px;
background: transparent;
cursor: pointer;
margin-right: 8px;
svg {
fill: ${props => props.theme.colors.text.primary};
transition-duration: 0.2s;
transition-property: background, border, box-shadow, fill;
}
&:hover svg {
fill: ${props => props.theme.colors.text.secondary};
}
align-items: center;
display: inline-flex;
justify-content: center;
`;
export const ClearButton = styled.div`
font-weight: 500;
font-size: 13px;
height: 36px;
line-height: 36px;
padding: 0 12px;
margin-left: auto;
cursor: pointer;
align-items: center;
border-radius: 6px;
display: inline-flex;
flex-shrink: 0;
justify-content: center;
transition-duration: 0.2s;
transition-property: background, border, box-shadow, color, fill;
color: ${props => props.theme.colors.text.primary};
&:hover {
color: ${props => props.theme.colors.text.secondary};
}
`;

View File

@ -1,27 +1,34 @@
import React, { useState, useEffect, forwardRef } from 'react';
import moment from 'moment';
import React, { useState, useEffect, forwardRef, useRef, useCallback } from 'react';
import dayjs from 'dayjs';
import styled from 'styled-components';
import DatePicker from 'react-datepicker';
import { Cross } from 'shared/icons';
import _ from 'lodash';
import 'react-datepicker/dist/react-datepicker.css';
import { getYear, getMonth } from 'date-fns';
import { useForm, Controller } from 'react-hook-form';
import NOOP from 'shared/utils/noop';
import {
Wrapper,
ActionWrapper,
RemoveDueDate,
DueDateInput,
DueDatePickerWrapper,
ConfirmAddDueDate,
CancelDueDate,
DateRangeInputs,
AddDateRange,
ActionIcon,
ActionsWrapper,
ClearButton,
ActionsSeparator,
ActionClock,
ActionLabel,
} from './Styles';
import 'react-datepicker/dist/react-datepicker.css';
import { getYear, getMonth } from 'date-fns';
import { useForm, Controller } from 'react-hook-form';
import { Clock, Cross } from 'shared/icons';
import Select from 'react-select/src/Select';
type DueDateManagerProps = {
task: Task;
onDueDateChange: (task: Task, newDueDate: Date) => void;
onDueDateChange: (task: Task, newDueDate: Date, hasTime: boolean) => void;
onRemoveDueDate: (task: Task) => void;
onCancel: () => void;
};
@ -52,7 +59,7 @@ const HeaderSelectLabel = styled.div`
color: #c2c6dc;
&:hover {
background: rgba(115, 103, 240);
background: ${(props) => props.theme.colors.primary};
color: #c2c6dc;
}
`;
@ -61,16 +68,22 @@ const HeaderSelect = styled.select`
text-decoration: underline;
font-size: 14px;
text-align: center;
padding: 4px 6px;
background: none;
outline: none;
border: none;
border-radius: 3px;
appearance: none;
width: 100%;
display: inline-block;
&:hover {
background: #262c49;
border: 1px solid rgba(115, 103, 240);
& option {
color: #c2c6dc;
background: ${(props) => props.theme.colors.bg.primary};
}
& option:hover {
background: ${(props) => props.theme.colors.bg.secondary};
border: 1px solid ${(props) => props.theme.colors.primary};
outline: none !important;
box-shadow: none;
color: #c2c6dc;
@ -102,7 +115,7 @@ const HeaderButton = styled.button`
border: none;
border-radius: 3px;
&:hover {
background: rgba(115, 103, 240);
background: ${(props) => props.theme.colors.primary};
color: #fff;
}
`;
@ -119,19 +132,41 @@ const HeaderActions = styled.div`
`;
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
const now = moment();
const [textStartDate, setTextStartDate] = useState(now.format('YYYY-MM-DD'));
const [startDate, setStartDate] = useState(new Date());
useEffect(() => {
setTextStartDate(moment(startDate).format('YYYY-MM-DD'));
}, [startDate]);
const currentDueDate = task.dueDate ? dayjs(task.dueDate).toDate() : null;
const {
register,
handleSubmit,
setValue,
setError,
formState: { errors },
control,
} = useForm<DueDateFormData>();
const [textEndTime, setTextEndTime] = useState(now.format('h:mm A'));
const [endTime, setEndTime] = useState(now.toDate());
useEffect(() => {
setTextEndTime(moment(endTime).format('h:mm A'));
}, [endTime]);
const [startDate, setStartDate] = useState<Date | null>(currentDueDate);
const [endDate, setEndDate] = useState<Date | null>(currentDueDate);
const [hasTime, enableTime] = useState(task.hasTime ?? false);
const firstRun = useRef<boolean>(true);
const debouncedFunctionRef = useRef((newDate: Date | null, nowHasTime: boolean) => {
if (!firstRun.current) {
if (newDate) {
onDueDateChange(task, newDate, nowHasTime);
} else {
onRemoveDueDate(task);
enableTime(false);
}
} else {
firstRun.current = false;
}
});
const debouncedChange = useCallback(
_.debounce((newDate, nowHasTime) => debouncedFunctionRef.current(newDate, nowHasTime), 500),
[],
);
useEffect(() => {
debouncedChange(startDate, hasTime);
}, [startDate, hasTime]);
const years = _.range(2010, getYear(new Date()) + 10, 1);
const months = [
'January',
@ -147,63 +182,70 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
'November',
'December',
];
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
const saveDueDate = (data: any) => {
const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A');
if (newDate.isValid()) {
onDueDateChange(task, newDate.toDate());
}
const onChange = (dates: any) => {
const [start, end] = dates;
setStartDate(start);
setEndDate(end);
};
const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => {
const [isRange, setIsRange] = useState(false);
const CustomTimeInput = forwardRef(({ value, onClick, onChange, onBlur, onFocus }: any, $ref: any) => {
return (
<DueDateInput
id="endTime"
value={value}
name="endTime"
ref={$ref}
onChange={onChange}
width="100%"
variant="alternate"
label="Date"
label="Time"
onClick={onClick}
/>
);
});
return (
<Wrapper>
<Form onSubmit={handleSubmit(saveDueDate)}>
<FormField>
<DueDateInput
id="endDate"
name="endDate"
width="100%"
variant="alternate"
label="Date"
defaultValue={textStartDate}
ref={register({
required: 'End date is required.',
})}
/>
</FormField>
<FormField>
<Controller
control={control}
name="endTime"
render={({ onChange, onBlur, value }) => (
<DateRangeInputs>
<DatePicker
onChange={onChange}
selected={value}
onBlur={onBlur}
showTimeSelect
showTimeSelectOnly
timeIntervals={15}
timeCaption="Time"
dateFormat="h:mm aa"
customInput={<CustomTimeInput />}
selected={startDate}
onChange={(date) => {
if (!Array.isArray(date)) {
setStartDate(date);
}
}}
popperClassName="picker-hidden"
dateFormat="yyyy-MM-dd"
disabledKeyboardNavigation
isClearable
placeholderText="Select due date"
/>
{isRange ? (
<DatePicker
selected={startDate}
isClearable
onChange={(date) => {
if (!Array.isArray(date)) {
setStartDate(date);
}
}}
popperClassName="picker-hidden"
dateFormat="yyyy-MM-dd"
placeholderText="Select from date"
/>
) : (
<AddDateRange>Add date range</AddDateRange>
)}
/>
</FormField>
<DueDatePickerWrapper>
</DateRangeInputs>
<DatePicker
selected={startDate}
onChange={(date) => {
if (!Array.isArray(date)) {
setStartDate(date);
}
}}
startDate={startDate}
useWeekdaysShort
renderCustomHeader={({
date,
@ -220,8 +262,11 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
</HeaderButton>
<HeaderSelectLabel>
{months[date.getMonth()]}
<HeaderSelect value={getYear(date)} onChange={({ target: { value } }) => changeYear(parseInt(value))}>
{years.map(option => (
<HeaderSelect
value={months[getMonth(date)]}
onChange={({ target: { value } }) => changeMonth(months.indexOf(value))}
>
{months.map((option) => (
<option key={option} value={option}>
{option}
</option>
@ -230,11 +275,8 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
</HeaderSelectLabel>
<HeaderSelectLabel>
{date.getFullYear()}
<HeaderSelect
value={months[getMonth(date)]}
onChange={({ target: { value } }) => changeMonth(months.indexOf(value))}
>
{months.map(option => (
<HeaderSelect value={getYear(date)} onChange={({ target: { value } }) => changeYear(parseInt(value, 10))}>
{years.map((option) => (
<option key={option} value={option}>
{option}
</option>
@ -247,28 +289,48 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
</HeaderButton>
</HeaderActions>
)}
selected={startDate}
inline
onChange={date => {
setStartDate(date ?? new Date());
}}
/>
</DueDatePickerWrapper>
<ActionWrapper>
<ConfirmAddDueDate type="submit" onClick={() => {}}>
Save
</ConfirmAddDueDate>
<RemoveDueDate
variant="outline"
color="danger"
<ActionsSeparator />
{hasTime && (
<ActionsWrapper>
<ActionClock width={16} height={16} />
<ActionLabel>Due Time</ActionLabel>
<DatePicker
selected={startDate}
onChange={(date) => {
if (!Array.isArray(date)) {
setStartDate(date);
}
}}
showTimeSelect
showTimeSelectOnly
timeIntervals={15}
timeCaption="Time"
dateFormat="h:mm aa"
/>
<ActionIcon onClick={() => enableTime(false)}>
<Cross width={16} height={16} />
</ActionIcon>
</ActionsWrapper>
)}
<ActionsWrapper>
{!hasTime && (
<ActionIcon
onClick={() => {
onRemoveDueDate(task);
if (startDate === null) {
const today = new Date();
today.setHours(12, 30, 0);
setStartDate(today);
}
enableTime(true);
}}
>
Remove
</RemoveDueDate>
</ActionWrapper>
</Form>
<Clock width={16} height={16} />
</ActionIcon>
)}
<ClearButton onClick={() => setStartDate(null)}>{hasTime ? 'Clear all' : 'Clear'}</ClearButton>
</ActionsWrapper>
</Wrapper>
);
};

View File

@ -1,6 +1,7 @@
import React from 'react';
import styled, { keyframes } from 'styled-components/macro';
import { mixin } from 'shared/utils/styles';
import theme from '../../../App/ThemeStyles';
export const BoardContainer = styled.div`
position: relative;
@ -34,9 +35,9 @@ export const Container = styled.div`
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`
0% {

View File

@ -1,43 +0,0 @@
import React from 'react';
import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import { theme } from 'App/ThemeStyles';
import styled, { ThemeProvider } from 'styled-components';
import Input from '.';
import { User } from 'shared/icons';
export default {
component: Input,
title: 'Input',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const Wrapper = styled.div`
background: rgba(${props => props.theme.colors.bg.primary});
padding: 45px;
margin: 25px;
display: flex;
flex-direction: column;
`;
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<ThemeProvider theme={theme}>
<Wrapper>
<Input label="Label placeholder" />
<Input width="100%" placeholder="Placeholder" />
<Input icon={<User size={20} />} width="100%" placeholder="Placeholder" />
</Wrapper>
</ThemeProvider>
</>
);
};

View File

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

View File

@ -1,145 +0,0 @@
import React, { createRef } from 'react';
import { action } from '@storybook/addon-actions';
import Card from 'shared/components/Card';
import CardComposer from 'shared/components/CardComposer';
import LabelColors from 'shared/constants/labelColors';
import List, { ListCards } from '.';
export default {
component: List,
title: 'List',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const labelData: Array<ProjectLabel> = [
{
id: 'development',
name: 'Development',
createdDate: new Date().toString(),
labelColor: {
id: '1',
colorHex: LabelColors.BLUE,
name: 'blue',
position: 1,
},
},
];
const createCard = () => {
const $ref = createRef<HTMLDivElement>();
return (
<Card
taskID="1"
taskGroupID="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
);
};
export const Default = () => {
return (
<List
id=""
name="General"
isComposerOpen={false}
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
onExtraMenuOpen={action('extra menu open')}
>
<ListCards>
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen={false} />
</ListCards>
</List>
);
};
export const WithCardComposer = () => {
return (
<List
id="1"
name="General"
isComposerOpen
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
onExtraMenuOpen={action('extra menu open')}
>
<ListCards>
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen />
</ListCards>
</List>
);
};
export const WithCard = () => {
const $cardRef: any = createRef();
return (
<List
id="1"
name="General"
isComposerOpen={false}
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
onExtraMenuOpen={action('extra menu open')}
>
<ListCards>
<Card
taskID="1"
taskGroupID="1"
description="hello!"
ref={$cardRef}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen={false} />
</ListCards>
</List>
);
};
export const WithCardAndComposer = () => {
const $cardRef: any = createRef();
return (
<List
id="1"
name="General"
isComposerOpen
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
onExtraMenuOpen={action('extra menu open')}
>
<ListCards>
<Card
taskID="1"
taskGroupID="1"
description="hello!"
ref={$cardRef}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
<CardComposer onClose={() => {}} onCreateCard={name => {}} isOpen />
</ListCards>
</List>
);
};

View File

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

View File

@ -24,6 +24,7 @@ type Props = {
onOpenComposer: (id: string) => void;
wrapperProps?: any;
headerProps?: any;
isPublic: boolean;
index?: number;
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
};
@ -37,6 +38,7 @@ const List = React.forwardRef(
isComposerOpen,
onOpenComposer,
children,
isPublic,
wrapperProps,
headerProps,
onExtraMenuOpen,
@ -86,39 +88,37 @@ const List = React.forwardRef(
<Container ref={$wrapperRef} {...wrapperProps}>
<Wrapper>
<Header {...headerProps} isEditing={isEditingTitle}>
<HeaderEditTarget onClick={onClick} isHidden={isEditingTitle} />
{!isPublic && <HeaderEditTarget onClick={onClick} isHidden={isEditingTitle} />}
<HeaderName
ref={$listNameRef}
disabled={isPublic}
onBlur={onBlur}
onChange={onChange}
onKeyDown={onKeyDown}
spellCheck={false}
value={listName}
/>
{!isPublic && (
<ListExtraMenuButtonWrapper ref={$extraActionsRef} onClick={handleExtraMenuOpen}>
<Ellipsis size={16} color="#c2c6dc" />
</ListExtraMenuButtonWrapper>
)}
</Header>
{children && children}
{!isPublic && (
<AddCardContainer hidden={isComposerOpen}>
<AddCardButton onClick={() => onOpenComposer(id)}>
<Plus size={12} color="#c2c6dc" />
<Plus width={12} height={12} />
<AddCardButtonText>Add another card</AddCardButtonText>
</AddCardButton>
</AddCardContainer>
)}
</Wrapper>
</Container>
);
},
);
List.defaultProps = {
children: null,
isComposerOpen: false,
wrapperProps: {},
headerProps: {},
};
List.displayName = 'List';
export default List;

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;
text-decoration: none;
&:hover {
background: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
}
`;

View File

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

View File

@ -1,103 +0,0 @@
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import Lists from '.';
export default {
component: Lists,
title: 'Lists',
parameters: {
backgrounds: [
{ name: 'gray', value: '#262c49', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
const initialListsData = {
columns: {
'column-1': {
taskGroupID: 'column-1',
name: 'General',
taskIds: ['task-3', 'task-4', 'task-1', 'task-2'],
position: 1,
tasks: [],
},
'column-2': {
taskGroupID: 'column-2',
name: 'Development',
taskIds: [],
position: 2,
tasks: [],
},
},
tasks: {
'task-1': {
taskID: 'task-1',
taskGroup: { taskGroupID: 'column-1' },
name: 'Create roadmap',
position: 2,
labels: [],
},
'task-2': {
taskID: 'task-2',
taskGroup: { taskGroupID: 'column-1' },
position: 1,
name: 'Create authentication',
labels: [],
},
'task-3': {
taskID: 'task-3',
taskGroup: { taskGroupID: 'column-1' },
position: 3,
name: 'Create login',
labels: [],
},
'task-4': {
taskID: 'task-4',
taskGroup: { taskGroupID: 'column-1' },
position: 4,
name: 'Create plugins',
labels: [],
},
},
};
export const Default = () => {
const [listsData, setListsData] = useState(initialListsData);
const onCardDrop = (droppedTask: Task) => {
const newState = {
...listsData,
tasks: {
...listsData.tasks,
[droppedTask.id]: droppedTask,
},
};
setListsData(newState);
};
const onListDrop = (droppedColumn: any) => {
const newState = {
...listsData,
columns: {
...listsData.columns,
[droppedColumn.taskGroupID]: droppedColumn,
},
};
setListsData(newState);
};
return (
<Lists
taskGroups={[]}
onTaskClick={action('card click')}
onQuickEditorOpen={action('card composer open')}
onCreateTask={action('card create')}
onTaskDrop={onCardDrop}
onTaskGroupDrop={onListDrop}
onChangeTaskGroupName={action('change group name')}
cardLabelVariant="large"
onCardLabelClick={action('label click')}
onCreateTaskGroup={action('create list')}
onExtraMenuOpen={action('extra menu open')}
onCardMemberClick={action('card member click')}
/>
);
};

View File

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

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