Compare commits

..

92 Commits

Author SHA1 Message Date
Jordan Knott
998db2a5da fix: add clarification to arguments for reset-password cmd 2022-09-02 12:05:43 -05:00
Jordan Knott
dfa8a4fba0 fix: frontend not being built due to lint errors 2022-09-02 12:05:20 -05:00
CarlosSalda
4f5aa2deb8 feat: new login mobile device 2021-12-11 10:50:31 -06:00
Jordan Knott
886b2763ee feat!: due date reminder notifications 2021-11-17 17:11:28 -06:00
Jordan Knott
0d00fc7518 feat: redesign due date manager 2021-11-05 22:35:57 -05:00
Jordan Knott
df6140a10f feat: change url structure to use short ids instead of full uuids 2021-11-04 14:08:30 -05:00
Jordan Knott
f9a5007104 feat: store notification filter state in localStorage 2021-11-04 11:27:26 -05:00
Jordan Knott
de6fe78004 fix: add check for when notifications is empty 2021-11-04 10:57:38 -05:00
Jordan Knott
799d7f3ad0 feat: add bell notification system for task assignment 2021-11-02 14:51:59 -05:00
Jordan Knott
3afd860534 fix: teams can now be created 2021-11-01 20:58:42 -05:00
Jordan Knott
cea99397db fix: user profile not rendering in top navbar 2021-10-30 17:20:41 -05:00
Jordan Knott
800dd2014c refactor: move config related code into dedicated package 2021-10-26 22:10:29 -05:00
Jordan Knott
54553cfbdd refactor: redesign notification table design 2021-10-26 21:35:48 -05:00
Jordan Knott
d5d85c5e30 refactor(magefile): update schema generator to use 0644 file permissions 2021-10-26 14:42:04 -05:00
Jordan Knott
ef2aadefbb refactor: add client log on task list change 2021-10-25 21:03:22 -05:00
Jordan Knott
cf63783174 refactor: split resolver into multiple files based on domain 2021-10-25 17:42:57 -05:00
Jordan Knott
fe90631df5 refactor: clean task control components 2021-10-25 15:38:20 -05:00
Jordan Knott
119a4b2868 feat: add comments badge to task card 2021-10-25 15:14:24 -05:00
Jordan Knott
3992e4c2de fix: task sort popup active checkmarks not showing 2021-10-24 10:57:46 -05:00
Jordan Knott
ce3afec8a0 fix: filtering tasks by label or member not working due to typescript
Upgrading all libraries fixed the error (ref.current is read-only)
2021-10-24 10:51:03 -05:00
Jordan Knott
25df251cc5 fix: remove translate on hover for gradient button 2021-10-24 10:51:03 -05:00
Jordan Knott
2b3084ea52 docs: update changelog 2021-10-24 10:51:03 -05:00
Mashiro
d725e42adf fix(docker-compose): add volume for uploads 2021-10-06 19:09:36 -05:00
Jordan Knott
aa84cbabb2 fix: add user popup is submittable again
react-form-hooks no longer played nice with custom input. created
a third input type `FormInput` that is made to play well
with the react-form-hooks.

also fixes auto complete overriding bg + text color on inputs.
2021-10-06 19:03:38 -05:00
Jordan Knott
8b1de30204 feat: redirect to register page if no users exist
fixes #130
2021-10-06 14:20:36 -05:00
Jordan Knott
eab33bfd9a refactor: fix docker tag names in release target 2021-09-13 13:15:34 -05:00
Jordan Knott
8d724fa3cf refactor: add release target 2021-09-13 13:07:49 -05:00
Jordan Knott
76e398488f fix: rewrite the label manager to no longer use useRef
useRef was causing a `readonly` error when trying to overwrite
`ref.current`. Rewrote components to use an Apollo query instead.

fixes #121
2021-09-13 12:44:02 -05:00
Jordan Knott
d1b867db35 deps: upgrade @types/react & @types/react-dom 2021-09-13 12:43:39 -05:00
Jordan Knott
aeb97a30d8 refactor: add docker testing targets to magefile 2021-09-13 11:23:09 -05:00
Jordan Knott
56e925a48d fix: add error to log when user creation fails 2021-09-13 11:22:48 -05:00
Jordan Knott
65cd431c1a fix: TaskDetails editor theme updated to work with latest version 2021-09-07 11:32:29 -05:00
Jordan Knott
a188c4b0ca fix: clean up component to fix lint warnings preventing frontend build 2021-09-04 14:08:44 -05:00
Jordan Knott
3bfce1825c docs: update unreleased changelog section 2021-09-04 13:16:03 -05:00
Jordan Knott
2b4f94117c fix: add missing rich-markdown-editor dependency
fixes #122
2021-09-04 12:16:01 -05:00
Jordan Knott
05799fce90 fix: hide any open popups when closing task details modal 2021-05-10 12:46:46 -05:00
Jordan Knott
b4f37350a9 refactor: switch to personal fork of rich-markdown-editor 2021-05-10 12:45:40 -05:00
Jordan Knott
8c6a3db0bc deps: upgrade all dependencies 2021-05-02 17:31:24 -05:00
Jordan Knott
5a9a66effe feat: apply new label to task when available 2021-04-30 23:49:12 -05:00
Jordan Knott
167d285d02 refactor: polling is now turned off in development mode 2021-04-30 23:36:58 -05:00
Jordan Knott
e2634dc490 feat: redirect after login when applicable 2021-04-30 23:25:48 -05:00
Jordan Knott
04c12e4da9 feat: projects can be set to public 2021-04-30 22:55:37 -05:00
Jordan Knott
3e72271d9b refactor(Project): split out components into their own files 2021-04-30 20:06:05 -05:00
Jordan Knott
bd34f4b3ad feat: change primary font to Open Sans 2021-04-30 16:35:43 -05:00
Jordan Knott
f45e359402 refactor: clean up components 2021-04-28 21:51:47 -05:00
Jordan Knott
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
Jordan Knott
3392b3345d fix(Project): remove spacing between task group list and add new task list component 2021-04-28 21:38:49 -05:00
Jordan Knott
29b7c028ca chore: update yarn.lock 2021-04-28 21:38:49 -05:00
Jordan Knott
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
Jordan Knott
383d90d747 chore(Pipfile): update python version 2021-04-28 21:38:49 -05:00
Jordan Knott
0cc1b5a1df refactor: remove Storybook stories 2021-04-28 21:38:49 -05:00
Jordan Knott
0760edac80 feat(FilterMeta): auto focus task name search input on popup open 2021-04-28 21:38:49 -05:00
Jordan Knott
ceaa49c5a1 feat(TaskDetails): clicking the '+' button now opens label manager 2021-04-28 21:38:49 -05:00
Jordan Knott
8b22d33dad fix(QuickCardEditor): use correct ref on due date open 2021-04-28 21:38:49 -05:00
Jordan Knott
61e9249c98 fix: prevent empty list title from being saved 2021-04-28 21:38:49 -05:00
dependabot[bot]
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
Jordan Knott
35ac12b7b2 fix(Lists): fix flickering when list was a certain size 2021-01-06 17:00:10 -06:00
Jordan Knott
40b27aa1f1 fix: card composer no longer allows creates tasks with empty names 2021-01-06 16:20:05 -06:00
Jordan Knott
533b9511c9 feat: add alternate project finder to left navbar 2021-01-05 19:47:52 -06:00
Jordan Knott
dc50ef3566 refactor: change nav icons to use Link instead of history.push 2021-01-05 19:10:07 -06:00
Jordan Knott
f9e6fba552 fix(Projects): set overflow-y to auto on Wrapper 2021-01-05 19:06:49 -06:00
Jordan Knott
be7e945313 feat(ProjectFinder): auto focus search bar 2021-01-05 19:04:47 -06:00
Jordan Knott
edc7b649ec feat: card composer now auto scrolls into view on being opened 2021-01-05 19:02:19 -06:00
Jordan Knott
4b83ff594f fix: fix AddList component behaving weirdly when a Task Group was moved 2021-01-05 18:58:13 -06:00
Jordan Knott
c2a0f5e5d0 feat: change project title input to auto-grow on content change 2021-01-05 18:53:48 -06:00
Jordan Knott
ff15e7fb53 feat: hide project finder after selecting project 2021-01-05 17:00:31 -06:00
Jordan Knott
b5744bcf22 fix: fix task position to use task idx not task group idx 2021-01-05 17:00:07 -06:00
Jordan Knott
a7c1ca328f feat: add search and minify to project finder 2021-01-05 16:46:49 -06:00
Jordan Knott
783e1c84c3 feat: add seed command to generate fake project data 2021-01-05 16:46:15 -06:00
Jordan Knott
433a4fd55c
docs(README): update readme feature list & heading 2021-01-04 16:24:36 -06:00
dependabot[bot]
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
Jordan Knott
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
Jordan Knott
f215418be1 fix: fix background not changing on hover in extra menu on sort popup 2021-01-03 17:11:08 -06:00
Jordan Knott
f051bebd48 feat(MyTasks): allow filtering by task complete status 2021-01-03 17:04:15 -06:00
Jordan Knott
a1c9251a1f fix(TaskDetails): blur task title textarea on pressing enter 2021-01-03 15:59:53 -06:00
Jordan Knott
9d7f46907f fix(MyTasks): update task entry name when updating name through TaskDetails 2021-01-03 15:54:32 -06:00
Jordan Knott
dcf53b9077 feat: add my tasks list view 2021-01-01 22:20:55 -06:00
Jordan Knott
d6101d9221 feat: redesign task due date manager 2021-01-01 14:54:05 -06:00
Jordan Knott
a8b3809515 feat: allow access token expiration to be set in the config 2020-12-30 21:10:55 -06:00
Jordan Knott
f16cceb0e1 feat: add ui skeleton to Task Details while loading 2020-12-30 19:14:00 -06:00
Jordan Knott
90b92781d7 refactor(Magefile): add build info in backend:build through ldflags 2020-12-29 19:37:14 -06:00
branchmispredictor
1bac555ebb fix: respect jwt validation errors 2020-12-29 17:42:38 -06:00
Jordan Knott
668b118b25 fix: admin created users are now set to be active by default 2020-12-29 17:25:42 -06:00
Jordan Knott
9c051c51a6 fix: add cascade delete to task comment & activity rows 2020-12-25 22:16:16 -06:00
Jordan Knott
66c603de75 docs: update contributing & redirect feature request to discussions 2020-12-24 20:18:20 -06:00
Jordan Knott
8d3b0bd510 fix: fix flashing on pollInterval 2020-12-23 20:02:38 -06:00
Jordan Knott
9f27bd157f feat: smtp server for sending email can now be set by config 2020-12-23 16:44:13 -06:00
Jordan Knott
e25a426e7b fix: update editor style to use new format for theme colors 2020-12-23 16:15:20 -06:00
Jordan Knott
0c9ab8abc2 feat: add update polling to relevant views 2020-12-23 15:55:17 -06:00
Jordan Knott
c4a80590a1 fix: fix issue where personal projects did not show up in Project Finder 2020-12-23 13:21:06 -06:00
Jordan Knott
978be2218d fix: fix issue where the Task Details modal would false when changing due date 2020-12-23 13:17:54 -06:00
Jordan Knott
19deab0515 feat: add task activity 2020-12-23 13:15:15 -06:00
284 changed files with 31734 additions and 22918 deletions

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

View File

@ -1,30 +0,0 @@
---
name: Feature request
about: Create a feature request to help improve Taskcafe
title: ""
labels: ""
assignees: ""
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
<!--
Be aware, not all feature requests will get accepted.
Please read the contributing guide before working on any new pull requests!
If you would like to ask a question regarding a possible bug or feature request, please
join the Taskcafe discord - https://discord.gg/JkQDruh
-->

View File

@ -10,6 +10,8 @@ windows:
- yarn: - yarn:
- cd frontend - cd frontend
- yarn start - yarn start
- worker:
- go run cmd/taskcafe/main.go worker
- web/editor: - web/editor:
root: ./frontend root: ./frontend
panes: panes:

View File

@ -4,17 +4,26 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## UNRELEASED
### Added ### Added
- Task sorting & filtering - On login page, redirects to `/register` if no users exist (to help streamline initial setup)
- Redesigned the Task Details UI
- Implement task group actions (duplicate/delete all tasks/sort)
### Fixed ### Fixed
- removed CORS middleware to fix security issue - Fixes new user popup form so that it can now be submitted
- Added 3 retries with backoff to initial database connection [(#47)](https://github.com/JordanKnott/taskcafe/issues/47)
- Can now actually set a due date ## [0.3.5] - 2021-09-04
### Added
- Project visibility can now be set to public - meaning anyone can view the project board
- When redirected to login page while trying to view a page that requires login, you'll be redirected back to the correct page after login
- When creating a new label within the LabelManager on a card, the new label will automatically be applied to the task after creation
### Changed
- Switch primary font to Open Sans
### Fixed
- Any open popups are hidden when closing the Task Details window
## [0.1.1] - 2020-08-21 ## [0.1.1] - 2020-08-21

View File

@ -6,7 +6,9 @@ Thanks for wanting to contribute to Taskcafe!
So you want to contribute to Taskcafe? Great! So you want to contribute to Taskcafe? Great!
If you have noticed a bug or want to add a new feature, please [create an issue](https://github.com/JordanKnott/taskcafe/issues/new/choose) for it before starting any work on a pull request. 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. Alternatively you can join the [Taskcafe discord](https://discord.gg/JkQDruh) and ask in the #questions channel.

View File

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

71
Pipfile.lock generated
View File

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

View File

@ -18,27 +18,34 @@
<img alt="Docker pulls" src="https://img.shields.io/docker/pulls/taskcafe/taskcafe" /> <img alt="Docker pulls" src="https://img.shields.io/docker/pulls/taskcafe/taskcafe" />
</a> </a>
</p> </p>
<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"> <p align="center">
Was this project useful? Please consider <a href="https://www.buymeacoffee.com/jordanknott">donating</a> to help me improve it! Was this project useful? Please consider <a href="https://www.buymeacoffee.com/jordanknott">donating</a> to help me improve it!
</p> </p>
<p align="center">
**Please note that this project is still in active development. Some options may not work yet! For updates on development, join the Discord server** This project is still in <strong>alpha development</strong></p>
![Taskcafe](./.github/taskcafe_preview.png) ![Taskcafe](./.github/taskcafe_preview.png)
## Features ## Features
Currently Taskcafe only offers basic task tracking through a Kanban board. The following features have been implemented:
Currently you can do the following to tasks: - Manage tasks through a Kanban board interface (set due dates, labels, add checklists)
- View all your current assigned tasks through the My Tasks view
- Personal projects
- Task comments and activity
- Task sorting & filtering This project is still in active development, so some options may not be fully implemented yet.
- Add colors & named labels
- Add due dates **For updates on development, join the [Discord server](https://discord.gg/JkQDruh).**
- Descriptions written in Markdown
- Assign members
- Checklists
- Mark tasks as complete
For a list of planned features, check out the [Roadmap](https://github.com/JordanKnott/taskcafe/wiki/Roadmap)! For a list of planned features, check out the [Roadmap](https://github.com/JordanKnott/taskcafe/wiki/Roadmap)!

View File

@ -17,8 +17,9 @@ user = 'taskcafe'
password = 'taskcafe_test' password = 'taskcafe_test'
[smtp] [smtp]
username = 'admin@example.com' username = 'taskcafe@example.com'
password = 'example' password = ''
server = 'mail.example.com' from = 'no-reply@taskcafe.com'
port = 465 host = 'localhost'
connection_security = 'STARTTLS' port = 11500
skip_verify = false

View File

@ -12,24 +12,18 @@ services:
volumes: volumes:
- taskcafe-postgres:/var/lib/postgresql/data - taskcafe-postgres:/var/lib/postgresql/data
ports: ports:
- 8855:5432 - 8865:5432
mailhog: mailhog:
image: mailhog/mailhog:latest image: mailhog/mailhog:latest
restart: always restart: always
ports: ports:
- 1025:1025 - 1025:1025
- 8025:8025 - 8025:8025
broker: redis:
image: rabbitmq:3-management image: redis:6.2
restart: always restart: always
ports: ports:
- 8060:15672 - 6379:6379
- 5672:5672
result_store:
image: memcached:1.6-alpine
restart: always
ports:
- 11211:11211
volumes: volumes:
taskcafe-postgres: taskcafe-postgres:

View File

@ -12,6 +12,9 @@ services:
environment: environment:
TASKCAFE_DATABASE_HOST: postgres TASKCAFE_DATABASE_HOST: postgres
TASKCAFE_MIGRATE: "true" TASKCAFE_MIGRATE: "true"
volumes:
- taskcafe-uploads:/root/uploads
postgres: postgres:
image: postgres:12.3-alpine image: postgres:12.3-alpine
restart: always restart: always
@ -22,11 +25,13 @@ services:
POSTGRES_PASSWORD: taskcafe_test POSTGRES_PASSWORD: taskcafe_test
POSTGRES_DB: taskcafe POSTGRES_DB: taskcafe
volumes: volumes:
- taskcafe-postgres:/var/lib/postgresql/data - taskcafe-postgres:/var/lib/postgresql/data
volumes: volumes:
taskcafe-postgres: taskcafe-postgres:
external: false external: false
taskcafe-uploads:
external: false
networks: networks:
taskcafe-test: taskcafe-test:

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

View File

@ -1,6 +1,6 @@
overwrite: true overwrite: true
schema: schema:
- '../internal/graph/schema.graphqls' - '../internal/graph/schema/*.gql'
documents: documents:
- 'src/shared/graphql/*.graphqls' - 'src/shared/graphql/*.graphqls'
- 'src/shared/graphql/**/*.ts' - 'src/shared/graphql/**/*.ts'

View File

@ -3,28 +3,25 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@apollo/client": "^3.0.0-rc.8", "@apollo/client": "^3.3.16",
"@apollo/react-common": "^3.1.4", "@apollo/react-common": "^3.1.4",
"@apollo/react-hooks": "^3.1.3", "@apollo/react-hooks": "^4.0.0",
"@types/axios": "^0.14.0", "@taskcafe/rich-markdown-editor": "^11.0.10",
"@types/color": "^3.0.1", "@types/color": "^3.0.1",
"@types/date-fns": "^2.6.0", "@types/dompurify": "^2.2.2",
"@types/jest": "^24.0.0", "@types/emoji-mart": "^3.0.4",
"@types/jwt-decode": "^2.2.1", "@types/jest": "^26.0.23",
"@types/lodash": "^4.14.149", "@types/lodash": "^4.14.168",
"@types/marked": "^1.2.2", "@types/node": "^15.0.1",
"@types/node": "^12.0.0", "@types/react": "^17.0.20",
"@types/query-string": "^6.3.0", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react": "^16.9.21", "@types/react-datepicker": "^3.1.8",
"@types/react-beautiful-dnd": "^12.1.1", "@types/react-dom": "^17.0.9",
"@types/react-datepicker": "^2.11.0", "@types/react-router": "^5.1.13",
"@types/react-dom": "^16.9.5", "@types/react-router-dom": "^5.1.7",
"@types/react-router": "^5.1.4", "@types/react-select": "^4.0.15",
"@types/react-router-dom": "^5.1.3",
"@types/react-select": "^3.0.13",
"@types/react-timeago": "^4.1.1", "@types/react-timeago": "^4.1.1",
"@types/react-window": "^1.8.2", "@types/styled-components": "^5.1.0",
"@types/styled-components": "^5.0.0",
"apollo-cache-inmemory": "^1.6.5", "apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8", "apollo-client": "^2.6.8",
"apollo-link": "^1.2.13", "apollo-link": "^1.2.13",
@ -32,38 +29,43 @@
"apollo-link-http": "^1.5.16", "apollo-link-http": "^1.5.16",
"apollo-link-state": "^0.4.2", "apollo-link-state": "^0.4.2",
"apollo-utilities": "^1.3.3", "apollo-utilities": "^1.3.3",
"axios": "^0.19.2", "axios": "^0.21.1",
"axios-auth-refresh": "^2.2.7", "axios-auth-refresh": "^3.1.0",
"color": "^3.1.2", "color": "^3.1.2",
"date-fns": "^2.14.0", "date-fns": "^2.21.1",
"dayjs": "^1.9.1", "dayjs": "^1.10.4",
"graphql": "^15.0.0", "dompurify": "^2.2.8",
"graphql-tag": "^2.10.3", "emoji-mart": "^3.0.1",
"history": "^4.10.1", "emoticon": "^4.0.0",
"immer": "^6.0.3", "graphql": "^15.5.0",
"jwt-decode": "^2.2.0", "graphql-tag": "^2.12.4",
"lodash": "^4.17.20", "history": "^5.0.0",
"marked": "^2.0.0", "immer": "^9.0.2",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"loglevel": "^1.7.1",
"loglevel-plugin-remote": "^0.6.8",
"node-emoji": "^1.10.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"query-string": "^6.13.7", "query-string": "^7.0.0",
"react": "^16.12.0", "react": "^17.0.2",
"react-autosize-textarea": "^7.0.0", "react-autosize-textarea": "^7.0.0",
"react-beautiful-dnd": "^13.0.0", "react-beautiful-dnd": "^13.1.0",
"react-datepicker": "^2.14.1", "react-datepicker": "^3.8.0",
"react-dom": "^16.12.0", "react-dom": "^17.0.2",
"react-hook-form": "^6.0.6", "react-emoji-render": "^1.2.4",
"react-markdown": "^4.3.1", "react-hook-form": "^7.3.6",
"react-markdown": "^6.0.1",
"react-router": "^5.1.2", "react-router": "^5.1.2",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-scripts": "3.4.0", "react-scripts": "4.0.3",
"react-select": "^3.1.0", "react-select": "^4.3.0",
"react-timeago": "^4.4.0", "react-timeago": "^5.2.0",
"react-toastify": "^6.0.8", "react-toastify": "^7.0.4",
"react-visibility-sensor": "^5.1.1", "rich-markdown-editor": "^11.17.4-0",
"react-window": "^1.8.6", "styled-components": "^5.2.3",
"rich-markdown-editor": "^10.6.5", "typescript": "~4.2.4",
"styled-components": "^5.0.1", "unist-util-visit": "^4.0.0"
"typescript": "~3.7.2"
}, },
"proxy": "http://localhost:3333", "proxy": "http://localhost:3333",
"scripts": { "scripts": {
@ -71,8 +73,6 @@
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"storybook": "start-storybook -p 9009 -s public",
"build-storybook": "build-storybook -s public",
"generate": "graphql-codegen", "generate": "graphql-codegen",
"lint": "eslint --ext js,ts,tsx src", "lint": "eslint --ext js,ts,tsx src",
"tsc": "tsc" "tsc": "tsc"
@ -93,30 +93,20 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^1.13.2", "@graphql-codegen/cli": "^1.21.4",
"@graphql-codegen/typescript": "^1.13.2", "@graphql-codegen/typescript": "^1.22.0",
"@graphql-codegen/typescript-operations": "^1.13.2", "@graphql-codegen/typescript-operations": "^1.17.16",
"@graphql-codegen/typescript-react-apollo": "^1.13.2", "@graphql-codegen/typescript-react-apollo": "^2.2.4",
"@storybook/addon-actions": "^5.3.13", "@typescript-eslint/eslint-plugin": "^4.22.0",
"@storybook/addon-backgrounds": "^5.3.17", "@typescript-eslint/parser": "^4.22.0",
"@storybook/addon-docs": "^5.3.17", "eslint": "^7.25.0",
"@storybook/addon-knobs": "^5.3.17",
"@storybook/addon-links": "^5.3.13",
"@storybook/addon-storysource": "^5.3.17",
"@storybook/addon-viewport": "^5.3.17",
"@storybook/addons": "^5.3.13",
"@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",
"eslint-config-airbnb": "^18.0.1", "eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.10.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.20.1", "eslint-plugin-import": "^2.20.1",
"eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-prettier": "^3.1.2", "eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-react": "^7.18.3", "eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^1.7.0", "eslint-plugin-react-hooks": "^4.2.0",
"prettier": "^1.19.1" "prettier": "^2.2.1"
} }
} }

View File

@ -10,16 +10,17 @@ import {
UsersDocument, UsersDocument,
UsersQuery, UsersQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import Input from 'shared/components/Input';
import styled from 'styled-components'; import styled from 'styled-components';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller, UseFormSetError } from 'react-hook-form';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer'; import produce from 'immer';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import { Redirect } from 'react-router'; import { Redirect } from 'react-router';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import ControlledInput from 'shared/components/ControlledInput';
import FormInput from 'shared/components/FormInput';
const DeleteUserWrapper = styled.div` const DeleteUserWrapper = styled.div`
display: flex; display: flex;
@ -77,55 +78,56 @@ const CreateUserButton = styled(Button)`
width: 100%; width: 100%;
`; `;
const AddUserInput = styled(Input)` const AddUserInput = styled(FormInput)`
margin-bottom: 8px; margin-bottom: 8px;
`; `;
const InputError = styled.span` const InputError = styled.span`
color: ${props => props.theme.colors.danger}; color: ${(props) => props.theme.colors.danger};
font-size: 12px; font-size: 12px;
`; `;
type AddUserPopupProps = { type AddUserPopupProps = {
onAddUser: (user: CreateUserData) => void; onAddUser: (user: CreateUserData, setError: UseFormSetError<CreateUserData>) => void;
}; };
const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => { const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
const { register, handleSubmit, errors, control } = useForm<CreateUserData>(); const {
register,
handleSubmit,
formState: { errors },
setError,
control,
} = useForm<CreateUserData>();
const createUser = (data: CreateUserData) => { const createUser = (data: CreateUserData) => {
onAddUser(data); onAddUser(data, setError);
}; };
return ( return (
<CreateUserForm onSubmit={handleSubmit(createUser)}> <CreateUserForm onSubmit={handleSubmit(createUser)}>
<AddUserInput <AddUserInput
floatingLabel
width="100%" width="100%"
label="Full Name" label="Full Name"
id="fullName"
name="fullName"
variant="alternate" variant="alternate"
ref={register({ required: 'Full name is required' })} {...register('fullName', { required: 'Full name is required' })}
/> />
{errors.fullName && <InputError>{errors.fullName.message}</InputError>} {errors.fullName && <InputError>{errors.fullName.message}</InputError>}
<AddUserInput <AddUserInput
floatingLabel floatingLabel
width="100%" width="100%"
label="Email" label="Email"
id="email"
name="email"
variant="alternate" variant="alternate"
ref={register({ required: 'Email is required' })} {...register('email', { required: 'Email is required' })}
/> />
{errors.email && <InputError>{errors.email.message}</InputError>}
<Controller <Controller
control={control} control={control}
name="roleCode" name="roleCode"
rules={{ required: 'Role is required' }} rules={{ required: 'Role is required' }}
render={({ onChange, value }) => ( render={({ field }) => (
<Select <Select
{...field}
label="Role" label="Role"
value={value}
onChange={onChange}
options={[ options={[
{ label: 'Admin', value: 'admin' }, { label: 'Admin', value: 'admin' },
{ label: 'Member', value: 'member' }, { label: 'Member', value: 'member' },
@ -138,31 +140,25 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
floatingLabel floatingLabel
width="100%" width="100%"
label="Username" label="Username"
id="username"
name="username"
variant="alternate" variant="alternate"
ref={register({ required: 'Username is required' })} {...register('username', { required: 'Username is required' })}
/> />
{errors.username && <InputError>{errors.username.message}</InputError>} {errors.username && <InputError>{errors.username.message}</InputError>}
<AddUserInput <AddUserInput
floatingLabel floatingLabel
width="100%" width="100%"
label="Initials" label="Initials"
id="initials"
name="initials"
variant="alternate" variant="alternate"
ref={register({ required: 'Initials is required' })} {...register('initials', { required: 'Initials is required' })}
/> />
{errors.initials && <InputError>{errors.initials.message}</InputError>} {errors.initials && <InputError>{errors.initials.message}</InputError>}
<AddUserInput <AddUserInput
floatingLabel floatingLabel
width="100%" width="100%"
label="Password" label="Password"
id="password"
name="password"
variant="alternate" variant="alternate"
type="password" type="password"
ref={register({ required: 'Password is required' })} {...register('password', { required: 'Password is required' })}
/> />
{errors.password && <InputError>{errors.password.message}</InputError>} {errors.password && <InputError>{errors.password.message}</InputError>}
<CreateUserButton type="submit">Create</CreateUserButton> <CreateUserButton type="submit">Create</CreateUserButton>
@ -174,15 +170,15 @@ const AdminRoute = () => {
useEffect(() => { useEffect(() => {
document.title = 'Admin | Taskcafé'; document.title = 'Admin | Taskcafé';
}, []); }, []);
const { loading, data } = useUsersQuery(); const { loading, data } = useUsersQuery({ fetchPolicy: 'cache-and-network' });
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({ const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache => updateApolloCache<UsersQuery>(client, UsersDocument, (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
draftCache.invitedUsers = cache.invitedUsers.filter( draftCache.invitedUsers = cache.invitedUsers.filter(
u => u.id !== response.data?.deleteInvitedUserAccount.invitedUser.id, (u) => u.id !== response.data?.deleteInvitedUserAccount.invitedUser.id,
); );
}), }),
); );
@ -190,9 +186,9 @@ const AdminRoute = () => {
}); });
const [deleteUser] = useDeleteUserAccountMutation({ const [deleteUser] = useDeleteUserAccountMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache => updateApolloCache<UsersQuery>(client, UsersDocument, (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
draftCache.users = cache.users.filter(u => u.id !== response.data?.deleteUserAccount.userAccount.id); draftCache.users = cache.users.filter((u) => u.id !== response.data?.deleteUserAccount.userAccount.id);
}), }),
); );
}, },
@ -214,13 +210,13 @@ const AdminRoute = () => {
}); });
}, },
}); });
if (loading) {
return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />;
}
if (data && user) { if (data && user) {
/*
TODO: add permision check
if (user.roles.org !== 'admin') { if (user.roles.org !== 'admin') {
return <Redirect to="/" />; return <Redirect to="/" />;
} }
*/
return ( return (
<> <>
<GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} /> <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
@ -228,12 +224,13 @@ const AdminRoute = () => {
initialTab={0} initialTab={0}
users={data.users} users={data.users}
invitedUsers={data.invitedUsers} invitedUsers={data.invitedUsers}
canInviteUser={user.roles.org === 'admin'} // canInviteUser={user.roles.org === 'admin'} TODO: add permision check
canInviteUser
onInviteUser={NOOP} onInviteUser={NOOP}
onUpdateUserPassword={() => { onUpdateUserPassword={() => {
hidePopup(); hidePopup();
}} }}
onDeleteInvitedUser={invitedUserID => { onDeleteInvitedUser={(invitedUserID) => {
deleteInvitedUser({ variables: { invitedUserID } }); deleteInvitedUser({ variables: { invitedUserID } });
hidePopup(); hidePopup();
}} }}
@ -241,15 +238,20 @@ const AdminRoute = () => {
deleteUser({ variables: { userID, newOwnerID } }); deleteUser({ variables: { userID, newOwnerID } });
hidePopup(); hidePopup();
}} }}
onAddUser={$target => { onAddUser={($target) => {
showPopup( showPopup(
$target, $target,
<Popup tab={0} title="Add member" onClose={() => hidePopup()}> <Popup tab={0} title="Add member" onClose={() => hidePopup()}>
<AddUserPopup <AddUserPopup
onAddUser={u => { onAddUser={(u, setError) => {
const { roleCode, ...userData } = u; const { roleCode, ...userData } = u;
createUser({ variables: { ...userData, roleCode: roleCode.value } }); createUser({
hidePopup(); variables: { ...userData, roleCode: roleCode.value },
})
.then(() => hidePopup())
.catch((e) => {
setError('email', { type: 'validate', message: e.message });
});
}} }}
/> />
</Popup>, </Popup>,
@ -259,7 +261,7 @@ const AdminRoute = () => {
</> </>
); );
} }
return <span>error</span>; return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />;
}; };
export default AdminRoute; export default AdminRoute;

View File

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

View File

@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Switch, Route, useHistory } from 'react-router-dom'; import { Switch, Route, useHistory, useLocation, Redirect } from 'react-router-dom';
import * as H from 'history'; import * as H from 'history';
import Dashboard from 'Dashboard'; import Dashboard from 'Dashboard';
import Admin from 'Admin'; import Admin from 'Admin';
import MyTasks from 'MyTasks';
import Confirm from 'Confirm'; import Confirm from 'Confirm';
import Projects from 'Projects'; import Projects from 'Projects';
import Project from 'Projects/Project'; import Project from 'Projects/Project';
@ -12,10 +13,7 @@ import Login from 'Auth';
import Register from 'Register'; import Register from 'Register';
import Profile from 'Profile'; import Profile from 'Profile';
import styled from 'styled-components'; import styled from 'styled-components';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import Outline from 'Outline';
const MainContent = styled.div` const MainContent = styled.div`
padding: 0 0 0 0; padding: 0 0 0 0;
@ -26,73 +24,64 @@ const MainContent = styled.div`
flex-grow: 1; flex-grow: 1;
`; `;
type RefreshTokenResponse = { type ValidateTokenResponse = {
accessToken: string; valid: boolean;
setup?: null | { confirmToken: string }; userID: string;
}; };
const AuthorizedRoutes = () => { const UserRequiredRoute: React.FC<any> = ({ children }) => {
const history = useHistory(); const { user } = useCurrentUser();
const location = useLocation();
if (user) {
return children;
}
return (
<Redirect
to={{
pathname: '/login',
state: { redirect: location.pathname },
}}
/>
);
};
const Routes: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { setUser } = useCurrentUser(); const { setUser } = useCurrentUser();
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); fetch('/auth/validate', {
fetch('/auth/refresh_token', {
signal: abortController.signal,
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}).then(async x => { }).then(async (x) => {
const { status } = x; const response: ValidateTokenResponse = await x.json();
if (status === 400) { const { valid, userID } = response;
history.replace('/login'); if (valid) {
} else { setUser(userID);
const response: RefreshTokenResponse = await x.json();
const { accessToken, setup } = response;
if (setup) {
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
}
} }
setLoading(false); setLoading(false);
}); });
return () => {
abortController.abort();
};
}, []); }, []);
return loading ? null : ( if (loading) return null;
return (
<Switch> <Switch>
<MainContent> <Route exact path="/login" component={Login} />
<Route exact path="/" component={Dashboard} /> <Route exact path="/register" component={Register} />
<Route exact path="/projects" component={Projects} /> <Route exact path="/confirm" component={Confirm} />
<Route path="/projects/:projectID" component={Project} /> <Switch>
<Route path="/teams/:teamID" component={Teams} /> <MainContent>
<Route path="/outline" component={Outline} /> <Route path="/p/:projectID" component={Project} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} /> <UserRequiredRoute>
</MainContent> <Route exact path="/" component={Projects} />
<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> </Switch>
); );
}; };
type RoutesProps = {
history: H.History;
};
const Routes: React.FC<RoutesProps> = () => (
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/register" component={Register} />
<Route exact path="/confirm" component={Confirm} />
<AuthorizedRoutes />
</Switch>
);
export default Routes; export default Routes;

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,438 +0,0 @@
import React from 'react';
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
import styled from 'styled-components/macro';
import { ProfileMenu } from 'shared/components/DropdownMenu';
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { useHistory } from 'react-router';
import { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import {
RoleCode,
useTopNavbarQuery,
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';
import NOOP from 'shared/utils/noop';
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
import theme from './ThemeStyles';
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: ${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: ${props => props.theme.colors.text.secondary};
}
&:hover ${TeamProjectAvatar} {
opacity: 1;
}
&:hover ${TeamProjectBackground}:before {
opacity: 0.78;
}
`;
const colors = [theme.colors.primary, theme.colors.secondary];
const ProjectFinder = () => {
const { loading, data } = useGetProjectsQuery();
if (loading) {
return <span>loading</span>;
}
if (data) {
const { projects, teams } = data;
const projectTeams = teams.map(team => {
return {
id: team.id,
name: team.name,
projects: projects.filter(project => project.team && 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: any;
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, { width: 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>;
projectInvitedMembers?: null | Array<InvitedUser>;
onSaveProjectName?: (projectName: string) => void;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
onSetTab?: (tab: number) => void;
onRemoveFromBoard?: (userID: string) => void;
onRemoveInvitedFromBoard?: (email: string) => void;
};
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
currentTab,
onSetTab,
menuType,
teamID,
onChangeProjectOwner,
onChangeRole,
name,
popupContent,
projectMembers,
projectInvitedMembers,
onInviteUser,
onSaveProjectName,
onRemoveInvitedFromBoard,
onRemoveFromBoard,
}) => {
const { user, setUserRoles, setUser } = useCurrentUser();
const { loading, data } = useTopNavbarQuery({
onCompleted: response => {
if (user && user.roles) {
setUserRoles({
org: user.roles.org,
teams: response.me.teamRoles.reduce((map, obj) => {
map.set(obj.teamID, obj.roleCode);
return map;
}, new Map<string, string>()),
projects: response.me.projectRoles.reduce((map, obj) => {
map.set(obj.projectID, obj.roleCode);
return map;
}, new Map<string, string>()),
});
}
},
});
const { showPopup, hidePopup } = 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>,
{ 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 },
);
}
};
if (!user) {
return null;
}
const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => {
const member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null;
if (member) {
showPopup(
$targetRef,
<MiniProfile
onRemoveFromBoard={() => {
if (onRemoveInvitedFromBoard) {
onRemoveInvitedFromBoard(member.email);
}
}}
invited
user={{
id: member.email,
fullName: member.email,
bio: 'Invited',
profileIcon: {
bgColor: '#000',
url: null,
initials: member.email.charAt(0),
},
}}
bio=""
/>,
);
}
};
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
const warning =
'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}
onInvitedMemberProfile={onInvitedMemberProfile}
onInviteUser={onInviteUser}
onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner}
onNotificationClick={onNotificationClick}
onSetTab={onSetTab}
onRemoveFromBoard={onRemoveFromBoard}
onDashboardClick={() => {
history.push('/');
}}
projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
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,278 @@
import React, { useState } from 'react';
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
import LoggedOutNavbar from 'shared/components/TopNavbar/LoggedOut';
import { ProfileMenu } from 'shared/components/DropdownMenu';
import polling from 'shared/utils/polling';
import { useHistory, useRouteMatch } from 'react-router';
import { useCurrentUser } from 'App/context';
import {
RoleCode,
useTopNavbarQuery,
useNotificationAddedSubscription,
useHasUnreadNotificationsQuery,
} 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 [notifications, setNotifications] = useState<Array<{ id: string; notification: { actionType: string } }>>([]);
const { data } = useTopNavbarQuery({
onCompleted: (d) => {
setNotifications((n) => [...n, ...d.notifications]);
},
});
const { data: nData, loading } = useNotificationAddedSubscription({
onSubscriptionData: (d) => {
setNotifications((n) => {
if (d.subscriptionData.data) {
return [...n, d.subscriptionData.data.notificationAdded];
}
return n;
});
},
});
const { showPopup, hidePopup } = usePopup();
const { setUser } = useCurrentUser();
const { data: unreadData, refetch: refetchHasUnread } = useHasUnreadNotificationsQuery({
pollInterval: polling.UNREAD_NOTIFICATIONS,
});
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 });
}
};
// TODO: rewrite popup to contain subscription and notification fetch
const onNotificationClick = ($target: React.RefObject<HTMLElement>) => {
showPopup($target, <NotificationPopup onToggleRead={() => refetchHasUnread()} />, {
width: 605,
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
hasUnread={unreadData ? unreadData.hasUnreadNotifications.unread : false}
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(); const cache = new InMemoryCache();

View File

@ -1,79 +1,20 @@
import React, { useContext } from 'react'; 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 = { type UserContextState = {
user: CurrentUserRaw | null; user: string | null;
setUser: (user: CurrentUserRaw | null) => void; setUser: (user: string | null) => void;
setUserRoles: (roles: CurrentUserRoles) => void;
}; };
export const UserContext = React.createContext<UserContextState>({ export const UserContext = React.createContext<UserContextState>({
user: null, user: null,
setUser: _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 = () => { export const useCurrentUser = () => {
const { user, setUser, setUserRoles } = useContext(UserContext); const { user, setUser } = useContext(UserContext);
let currentUser: CurrentUser | null = null;
if (user) {
currentUser = {
...user,
isAdmin(level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) {
if (user.roles.org === 'admin') {
return true;
}
switch (level) {
case PermissionLevel.TEAM:
return subjectID ? this.roles.teams.get(subjectID) === 'admin' : false;
default:
return false;
}
},
isVisible(level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) {
if (user.roles.org === 'admin') {
return true;
}
switch (level) {
case PermissionLevel.TEAM:
return subjectID ? this.roles.teams.get(subjectID) !== null : false;
default:
return false;
}
},
};
}
return { return {
user: currentUser, user,
setUser, 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,75 +1,32 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import jwtDecode from 'jwt-decode'; import { BrowserRouter } from 'react-router-dom';
import { createBrowserHistory } from 'history';
import { Router } from 'react-router';
import { PopupProvider } from 'shared/components/PopupMenu'; import { PopupProvider } from 'shared/components/PopupMenu';
import { ToastContainer } from 'react-toastify';
import { setAccessToken } from 'shared/utils/accessToken';
import styled, { ThemeProvider } from 'styled-components'; import styled, { ThemeProvider } from 'styled-components';
import NormalizeStyles from './NormalizeStyles'; import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles'; import BaseStyles from './BaseStyles';
import theme from './ThemeStyles'; import theme from './ThemeStyles';
import Routes from './Routes'; import Routes from './Routes';
import { UserContext, CurrentUserRaw, CurrentUserRoles, PermissionLevel, PermissionObjectType } from './context'; import ToastedContainer from './Toast';
import { UserContext } from './context';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import './fonts.css';
const StyledContainer = styled(ToastContainer).attrs({
// custom props
})`
.Toastify__toast-container {
}
.Toastify__toast {
padding: 5px;
margin-left: 5px;
margin-right: 5px;
border-radius: 10px;
background: #7367f0;
color: #fff;
}
.Toastify__toast--error {
background: ${props => props.theme.colors.danger};
}
.Toastify__toast--warning {
background: ${props => props.theme.colors.warning};
}
.Toastify__toast--success {
background: ${props => props.theme.colors.success};
}
.Toastify__toast-body {
}
.Toastify__progress-bar {
}
.Toastify__close-button {
display: none;
}
`;
const history = createBrowserHistory();
const App = () => { const App = () => {
const [user, setUser] = useState<CurrentUserRaw | null>(null); const [user, setUser] = useState<string | null>(null);
const setUserRoles = (roles: CurrentUserRoles) => {
if (user) {
setUser({
...user,
roles,
});
}
};
return ( return (
<> <>
<UserContext.Provider value={{ user, setUser, setUserRoles }}> <UserContext.Provider value={{ user, setUser }}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<NormalizeStyles /> <NormalizeStyles />
<BaseStyles /> <BaseStyles />
<Router history={history}> <BrowserRouter>
<PopupProvider> <PopupProvider>
<Routes history={history} /> <Routes />
</PopupProvider> </PopupProvider>
</Router> </BrowserRouter>
<StyledContainer <ToastedContainer
position="bottom-right" position="bottom-right"
autoClose={5000} autoClose={5000}
hideProgressBar hideProgressBar

View File

@ -4,10 +4,20 @@ export const Container = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100vw;
height: 100vh; height: 100vh;
@media (max-width: 600px) {
position: relative;
top: 30%;
font-size: 150px;
}
`; `;
export const LoginWrapper = styled.div` export const LoginWrapper = styled.div`
width: 60%; width: 70%;
@media (max-width: 600px) {
width: 90%;
margin-top: 50vh;
}
`; `;

View File

@ -1,7 +1,5 @@
import React, { useState, useEffect, useContext } from 'react'; import React, { useState, useEffect, useContext } from 'react';
import { useHistory } from 'react-router'; import { useHistory, useLocation } from 'react-router';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import Login from 'shared/components/Login'; import Login from 'shared/components/Login';
import UserContext from 'App/context'; import UserContext from 'App/context';
import { Container, LoginWrapper } from './Styles'; import { Container, LoginWrapper } from './Styles';
@ -9,6 +7,7 @@ import { Container, LoginWrapper } from './Styles';
const Auth = () => { const Auth = () => {
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0); const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
const history = useHistory(); const history = useHistory();
const location = useLocation<{ redirect: string } | undefined>();
const { setUser } = useContext(UserContext); const { setUser } = useContext(UserContext);
const login = ( const login = (
data: LoginFormData, data: LoginFormData,
@ -22,7 +21,7 @@ const Auth = () => {
username: data.username, username: data.username,
password: data.password, password: data.password,
}), }),
}).then(async x => { }).then(async (x) => {
if (x.status === 401) { if (x.status === 401) {
setInvalidLoginAttempt(invalidLoginAttempt + 1); setInvalidLoginAttempt(invalidLoginAttempt + 1);
setError('username', { type: 'error', message: 'Invalid username' }); setError('username', { type: 'error', message: 'Invalid username' });
@ -30,42 +29,27 @@ const Auth = () => {
setComplete(true); setComplete(true);
} else { } else {
const response = await x.json(); const response = await x.json();
const { accessToken } = response; const { userID } = response;
const claims: JWTToken = JwtDecode(accessToken); setUser(userID);
const currentUser = { if (location.state && location.state.redirect) {
id: claims.userId, history.push(location.state.redirect);
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() }, } else {
}; history.push('/');
setUser(currentUser); }
setComplete(true);
setAccessToken(accessToken);
history.push('/');
} }
}); });
}; };
useEffect(() => { useEffect(() => {
fetch('/auth/refresh_token', { fetch('/auth/validate', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}).then(async x => { }).then(async (x) => {
const { status } = x; const response = await x.json();
if (status === 200) { const { valid, userID } = response;
const response: RefreshTokenResponse = await x.json(); if (valid) {
const { accessToken, setup } = response; setUser(userID);
if (setup) { history.replace('/projects');
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
history.replace('/projects');
}
} }
}); });
}, []); }, []);

View File

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

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,947 @@
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 { StaticContext } from 'react-router';
import MyTasksSortPopup from './MyTasksSort';
import MyTasksStatusPopup from './MyTasksStatus';
import TaskEntry from './TaskEntry';
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) {
hidePopup();
updateTaskDueDate({
variables: {
taskID: dateEditor.task.id,
dueDate,
hasTime,
deleteNotifications: [],
updateNotifications: [],
createNotifications: [],
},
});
setDateEditor((prev) => ({
...prev,
task: { ...task, dueDate: { at: dueDate.toISOString(), notifications: [] }, hasTime },
}));
}
}}
onRemoveDueDate={(task) => {
if (dateEditor.task) {
hidePopup();
updateTaskDueDate({
variables: {
taskID: dateEditor.task.id,
dueDate: null,
hasTime: false,
deleteNotifications: [],
updateNotifications: [],
createNotifications: [],
},
});
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.at);
const second = dayjs(b.dueDate.at);
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,
deleteNotifications: [],
updateNotifications: [],
createNotifications: [],
},
});
}}
project={projectName ?? 'none'}
dueDate={task.dueDate.at}
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.at).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,24 +0,0 @@
import React from 'react';
import { DragDebugWrapper } from './Styles';
type DragDebugProps = {
zone: ImpactZone | null;
depthTarget: number;
draggedNodes: Array<string> | null;
};
const DragDebug: React.FC<DragDebugProps> = ({ zone, depthTarget, draggedNodes }) => {
let aboveID = null;
let belowID = null;
if (zone) {
aboveID = zone.above ? zone.above.node.id : null;
belowID = zone.below ? zone.below.node.id : null;
}
return (
<DragDebugWrapper>{`aboveID=${aboveID} / belowID=${belowID} / depthTarget=${depthTarget} draggedNodes=${
draggedNodes ? draggedNodes.toString() : null
}`}</DragDebugWrapper>
);
};
export default DragDebug;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar'; import GlobalTopNavbar from 'App/TopNavbar';
import { getAccessToken } from 'shared/utils/accessToken';
import Settings from 'shared/components/Settings'; import Settings from 'shared/components/Settings';
import { import {
useMeQuery, useMeQuery,
@ -45,18 +44,15 @@ const Projects = () => {
name="file" name="file"
style={{ display: 'none' }} style={{ display: 'none' }}
ref={$fileUpload} ref={$fileUpload}
onChange={e => { onChange={(e) => {
if (e.target.files) { if (e.target.files) {
const fileData = new FormData(); const fileData = new FormData();
fileData.append('file', e.target.files[0]); fileData.append('file', e.target.files[0]);
const accessToken = getAccessToken();
axios axios
.post('/users/me/avatar', fileData, { .post('/users/me/avatar', fileData, {
headers: { withCredentials: true,
Authorization: `Bearer ${accessToken}`,
},
}) })
.then(res => { .then((res) => {
if ($fileUpload && $fileUpload.current) { if ($fileUpload && $fileUpload.current) {
$fileUpload.current.value = ''; $fileUpload.current.value = '';
refetch(); refetch();
@ -66,7 +62,7 @@ const Projects = () => {
}} }}
/> />
<GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} /> <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
{!loading && data && ( {!loading && data && data.me && (
<Settings <Settings
profile={data.me.user} profile={data.me.user}
onProfileAvatarChange={() => { onProfileAvatarChange={() => {
@ -75,13 +71,13 @@ const Projects = () => {
} }
}} }}
onResetPassword={(password, done) => { onResetPassword={(password, done) => {
updateUserPassword({ variables: { userID: user.id, password } }); updateUserPassword({ variables: { userID: user, password } });
toast('Password was changed!'); toast('Password was changed!');
done(); done();
}} }}
onChangeUserInfo={(d, done) => { onChangeUserInfo={(d, done) => {
updateUserInfo({ updateUserInfo({
variables: { name: d.full_name, bio: d.bio, email: d.email, initials: d.initials }, variables: { name: d.fullName, bio: d.bio, email: d.email, initials: d.initials },
}); });
toast('User info was saved!'); toast('User info was saved!');
done(); done();

View File

@ -7,12 +7,13 @@ import { Popup, usePopup } from 'shared/components/PopupMenu';
import produce from 'immer'; import produce from 'immer';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
import Member from 'shared/components/Member'; import Member from 'shared/components/Member';
import { useLabelsQuery } from 'shared/generated/graphql';
const FilterMember = styled(Member)` const FilterMember = styled(Member)`
margin: 2px 0; margin: 2px 0;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
} }
`; `;
@ -28,7 +29,7 @@ export const Label = styled.li`
`; `;
export const CardLabel = styled.span<{ active: boolean; color: string }>` export const CardLabel = styled.span<{ active: boolean; color: string }>`
${props => ${(props) =>
props.active && props.active &&
css` css`
margin-left: 4px; margin-left: 4px;
@ -43,7 +44,7 @@ export const CardLabel = styled.span<{ active: boolean; color: string }>`
padding: 6px 12px; padding: 6px 12px;
position: relative; position: relative;
transition: padding 85ms, margin 85ms, box-shadow 85ms; transition: padding 85ms, margin 85ms, box-shadow 85ms;
background-color: ${props => props.color}; background-color: ${(props) => props.color};
color: #fff; color: #fff;
display: block; display: block;
max-width: 100%; max-width: 100%;
@ -71,7 +72,7 @@ export const ActionItem = styled.li`
align-items: center; align-items: center;
font-size: 14px; font-size: 14px;
&:hover { &:hover {
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
} }
`; `;
@ -80,7 +81,7 @@ export const ActionTitle = styled.span`
`; `;
const ActionItemSeparator = styled.li` const ActionItemSeparator = styled.li`
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)}; color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.4)};
font-size: 12px; font-size: 12px;
padding-left: 4px; padding-left: 4px;
padding-right: 4px; padding-right: 4px;
@ -107,18 +108,25 @@ const ActionItemLine = styled.div`
margin: 0.25rem !important; margin: 0.25rem !important;
`; `;
type FilterMetaProps = { type ControlFilterProps = {
filters: TaskMetaFilters; filters: TaskMetaFilters;
userID: string; userID: string;
labels: React.RefObject<Array<ProjectLabel>>; projectID: string;
members: React.RefObject<Array<TaskUser>>; members: React.RefObject<Array<TaskUser>>;
onChangeTaskMetaFilter: (filters: TaskMetaFilters) => void; onChangeTaskMetaFilter: (filters: TaskMetaFilters) => void;
}; };
const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter, userID, labels, members }) => { const ControlFilter: React.FC<ControlFilterProps> = ({
filters,
onChangeTaskMetaFilter,
userID,
projectID,
members,
}) => {
const [currentFilters, setFilters] = useState(filters); const [currentFilters, setFilters] = useState(filters);
const [nameFilter, setNameFilter] = useState(filters.taskName ? filters.taskName.name : ''); const [nameFilter, setNameFilter] = useState(filters.taskName ? filters.taskName.name : '');
const [currentLabel, setCurrentLabel] = useState(''); const [currentLabel, setCurrentLabel] = useState('');
const { data } = useLabelsQuery({ variables: { projectID } });
const handleSetFilters = (f: TaskMetaFilters) => { const handleSetFilters = (f: TaskMetaFilters) => {
setFilters(f); setFilters(f);
@ -127,7 +135,7 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
const handleNameChange = (nFilter: string) => { const handleNameChange = (nFilter: string) => {
handleSetFilters( handleSetFilters(
produce(currentFilters, draftFilters => { produce(currentFilters, (draftFilters) => {
draftFilters.taskName = nFilter !== '' ? { name: nFilter } : null; draftFilters.taskName = nFilter !== '' ? { name: nFilter } : null;
}), }),
); );
@ -138,7 +146,7 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
const handleSetDueDate = (filterType: DueDateFilterType, label: string) => { const handleSetDueDate = (filterType: DueDateFilterType, label: string) => {
handleSetFilters( handleSetFilters(
produce(currentFilters, draftFilters => { produce(currentFilters, (draftFilters) => {
if (draftFilters.dueDate && draftFilters.dueDate.type === filterType) { if (draftFilters.dueDate && draftFilters.dueDate.type === filterType) {
draftFilters.dueDate = null; draftFilters.dueDate = null;
} else { } else {
@ -157,8 +165,9 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
<ActionsList> <ActionsList>
<TaskNameInput <TaskNameInput
width="100%" width="100%"
onChange={e => handleNameChange(e.currentTarget.value)} onChange={(e) => handleNameChange(e.currentTarget.value)}
value={nameFilter} value={nameFilter}
autoFocus
variant="alternate" variant="alternate"
placeholder="Task name..." placeholder="Task name..."
/> />
@ -166,14 +175,14 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
<ActionItem <ActionItem
onClick={() => { onClick={() => {
handleSetFilters( handleSetFilters(
produce(currentFilters, draftFilters => { produce(currentFilters, (draftFilters) => {
if (members.current) { if (members.current) {
const member = members.current.find(m => m.id === userID); const member = members.current.find((m) => m.id === userID);
const draftMember = draftFilters.members.find(m => m.id === userID); const draftMember = draftFilters.members.find((m) => m.id === userID);
if (member && !draftMember) { if (member && !draftMember) {
draftFilters.members.push({ id: userID, username: member.username ? member.username : '' }); draftFilters.members.push({ id: userID, username: member.username ? member.username : '' });
} else { } else {
draftFilters.members = draftFilters.members.filter(m => m.id !== userID); draftFilters.members = draftFilters.members.filter((m) => m.id !== userID);
} }
} }
}), }),
@ -184,7 +193,7 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
<User width={12} height={12} /> <User width={12} height={12} />
</ItemIcon> </ItemIcon>
<ActionTitle>Just my tasks</ActionTitle> <ActionTitle>Just my tasks</ActionTitle>
{currentFilters.members.find(m => m.id === userID) && <ActiveIcon width={12} height={12} />} {currentFilters.members.find((m) => m.id === userID) && <ActiveIcon width={12} height={12} />}
</ActionItem> </ActionItem>
<ActionItem onClick={() => handleSetDueDate(DueDateFilterType.THIS_WEEK, 'Due this week')}> <ActionItem onClick={() => handleSetDueDate(DueDateFilterType.THIS_WEEK, 'Due this week')}>
<ItemIcon> <ItemIcon>
@ -227,10 +236,10 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
</Popup> </Popup>
<Popup tab={1} title="By Labels"> <Popup tab={1} title="By Labels">
<Labels> <Labels>
{labels.current && {data &&
labels.current data.findProject.labels
// .filter(label => '' === '' || (label.name && label.name.toLowerCase().startsWith(''.toLowerCase()))) // .filter(label => '' === '' || (label.name && label.name.toLowerCase().startsWith(''.toLowerCase())))
.map(label => ( .map((label) => (
<Label key={label.id}> <Label key={label.id}>
<CardLabel <CardLabel
key={label.id} key={label.id}
@ -241,9 +250,9 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
}} }}
onClick={() => { onClick={() => {
handleSetFilters( handleSetFilters(
produce(currentFilters, draftFilters => { produce(currentFilters, (draftFilters) => {
if (draftFilters.labels.find(l => l.id === label.id)) { if (draftFilters.labels.find((l) => l.id === label.id)) {
draftFilters.labels = draftFilters.labels.filter(l => l.id !== label.id); draftFilters.labels = draftFilters.labels.filter((l) => l.id !== label.id);
} else { } else {
draftFilters.labels.push({ draftFilters.labels.push({
id: label.id, id: label.id,
@ -264,16 +273,16 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
<Popup tab={2} title="By Member"> <Popup tab={2} title="By Member">
<ActionsList> <ActionsList>
{members.current && {members.current &&
members.current.map(member => ( members.current.map((member) => (
<FilterMember <FilterMember
key={member.id} key={member.id}
member={member} member={member}
showName showName
onCardMemberClick={() => { onCardMemberClick={() => {
handleSetFilters( handleSetFilters(
produce(currentFilters, draftFilters => { produce(currentFilters, (draftFilters) => {
if (draftFilters.members.find(m => m.id === member.id)) { if (draftFilters.members.find((m) => m.id === member.id)) {
draftFilters.members = draftFilters.members.filter(m => m.id !== member.id); draftFilters.members = draftFilters.members.filter((m) => m.id !== member.id);
} else { } else {
draftFilters.members.push({ id: member.id, username: member.username ?? '' }); draftFilters.members.push({ id: member.id, username: member.username ?? '' });
} }
@ -320,4 +329,4 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
); );
}; };
export default FilterMeta; export default ControlFilter;

View File

@ -1,7 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/utils/sorting'; import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/utils/sorting';
import { mixin } from 'shared/utils/styles'; import { Checkmark } from 'shared/icons';
const ActiveIcon = styled(Checkmark)`
position: absolute;
`;
export const ActionsList = styled.ul` export const ActionsList = styled.ul`
margin: 0; margin: 0;
@ -21,7 +25,7 @@ export const ActionItem = styled.li`
align-items: center; align-items: center;
font-size: 14px; font-size: 14px;
&:hover { &:hover {
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
} }
`; `;
@ -29,21 +33,12 @@ export const ActionTitle = styled.span`
margin-left: 20px; margin-left: 20px;
`; `;
const ActionItemSeparator = styled.li` type ControlSortProps = {
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; sorting: TaskSorting;
onChangeTaskSorting: (taskSorting: TaskSorting) => void; onChangeTaskSorting: (taskSorting: TaskSorting) => void;
}; };
const SortPopup: React.FC<SortPopupProps> = ({ sorting, onChangeTaskSorting }) => { const ControlSort: React.FC<ControlSortProps> = ({ sorting, onChangeTaskSorting }) => {
const [currentSorting, setSorting] = useState(sorting); const [currentSorting, setSorting] = useState(sorting);
const handleSetSorting = (s: TaskSorting) => { const handleSetSorting = (s: TaskSorting) => {
setSorting(s); setSorting(s);
@ -52,35 +47,41 @@ const SortPopup: React.FC<SortPopupProps> = ({ sorting, onChangeTaskSorting }) =
return ( return (
<ActionsList> <ActionsList>
<ActionItem onClick={() => handleSetSorting({ type: TaskSortingType.NONE, direction: TaskSortingDirection.ASC })}> <ActionItem onClick={() => handleSetSorting({ type: TaskSortingType.NONE, direction: TaskSortingDirection.ASC })}>
{currentSorting.type === TaskSortingType.NONE && <ActiveIcon width={12} height={12} />}
<ActionTitle>None</ActionTitle> <ActionTitle>None</ActionTitle>
</ActionItem> </ActionItem>
<ActionItem <ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.DUE_DATE, direction: TaskSortingDirection.ASC })} onClick={() => handleSetSorting({ type: TaskSortingType.DUE_DATE, direction: TaskSortingDirection.ASC })}
> >
{currentSorting.type === TaskSortingType.DUE_DATE && <ActiveIcon width={12} height={12} />}
<ActionTitle>Due date</ActionTitle> <ActionTitle>Due date</ActionTitle>
</ActionItem> </ActionItem>
<ActionItem <ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.MEMBERS, direction: TaskSortingDirection.ASC })} onClick={() => handleSetSorting({ type: TaskSortingType.MEMBERS, direction: TaskSortingDirection.ASC })}
> >
{currentSorting.type === TaskSortingType.MEMBERS && <ActiveIcon width={12} height={12} />}
<ActionTitle>Members</ActionTitle> <ActionTitle>Members</ActionTitle>
</ActionItem> </ActionItem>
<ActionItem <ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.LABELS, direction: TaskSortingDirection.ASC })} onClick={() => handleSetSorting({ type: TaskSortingType.LABELS, direction: TaskSortingDirection.ASC })}
> >
{currentSorting.type === TaskSortingType.LABELS && <ActiveIcon width={12} height={12} />}
<ActionTitle>Labels</ActionTitle> <ActionTitle>Labels</ActionTitle>
</ActionItem> </ActionItem>
<ActionItem <ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })} onClick={() => handleSetSorting({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
> >
{currentSorting.type === TaskSortingType.TASK_TITLE && <ActiveIcon width={12} height={12} />}
<ActionTitle>Task title</ActionTitle> <ActionTitle>Task title</ActionTitle>
</ActionItem> </ActionItem>
<ActionItem <ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.COMPLETE, direction: TaskSortingDirection.ASC })} onClick={() => handleSetSorting({ type: TaskSortingType.COMPLETE, direction: TaskSortingDirection.ASC })}
> >
{currentSorting.type === TaskSortingType.COMPLETE && <ActiveIcon width={12} height={12} />}
<ActionTitle>Complete</ActionTitle> <ActionTitle>Complete</ActionTitle>
</ActionItem> </ActionItem>
</ActionsList> </ActionsList>
); );
}; };
export default SortPopup; export default ControlSort;

View File

@ -30,7 +30,7 @@ export const ActionItem = styled.li`
align-items: center; align-items: center;
font-size: 14px; font-size: 14px;
&:hover { &:hover {
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
} }
&:hover ${ActionExtraMenuContainer} { &:hover ${ActionExtraMenuContainer} {
visibility: visible; visibility: visible;
@ -69,11 +69,11 @@ export const ActionExtraMenuItem = styled.li`
align-items: center; align-items: center;
font-size: 14px; font-size: 14px;
&:hover { &:hover {
background: rgb(${props => props.theme.colors.primary}); background: ${(props) => props.theme.colors.primary};
} }
`; `;
const ActionExtraMenuSeparator = styled.li` const ActionExtraMenuSeparator = styled.li`
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
font-size: 12px; font-size: 12px;
padding-left: 4px; padding-left: 4px;
padding-right: 4px; padding-right: 4px;
@ -85,12 +85,12 @@ const ActiveIcon = styled(Checkmark)`
position: absolute; position: absolute;
`; `;
type FilterStatusProps = { type ControlStatusProps = {
filter: TaskStatusFilter; filter: TaskStatusFilter;
onChangeTaskStatusFilter: (filter: TaskStatusFilter) => void; onChangeTaskStatusFilter: (filter: TaskStatusFilter) => void;
}; };
const FilterStatus: React.FC<FilterStatusProps> = ({ filter, onChangeTaskStatusFilter }) => { const ControlStatus: React.FC<ControlStatusProps> = ({ filter, onChangeTaskStatusFilter }) => {
const [currentFilter, setFilter] = useState(filter); const [currentFilter, setFilter] = useState(filter);
const handleFilterChange = (f: TaskStatusFilter) => { const handleFilterChange = (f: TaskStatusFilter) => {
setFilter(f); setFilter(f);
@ -146,4 +146,4 @@ const FilterStatus: React.FC<FilterStatusProps> = ({ filter, onChangeTaskStatusF
); );
}; };
export default FilterStatus; export default ControlStatus;

View File

@ -49,9 +49,9 @@ import LabelManagerEditor from 'Projects/Project/LabelManagerEditor';
import Chip from 'shared/components/Chip'; import Chip from 'shared/components/Chip';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import FilterStatus from './FilterStatus'; import ControlStatus from './ControlStatus';
import FilterMeta from './FilterMeta'; import ControlFilter from './ControlFilter';
import SortPopup from './SortPopup'; import ControlSort from './ControlSort';
const FilterChip = styled(Chip)` const FilterChip = styled(Chip)`
margin-right: 4px; margin-right: 4px;
@ -60,19 +60,20 @@ const FilterChip = styled(Chip)`
type MetaFilterCloseFn = (meta: TaskMeta, key: string) => void; type MetaFilterCloseFn = (meta: TaskMeta, key: string) => void;
const renderTaskSortingLabel = (sorting: TaskSorting) => { const renderTaskSortingLabel = (sorting: TaskSorting) => {
if (sorting.type === TaskSortingType.TASK_TITLE) { switch (sorting.type) {
return 'Sort: Card title'; case TaskSortingType.TASK_TITLE:
return 'Sort: Task Title';
case TaskSortingType.MEMBERS:
return 'Sort: Members';
case TaskSortingType.DUE_DATE:
return 'Sort: Due Date';
case TaskSortingType.LABELS:
return 'Sort: Labels';
case TaskSortingType.COMPLETE:
return 'Sort: Complete';
default:
return 'Sort';
} }
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 renderMetaFilters = (filters: TaskMetaFilters, onClose: MetaFilterCloseFn) => {
@ -136,16 +137,16 @@ const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 15px; font-size: 15px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
&:not(:last-of-type) { &:not(:last-of-type) {
margin-right: 16px; margin-right: 16px;
} }
&:hover { &:hover {
color: ${props => props.theme.colors.text.secondary}; color: ${(props) => props.theme.colors.text.secondary};
} }
${props => ${(props) =>
props.disabled && props.disabled &&
css` css`
opacity: 0.5; opacity: 0.5;
@ -198,6 +199,7 @@ type ProjectBoardProps = {
}; };
export const BoardLoading = () => { export const BoardLoading = () => {
const { user } = useCurrentUser();
return ( return (
<> <>
<ProjectBar> <ProjectBar>
@ -215,20 +217,22 @@ export const BoardLoading = () => {
<ProjectActionText>Filter</ProjectActionText> <ProjectActionText>Filter</ProjectActionText>
</ProjectAction> </ProjectAction>
</ProjectActions> </ProjectActions>
<ProjectActions> {user && (
<ProjectAction> <ProjectActions>
<Tags width={13} height={13} /> <ProjectAction>
<ProjectActionText>Labels</ProjectActionText> <Tags width={13} height={13} />
</ProjectAction> <ProjectActionText>Labels</ProjectActionText>
<ProjectAction disabled> </ProjectAction>
<ToggleOn width={13} height={13} /> <ProjectAction disabled>
<ProjectActionText>Fields</ProjectActionText> <ToggleOn width={13} height={13} />
</ProjectAction> <ProjectActionText>Fields</ProjectActionText>
<ProjectAction disabled> </ProjectAction>
<Bolt width={13} height={13} /> <ProjectAction disabled>
<ProjectActionText>Rules</ProjectActionText> <Bolt width={13} height={13} />
</ProjectAction> <ProjectActionText>Rules</ProjectActionText>
</ProjectActions> </ProjectAction>
</ProjectActions>
)}
</ProjectBar> </ProjectBar>
<EmptyBoard /> <EmptyBoard />
</> </>
@ -277,8 +281,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter( draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter(
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data?.deleteTaskGroup.taskGroup.id, (taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data?.deleteTaskGroup.taskGroup.id,
); );
@ -293,10 +297,10 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
const { taskGroups } = cache.findProject; const { taskGroups } = cache.findProject;
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data?.createTask.taskGroup.id); const idx = taskGroups.findIndex((taskGroup) => taskGroup.id === newTaskData.data?.createTask.taskGroup.id);
if (idx !== -1) { if (idx !== -1) {
if (newTaskData.data) { if (newTaskData.data) {
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask }); draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
@ -313,8 +317,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (newTaskGroupData.data) { if (newTaskGroupData.data) {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] }); draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
} }
@ -333,10 +337,10 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
const idx = cache.findProject.taskGroups.findIndex( const idx = cache.findProject.taskGroups.findIndex(
t => t.id === resp.data?.deleteTaskGroupTasks.taskGroupID, (t) => t.id === resp.data?.deleteTaskGroupTasks.taskGroupID,
); );
if (idx !== -1) { if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks = []; draftCache.findProject.taskGroups[idx].tasks = [];
@ -350,8 +354,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (resp.data) { if (resp.data) {
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup); draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
} }
@ -368,8 +372,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (newTask.data) { if (newTask.data) {
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation; const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
if (previousTaskGroupID !== task.taskGroup.id) { if (previousTaskGroupID !== task.taskGroup.id) {
@ -377,7 +381,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID); const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id); const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) { if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
const previousTask = cache.findProject.taskGroups[oldTaskGroupIdx].tasks.find(t => t.id === task.id); const previousTask = cache.findProject.taskGroups[oldTaskGroupIdx].tasks.find(
(t) => t.id === task.id,
);
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter( draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
(t: Task) => t.id !== task.id, (t: Task) => t.id !== task.id,
); );
@ -398,14 +404,14 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [deleteTask] = useDeleteTaskMutation(); const [deleteTask] = useDeleteTaskMutation();
const [toggleTaskLabel] = useToggleTaskLabelMutation({ const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => { onCompleted: (newTaskLabel) => {
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels; taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
}, },
}); });
const onCreateTask = (taskGroupID: string, name: string) => { const onCreateTask = (taskGroupID: string, name: string) => {
if (data) { if (data) {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID); const taskGroup = data.findProject.taskGroups.find((t) => t.id === taskGroupID);
if (taskGroup) { if (taskGroup) {
let position = 65535; let position = 65535;
if (taskGroup.tasks.length !== 0) { if (taskGroup.tasks.length !== 0) {
@ -423,9 +429,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
createTask: { createTask: {
__typename: 'Task', __typename: 'Task',
id: `${Math.round(Math.random() * -1000000)}`, id: `${Math.round(Math.random() * -1000000)}`,
shortId: '',
name, name,
watched: false,
complete: false, complete: false,
completedAt: null, completedAt: null,
hasTime: false,
taskGroup: { taskGroup: {
__typename: 'TaskGroup', __typename: 'TaskGroup',
id: taskGroup.id, id: taskGroup.id,
@ -437,7 +446,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
checklist: null, checklist: null,
}, },
position, position,
dueDate: null, dueDate: { at: null },
description: null, description: null,
labels: [], labels: [],
assigned: [], assigned: [],
@ -459,9 +468,6 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
} }
}; };
if (loading) {
return <BoardLoading />;
}
const getTaskStatusFilterLabel = (filter: TaskStatusFilter) => { const getTaskStatusFilterLabel = (filter: TaskStatusFilter) => {
if (filter.status === TaskStatus.COMPLETE) { if (filter.status === TaskStatus.COMPLETE) {
return 'Complete'; return 'Complete';
@ -471,12 +477,13 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
} }
return 'All Tasks'; return 'All Tasks';
}; };
if (data && user) {
if (data) {
labelsRef.current = data.findProject.labels; labelsRef.current = data.findProject.labels;
membersRef.current = data.findProject.members; membersRef.current = data.findProject.members;
const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => { const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID); const taskGroup = data.findProject.taskGroups.find((t) => t.id === taskGroupID);
const currentTask = taskGroup ? taskGroup.tasks.find(t => t.id === taskID) : null; const currentTask = taskGroup ? taskGroup.tasks.find((t) => t.id === taskID) : null;
if (currentTask) { if (currentTask) {
setQuickCardEditor({ setQuickCardEditor({
target: $target, target: $target,
@ -488,9 +495,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
}; };
let currentQuickTask = null; let currentQuickTask = null;
if (quickCardEditor.taskID && quickCardEditor.taskGroupID) { if (quickCardEditor.taskID && quickCardEditor.taskGroupID) {
const targetGroup = data.findProject.taskGroups.find(t => t.id === quickCardEditor.taskGroupID); const targetGroup = data.findProject.taskGroups.find((t) => t.id === quickCardEditor.taskGroupID);
if (targetGroup) { if (targetGroup) {
currentQuickTask = targetGroup.tasks.find(t => t.id === quickCardEditor.taskID); currentQuickTask = targetGroup.tasks.find((t) => t.id === quickCardEditor.taskID);
} }
} }
return ( return (
@ -498,13 +505,13 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<ProjectBar> <ProjectBar>
<ProjectActions> <ProjectActions>
<ProjectAction <ProjectAction
onClick={target => { onClick={(target) => {
showPopup( showPopup(
target, target,
<Popup tab={0} title={null}> <Popup tab={0} title={null}>
<FilterStatus <ControlStatus
filter={taskStatusFilter} filter={taskStatusFilter}
onChangeTaskStatusFilter={filter => { onChangeTaskStatusFilter={(filter) => {
setTaskStatusFilter(filter); setTaskStatusFilter(filter);
hidePopup(); hidePopup();
}} }}
@ -518,13 +525,13 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<ProjectActionText>{getTaskStatusFilterLabel(taskStatusFilter)}</ProjectActionText> <ProjectActionText>{getTaskStatusFilterLabel(taskStatusFilter)}</ProjectActionText>
</ProjectAction> </ProjectAction>
<ProjectAction <ProjectAction
onClick={target => { onClick={(target) => {
showPopup( showPopup(
target, target,
<Popup tab={0} title={null}> <Popup tab={0} title={null}>
<SortPopup <ControlSort
sorting={taskSorting} sorting={taskSorting}
onChangeTaskSorting={sorting => { onChangeTaskSorting={(sorting) => {
setTaskSorting(sorting); setTaskSorting(sorting);
}} }}
/> />
@ -537,16 +544,16 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<ProjectActionText>{renderTaskSortingLabel(taskSorting)}</ProjectActionText> <ProjectActionText>{renderTaskSortingLabel(taskSorting)}</ProjectActionText>
</ProjectAction> </ProjectAction>
<ProjectAction <ProjectAction
onClick={target => { onClick={(target) => {
showPopup( showPopup(
target, target,
<FilterMeta <ControlFilter
filters={taskMetaFilters} filters={taskMetaFilters}
onChangeTaskMetaFilter={filter => { onChangeTaskMetaFilter={(filter) => {
setTaskMetaFilters(filter); setTaskMetaFilters(filter);
}} }}
userID={user?.id} userID={user ?? ''}
labels={labelsRef} projectID={projectID}
members={membersRef} members={membersRef}
/>, />,
{ width: 200 }, { width: 200 },
@ -558,11 +565,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
</ProjectAction> </ProjectAction>
{renderMetaFilters(taskMetaFilters, (meta, id) => { {renderMetaFilters(taskMetaFilters, (meta, id) => {
setTaskMetaFilters( setTaskMetaFilters(
produce(taskMetaFilters, draftFilters => { produce(taskMetaFilters, (draftFilters) => {
if (meta === TaskMeta.MEMBER) { if (meta === TaskMeta.MEMBER) {
draftFilters.members = draftFilters.members.filter(m => m.id !== id); draftFilters.members = draftFilters.members.filter((m) => m.id !== id);
} else if (meta === TaskMeta.LABEL) { } else if (meta === TaskMeta.LABEL) {
draftFilters.labels = draftFilters.labels.filter(m => m.id !== id); draftFilters.labels = draftFilters.labels.filter((m) => m.id !== id);
} else if (meta === TaskMeta.TITLE) { } else if (meta === TaskMeta.TITLE) {
draftFilters.taskName = null; draftFilters.taskName = null;
} else if (meta === TaskMeta.DUE_DATE) { } else if (meta === TaskMeta.DUE_DATE) {
@ -572,36 +579,34 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
); );
})} })}
</ProjectActions> </ProjectActions>
<ProjectActions> {user && (
<ProjectAction <ProjectActions>
onClick={$labelsRef => { <ProjectAction
showPopup( onClick={($labelsRef) => {
$labelsRef, showPopup(
<LabelManagerEditor $labelsRef,
taskLabels={null} <LabelManagerEditor taskLabels={null} labelColors={data.labelColors} projectID={projectID ?? ''} />,
labelColors={data.labelColors} );
labels={labelsRef} }}
projectID={projectID ?? ''} >
/>, <Tags width={13} height={13} />
); <ProjectActionText>Labels</ProjectActionText>
}} </ProjectAction>
> <ProjectAction disabled>
<Tags width={13} height={13} /> <ToggleOn width={13} height={13} />
<ProjectActionText>Labels</ProjectActionText> <ProjectActionText>Fields</ProjectActionText>
</ProjectAction> </ProjectAction>
<ProjectAction disabled> <ProjectAction disabled>
<ToggleOn width={13} height={13} /> <Bolt width={13} height={13} />
<ProjectActionText>Fields</ProjectActionText> <ProjectActionText>Rules</ProjectActionText>
</ProjectAction> </ProjectAction>
<ProjectAction disabled> </ProjectActions>
<Bolt width={13} height={13} /> )}
<ProjectActionText>Rules</ProjectActionText>
</ProjectAction>
</ProjectActions>
</ProjectBar> </ProjectBar>
<SimpleLists <SimpleLists
onTaskClick={task => { isPublic={user === null}
history.push(`${match.url}/c/${task.id}`); onTaskClick={(task) => {
history.push(`${match.url}/c/${task.shortId}`);
}} }}
onCardLabelClick={onCardLabelClick ?? NOOP} onCardLabelClick={onCardLabelClick ?? NOOP}
cardLabelVariant={cardLabelVariant ?? 'large'} cardLabelVariant={cardLabelVariant ?? 'large'}
@ -633,7 +638,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
}, },
}); });
}} }}
onTaskGroupDrop={droppedTaskGroup => { onTaskGroupDrop={(droppedTaskGroup) => {
updateTaskGroupLocation({ updateTaskGroupLocation({
variables: { taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position }, variables: { taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position },
optimisticResponse: { optimisticResponse: {
@ -653,7 +658,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onCreateTask={onCreateTask} onCreateTask={onCreateTask}
onCreateTaskGroup={onCreateList} onCreateTaskGroup={onCreateList}
onCardMemberClick={($targetRef, _taskID, memberID) => { onCardMemberClick={($targetRef, _taskID, memberID) => {
const member = data.findProject.members.find(m => m.id === memberID); const member = data.findProject.members.find((m) => m.id === memberID);
if (member) { if (member) {
showPopup( showPopup(
$targetRef, $targetRef,
@ -680,8 +685,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
deleteTaskGroupTasks({ variables: { taskGroupID } }); deleteTaskGroupTasks({ variables: { taskGroupID } });
hidePopup(); hidePopup();
}} }}
onSortTaskGroup={taskSort => { onSortTaskGroup={(taskSort) => {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID); const taskGroup = data.findProject.taskGroups.find((t) => t.id === taskGroupID);
if (taskGroup) { if (taskGroup) {
const tasks: Array<{ taskID: string; position: number }> = taskGroup.tasks const tasks: Array<{ taskID: string; position: number }> = taskGroup.tasks
.sort((a, b) => sortTasks(a, b, taskSort)) .sort((a, b) => sortTasks(a, b, taskSort))
@ -693,8 +698,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
hidePopup(); hidePopup();
} }
}} }}
onDuplicateTaskGroup={newName => { onDuplicateTaskGroup={(newName) => {
const idx = data.findProject.taskGroups.findIndex(t => t.id === taskGroupID); const idx = data.findProject.taskGroups.findIndex((t) => t.id === taskGroupID);
if (idx !== -1) { if (idx !== -1) {
const taskGroups = data.findProject.taskGroups.sort((a, b) => a.position - b.position); const taskGroups = data.findProject.taskGroups.sort((a, b) => a.position - b.position);
const prevPos = taskGroups[idx].position; const prevPos = taskGroups[idx].position;
@ -707,7 +712,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
hidePopup(); hidePopup();
} }
}} }}
onArchiveTaskGroup={tgID => { onArchiveTaskGroup={(tgID) => {
deleteTaskGroup({ variables: { taskGroupID: tgID } }); deleteTaskGroup({ variables: { taskGroupID: tgID } });
hidePopup(); hidePopup();
}} }}
@ -741,7 +746,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
); );
}} }}
onCardMemberClick={($targetRef, _taskID, memberID) => { onCardMemberClick={($targetRef, _taskID, memberID) => {
const member = data.findProject.members.find(m => m.id === memberID); const member = data.findProject.members.find((m) => m.id === memberID);
if (member) { if (member) {
showPopup( showPopup(
$targetRef, $targetRef,
@ -760,11 +765,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
showPopup( showPopup(
$targetRef, $targetRef,
<LabelManagerEditor <LabelManagerEditor
onLabelToggle={labelID => { onLabelToggle={(labelID) => {
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } }); toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
}} }}
taskID={task.id}
labelColors={data.labelColors} labelColors={data.labelColors}
labels={labelsRef}
taskLabels={taskLabelsRef} taskLabels={taskLabelsRef}
projectID={projectID ?? ''} projectID={projectID ?? ''}
/>, />,
@ -773,15 +778,15 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onArchiveCard={(_listId: string, cardId: string) => { onArchiveCard={(_listId: string, cardId: string) => {
return deleteTask({ return deleteTask({
variables: { taskID: cardId }, variables: { taskID: cardId },
update: client => { update: (client) => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
draftCache.findProject.taskGroups = cache.findProject.taskGroups.map(taskGroup => ({ draftCache.findProject.taskGroups = cache.findProject.taskGroups.map((taskGroup) => ({
...taskGroup, ...taskGroup,
tasks: taskGroup.tasks.filter(t => t.id !== cardId), tasks: taskGroup.tasks.filter((t) => t.id !== cardId),
})); }));
}), }),
{ projectID }, { projectID },
@ -795,20 +800,38 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<Popup title="Change Due Date" tab={0} onClose={() => hidePopup()}> <Popup title="Change Due Date" tab={0} onClose={() => hidePopup()}>
<DueDateManager <DueDateManager
task={task} task={task}
onRemoveDueDate={t => { onRemoveDueDate={(t) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
hidePopup(); hidePopup();
updateTaskDueDate({
variables: {
taskID: t.id,
dueDate: null,
hasTime: false,
deleteNotifications: [],
updateNotifications: [],
createNotifications: [],
},
});
}} }}
onDueDateChange={(t, newDueDate) => { onDueDateChange={(t, newDueDate, hasTime) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup(); hidePopup();
updateTaskDueDate({
variables: {
taskID: t.id,
dueDate: newDueDate,
hasTime,
deleteNotifications: [],
updateNotifications: [],
createNotifications: [],
},
});
}} }}
onCancel={NOOP} onCancel={NOOP}
/> />
</Popup>, </Popup>,
); );
}} }}
onToggleComplete={task => { onToggleComplete={(task) => {
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } }); setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
}} }}
target={quickCardEditor.target} target={quickCardEditor.target}
@ -818,7 +841,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
); );
} }
return <span>Error</span>; return <BoardLoading />;
}; };
export default ProjectBoard; export default ProjectBoard;

View File

@ -1,15 +1,18 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Modal from 'shared/components/Modal'; import Modal from 'shared/components/Modal';
import TaskDetails from 'shared/components/TaskDetails'; import TaskDetails from 'shared/components/TaskDetails';
import TaskDetailsLoading from 'shared/components/TaskDetails/Loading';
import { Popup, usePopup } from 'shared/components/PopupMenu'; import { Popup, usePopup } from 'shared/components/PopupMenu';
import MemberManager from 'shared/components/MemberManager'; import MemberManager from 'shared/components/MemberManager';
import { useRouteMatch, useHistory } from 'react-router'; import { useRouteMatch, useHistory, useParams } from 'react-router';
import { import {
useDeleteTaskChecklistMutation, useDeleteTaskChecklistMutation,
useToggleTaskWatchMutation,
useUpdateTaskChecklistNameMutation, useUpdateTaskChecklistNameMutation,
useUpdateTaskChecklistItemLocationMutation, useUpdateTaskChecklistItemLocationMutation,
useCreateTaskChecklistMutation, useCreateTaskChecklistMutation,
useFindTaskQuery, useFindTaskQuery,
DueDateNotificationDuration,
useUpdateTaskDueDateMutation, useUpdateTaskDueDateMutation,
useSetTaskCompleteMutation, useSetTaskCompleteMutation,
useAssignTaskMutation, useAssignTaskMutation,
@ -21,6 +24,9 @@ import {
useCreateTaskChecklistItemMutation, useCreateTaskChecklistItemMutation,
FindTaskDocument, FindTaskDocument,
FindTaskQuery, FindTaskQuery,
useCreateTaskCommentMutation,
useDeleteTaskCommentMutation,
useUpdateTaskCommentMutation,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import MiniProfile from 'shared/components/MiniProfile'; import MiniProfile from 'shared/components/MiniProfile';
@ -32,6 +38,74 @@ import Input from 'shared/components/Input';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import NOOP from 'shared/utils/noop'; 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 calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
const total = checklists.reduce((prev: any, next: any) => { const total = checklists.reduce((prev: any, next: any) => {
@ -94,10 +168,8 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
defaultValue="Checklist" defaultValue="Checklist"
width="100%" width="100%"
label="Name" label="Name"
id="name"
name="name"
variant="alternate" variant="alternate"
ref={register({ required: 'Checklist name is required' })} {...register('name', { required: 'Checklist name is required' })}
/> />
<CreateChecklistButton type="submit">Create</CreateChecklistButton> <CreateChecklistButton type="submit">Create</CreateChecklistButton>
</CreateChecklistForm> </CreateChecklistForm>
@ -105,7 +177,6 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
}; };
type DetailsProps = { type DetailsProps = {
taskID: string;
projectURL: string; projectURL: string;
onTaskNameChange: (task: Task, newName: string) => void; onTaskNameChange: (task: Task, newName: string) => void;
onTaskDescriptionChange: (task: Task, newDescription: string) => void; onTaskDescriptionChange: (task: Task, newDescription: string) => void;
@ -119,7 +190,6 @@ const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 };
const Details: React.FC<DetailsProps> = ({ const Details: React.FC<DetailsProps> = ({
projectURL, projectURL,
taskID,
onTaskNameChange, onTaskNameChange,
onTaskDescriptionChange, onTaskDescriptionChange,
onDeleteTask, onDeleteTask,
@ -128,31 +198,67 @@ const Details: React.FC<DetailsProps> = ({
refreshCache, refreshCache,
}) => { }) => {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { taskID } = useParams<{ taskID: string }>();
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const history = useHistory(); const history = useHistory();
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 [toggleTaskWatch] = useToggleTaskWatchMutation();
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 [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({ const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
client, client,
FindTaskDocument, FindTaskDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (response.data) { if (response.data) {
const { prevChecklistID, taskChecklistID, checklistItem } = response.data.updateTaskChecklistItemLocation; const { prevChecklistID, taskChecklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
if (taskChecklistID !== prevChecklistID) { if (taskChecklistID !== prevChecklistID) {
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID); const oldIdx = cache.findTask.checklists.findIndex((c) => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex(c => c.id === taskChecklistID); const newIdx = cache.findTask.checklists.findIndex((c) => c.id === taskChecklistID);
if (oldIdx > -1 && newIdx > -1) { if (oldIdx > -1 && newIdx > -1) {
const item = cache.findTask.checklists[oldIdx].items.find(i => i.id === checklistItem.id); const item = cache.findTask.checklists[oldIdx].items.find((i) => i.id === checklistItem.id);
if (item) { if (item) {
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter( 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({ draftCache.findTask.checklists[newIdx].items.push({
...item, ...item,
position: checklistItem.position, position: checklistItem.position,
taskChecklistID: taskChecklistID, taskChecklistID,
}); });
} }
} }
@ -164,12 +270,12 @@ const Details: React.FC<DetailsProps> = ({
}, },
}); });
const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({ const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({
update: client => { update: (client) => {
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
client, client,
FindTaskDocument, FindTaskDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = { draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge', __typename: 'ChecklistBadge',
@ -186,11 +292,11 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
client, client,
FindTaskDocument, FindTaskDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
const { checklists } = cache.findTask; const { checklists } = cache.findTask;
draftCache.findTask.checklists = checklists.filter( draftCache.findTask.checklists = checklists.filter(
c => c.id !== deleteData.data?.deleteTaskChecklist.taskChecklist.id, (c) => c.id !== deleteData.data?.deleteTaskChecklist.taskChecklist.id,
); );
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = { draftCache.findTask.badges.checklist = {
@ -212,8 +318,8 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
client, client,
FindTaskDocument, FindTaskDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (createData.data) { if (createData.data) {
const item = createData.data.createTaskChecklist; const item = createData.data.createTaskChecklist;
draftCache.findTask.checklists.push({ ...item }); draftCache.findTask.checklists.push({ ...item });
@ -229,14 +335,14 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
client, client,
FindTaskDocument, FindTaskDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (deleteData.data) { if (deleteData.data) {
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem; 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) { if (targetIdx > -1) {
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter( 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); const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
@ -256,12 +362,12 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
client, client,
FindTaskDocument, FindTaskDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (newTaskItem.data) { if (newTaskItem.data) {
const item = newTaskItem.data.createTaskChecklistItem; const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask; 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) { if (idx !== -1) {
draftCache.findTask.checklists[idx].items.push({ ...item }); draftCache.findTask.checklists[idx].items.push({ ...item });
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
@ -277,7 +383,11 @@ const Details: React.FC<DetailsProps> = ({
); );
}, },
}); });
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID }, fetchPolicy: 'cache-and-network' }); const { loading, data, refetch } = useFindTaskQuery({
variables: { taskID },
pollInterval: polling.TASK_DETAILS,
fetchPolicy: 'cache-and-network',
});
const [setTaskComplete] = useSetTaskCompleteMutation(); const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({ const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
onCompleted: () => { onCompleted: () => {
@ -297,25 +407,59 @@ const Details: React.FC<DetailsProps> = ({
refreshCache(); refreshCache();
}, },
}); });
if (loading) { const [updateTaskComment] = useUpdateTaskCommentMutation();
return null; const [editableComment, setEditableComment] = useState<null | string>(null);
} const isLoading = true;
if (!data) {
return null;
}
return ( return (
<> <>
<Modal <Modal
width={1070} width={1070}
onClose={() => { onClose={() => {
history.push(projectURL); history.push(projectURL);
hidePopup();
}} }}
renderContent={() => { renderContent={() => {
return ( return data ? (
<TaskDetails <TaskDetails
me={data.me.user} 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} task={data.findTask}
onChecklistDrop={checklist => { onToggleTaskWatch={(task, watched) => {
toggleTaskWatch({
variables: { taskID: task.id },
optimisticResponse: {
__typename: 'Mutation',
toggleTaskWatch: {
id: task.id,
__typename: 'Task',
watched,
},
},
});
}}
onCreateComment={(task, message) => {
createTaskComment({ variables: { taskID: task.id, message } });
}}
onChecklistDrop={(checklist) => {
updateTaskChecklistLocation({ updateTaskChecklistLocation({
variables: { taskChecklistID: checklist.id, position: checklist.position }, variables: { taskChecklistID: checklist.id, position: checklist.position },
@ -357,7 +501,7 @@ const Details: React.FC<DetailsProps> = ({
}} }}
onTaskNameChange={onTaskNameChange} onTaskNameChange={onTaskNameChange}
onTaskDescriptionChange={onTaskDescriptionChange} onTaskDescriptionChange={onTaskDescriptionChange}
onToggleTaskComplete={task => { onToggleTaskComplete={(task) => {
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } }); setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
}} }}
onDeleteTask={onDeleteTask} onDeleteTask={onDeleteTask}
@ -402,7 +546,7 @@ const Details: React.FC<DetailsProps> = ({
createTaskChecklistItem({ variables: { taskChecklistID, name, position } }); createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
}} }}
onMemberProfile={($targetRef, memberID) => { 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) { if (member) {
showPopup( showPopup(
$targetRef, $targetRef,
@ -412,7 +556,8 @@ const Details: React.FC<DetailsProps> = ({
bio="None" bio="None"
onRemoveFromTask={() => { onRemoveFromTask={() => {
if (user) { if (user) {
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } }); unassignTask({ variables: { taskID: data.findTask.id, userID: member.id ?? '' } });
hidePopup();
} }
}} }}
/> />
@ -452,7 +597,7 @@ const Details: React.FC<DetailsProps> = ({
}} }}
> >
<CreateChecklistPopup <CreateChecklistPopup
onCreateChecklist={checklistData => { onCreateChecklist={(checklistData) => {
let position = 65535; let position = 65535;
if (data.findTask.checklists) { if (data.findTask.checklists) {
const [lastChecklist] = data.findTask.checklists.slice(-1); const [lastChecklist] = data.findTask.checklists.slice(-1);
@ -502,12 +647,79 @@ const Details: React.FC<DetailsProps> = ({
> >
<DueDateManager <DueDateManager
task={task} task={task}
onRemoveDueDate={t => { onRemoveDueDate={(t) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } }); updateTaskDueDate({
variables: {
taskID: t.id,
dueDate: null,
hasTime: false,
deleteNotifications: t.dueDate.notifications
? t.dueDate.notifications.map((n) => ({ id: n.id }))
: [],
updateNotifications: [],
createNotifications: [],
},
});
hidePopup(); hidePopup();
}} }}
onDueDateChange={(t, newDueDate) => { onDueDateChange={(t, newDueDate, hasTime, notifications) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } }); const updatedNotifications = notifications.current
.filter((c) => c.externalId !== null)
.map((c) => {
let duration = DueDateNotificationDuration.Minute;
switch (c.duration.value) {
case 'hour':
duration = DueDateNotificationDuration.Hour;
break;
case 'day':
duration = DueDateNotificationDuration.Day;
break;
case 'week':
duration = DueDateNotificationDuration.Week;
break;
default:
break;
}
return {
id: c.externalId ?? '',
period: c.period,
duration,
};
});
const newNotifications = notifications.current
.filter((c) => c.externalId === null)
.map((c) => {
let duration = DueDateNotificationDuration.Minute;
switch (c.duration.value) {
case 'hour':
duration = DueDateNotificationDuration.Hour;
break;
case 'day':
duration = DueDateNotificationDuration.Day;
break;
case 'week':
duration = DueDateNotificationDuration.Week;
break;
default:
break;
}
return {
taskID: task.id,
period: c.period,
duration,
};
});
// const updatedNotifications = notifications.filter(c => c.externalId === null);
updateTaskDueDate({
variables: {
taskID: t.id,
dueDate: newDueDate,
hasTime,
createNotifications: newNotifications,
updateNotifications: updatedNotifications,
deleteNotifications: notifications.removed.map((n) => ({ id: n })),
},
});
hidePopup(); hidePopup();
}} }}
onCancel={NOOP} onCancel={NOOP}
@ -517,6 +729,8 @@ const Details: React.FC<DetailsProps> = ({
); );
}} }}
/> />
) : (
<TaskDetailsLoading />
); );
}} }}
/> />

View File

@ -8,12 +8,14 @@ import {
FindProjectDocument, FindProjectDocument,
useCreateProjectLabelMutation, useCreateProjectLabelMutation,
FindProjectQuery, FindProjectQuery,
useToggleTaskLabelMutation,
useLabelsQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import LabelManager from 'shared/components/PopupMenu/LabelManager'; import LabelManager from 'shared/components/PopupMenu/LabelManager';
import LabelEditor from 'shared/components/PopupMenu/LabelEditor'; import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
type LabelManagerEditorProps = { type LabelManagerEditorProps = {
labels: React.RefObject<Array<ProjectLabel>>; taskID?: string;
taskLabels: null | React.RefObject<Array<TaskLabel>>; taskLabels: null | React.RefObject<Array<TaskLabel>>;
projectID: string; projectID: string;
labelColors: Array<LabelColor>; labelColors: Array<LabelColor>;
@ -21,7 +23,7 @@ type LabelManagerEditorProps = {
}; };
const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
labels: labelsRef, taskID,
projectID, projectID,
labelColors, labelColors,
onLabelToggle, onLabelToggle,
@ -29,13 +31,19 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
}) => { }) => {
const [currentLabel, setCurrentLabel] = useState(''); const [currentLabel, setCurrentLabel] = useState('');
const { setTab, hidePopup } = usePopup(); const { setTab, hidePopup } = usePopup();
const [toggleTaskLabel] = useToggleTaskLabelMutation();
const [createProjectLabel] = useCreateProjectLabelMutation({ const [createProjectLabel] = useCreateProjectLabelMutation({
onCompleted: (data) => {
if (taskID) {
toggleTaskLabel({ variables: { taskID, projectLabelID: data.createProjectLabel.id } });
}
},
update: (client, newLabelData) => { update: (client, newLabelData) => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (newLabelData.data) { if (newLabelData.data) {
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel }); draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel });
} }
@ -52,38 +60,39 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
draftCache.findProject.labels = cache.findProject.labels.filter( draftCache.findProject.labels = cache.findProject.labels.filter(
label => label.id !== newLabelData.data?.deleteProjectLabel.id, (label) => label.id !== newLabelData.data?.deleteProjectLabel.id,
); );
}), }),
{ projectID }, { projectID },
); );
}, },
}); });
const labels = labelsRef.current ? labelsRef.current : []; const { data } = useLabelsQuery({ variables: { projectID } });
const labels = data ? data.findProject.labels : [];
const taskLabels = taskLabelsRef && taskLabelsRef.current ? taskLabelsRef.current : []; const taskLabels = taskLabelsRef && taskLabelsRef.current ? taskLabelsRef.current : [];
const [currentTaskLabels, setCurrentTaskLabels] = useState(taskLabels); const [currentTaskLabels, setCurrentTaskLabels] = useState(taskLabels);
return ( return (
<> <>
<Popup title="Labels" tab={0} onClose={() => hidePopup()}> <Popup title="Labels" tab={0} onClose={() => hidePopup()}>
<LabelManager <LabelManager
labels={labels} labels={data ? data.findProject.labels : []}
taskLabels={currentTaskLabels} taskLabels={currentTaskLabels}
onLabelCreate={() => { onLabelCreate={() => {
setTab(2); setTab(2);
}} }}
onLabelEdit={labelId => { onLabelEdit={(labelId) => {
setCurrentLabel(labelId); setCurrentLabel(labelId);
setTab(1); setTab(1);
}} }}
onLabelToggle={labelId => { onLabelToggle={(labelId) => {
if (onLabelToggle) { if (onLabelToggle) {
if (currentTaskLabels.find(t => t.projectLabel.id === labelId)) { if (currentTaskLabels.find((t) => t.projectLabel.id === labelId)) {
setCurrentTaskLabels(currentTaskLabels.filter(t => t.projectLabel.id !== labelId)); setCurrentTaskLabels(currentTaskLabels.filter((t) => t.projectLabel.id !== labelId));
} else { } else if (data) {
const newProjectLabel = labels.find(l => l.id === labelId); const newProjectLabel = data.findProject.labels.find((l) => l.id === labelId);
if (newProjectLabel) { if (newProjectLabel) {
setCurrentTaskLabels([ setCurrentTaskLabels([
...currentTaskLabels, ...currentTaskLabels,
@ -103,14 +112,14 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
<Popup onClose={() => hidePopup()} title="Edit label" tab={1}> <Popup onClose={() => hidePopup()} title="Edit label" tab={1}>
<LabelEditor <LabelEditor
labelColors={labelColors} labelColors={labelColors}
label={labels.find(label => label.id === currentLabel) ?? null} label={labels.find((label) => label.id === currentLabel) ?? null}
onLabelEdit={(projectLabelID, name, color) => { onLabelEdit={(projectLabelID, name, color) => {
if (projectLabelID) { if (projectLabelID) {
updateProjectLabel({ variables: { projectLabelID, labelColorID: color.id, name: name ?? '' } }); updateProjectLabel({ variables: { projectLabelID, labelColorID: color.id, name: name ?? '' } });
} }
setTab(0); setTab(0);
}} }}
onLabelDelete={labelID => { onLabelDelete={(labelID) => {
deleteProjectLabel({ variables: { projectLabelID: labelID } }); deleteProjectLabel({ variables: { projectLabelID: labelID } });
setTab(0); setTab(0);
}} }}

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 // LOC830
import React, { useState, useRef, useEffect, useContext } from 'react'; import React, { useRef, useEffect } from 'react';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar'; import GlobalTopNavbar from 'App/TopNavbar';
import styled from 'styled-components/macro'; import ProjectPopup from 'App/TopNavbar/ProjectPopup';
import AsyncSelect from 'react-select/async'; import { usePopup } from 'shared/components/PopupMenu';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { import {
useParams, useParams,
Route, Route,
@ -23,437 +22,85 @@ import {
useFindProjectQuery, useFindProjectQuery,
useDeleteInvitedProjectMemberMutation, useDeleteInvitedProjectMemberMutation,
useUpdateTaskNameMutation, useUpdateTaskNameMutation,
useCreateTaskMutation,
useDeleteTaskMutation, useDeleteTaskMutation,
useUpdateTaskLocationMutation,
useUpdateTaskGroupLocationMutation,
useCreateTaskGroupMutation,
useUpdateTaskDescriptionMutation, useUpdateTaskDescriptionMutation,
FindProjectDocument, FindProjectDocument,
FindProjectQuery, FindProjectQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import produce from 'immer'; import produce from 'immer';
import UserContext, { useCurrentUser } from 'App/context';
import Input from 'shared/components/Input';
import Member from 'shared/components/Member';
import EmptyBoard from 'shared/components/EmptyBoard';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import { Lock, Cross } from 'shared/icons'; import useStateWithLocalStorage from 'shared/hooks/useStateWithLocalStorage';
import Button from 'shared/components/Button'; import localStorage from 'shared/utils/localStorage';
import { useApolloClient } from '@apollo/react-hooks'; import polling from 'shared/utils/polling';
import TaskAssignee from 'shared/components/TaskAssignee';
import gql from 'graphql-tag';
import { colourStyles } from 'shared/components/Select';
import Board, { BoardLoading } from './Board'; import Board, { BoardLoading } from './Board';
import Details from './Details'; import Details from './Details';
import LabelManagerEditor from './LabelManagerEditor'; import LabelManagerEditor from './LabelManagerEditor';
import { mixin } from '../../shared/utils/styles'; import UserManagementPopup from './UserManagementPopup';
const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant';
const RFC2822_EMAIL = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => {
const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || '');
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: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
}
border-radius: 6px;
`;
const MemberList = styled.div`
margin: 8px 0;
`;
type InviteUserData = {
email?: string;
suerID?: string;
};
type UserManagementPopupProps = {
projectID: string;
users: Array<User>;
projectMembers: Array<TaskUser>;
onInviteProjectMembers: (data: Array<InviteUserData>) => void;
};
const VisibiltyPrivateIcon = styled(Lock)`
padding-right: 4px;
`;
const VisibiltyButtonText = styled.span`
color: rgba(${props => props.theme.colors.text.primary});
`;
const ShareActions = styled.div`
border-top: 1px solid #414561;
margin-top: 8px;
padding-top: 8px;
display: flex;
align-items: center;
justify-content: space-between;
`;
const VisibiltyButton = styled.button`
cursor: pointer;
margin: 2px 4px;
padding: 2px 4px;
align-items: center;
justify-content: center;
border-bottom: 1px solid transparent;
&:hover ${VisibiltyButtonText} {
color: rgba(${props => props.theme.colors.text.secondary});
}
&:hover ${VisibiltyPrivateIcon} {
fill: rgba(${props => props.theme.colors.text.secondary});
stroke: rgba(${props => props.theme.colors.text.secondary});
}
&:hover {
border-bottom: 1px solid rgba(${props => props.theme.colors.primary});
}
`;
type MemberFilterOptions = {
projectID?: null | string;
teamID?: null | string;
organization?: boolean;
};
const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => {
console.log(input.trim().length < 3);
if (input && input.trim().length < 3) {
return [];
}
const res = await client.query({
query: gql`
query {
searchMembers(input: {searchFilter:"${input}", projectID:"${projectID}"}) {
id
similarity
status
user {
id
fullName
email
profileIcon {
url
initials
bgColor
}
}
}
}
`,
});
let results: any = [];
const emails: Array<string> = [];
console.log(res.data && res.data.searchMembers);
if (res.data && res.data.searchMembers) {
results = [
...res.data.searchMembers.map((m: any) => {
if (m.status === 'INVITED') {
console.log(`${m.id} is added`);
return {
label: m.id,
value: {
id: m.id,
type: 2,
profileIcon: {
bgColor: '#ccc',
initials: m.id.charAt(0),
},
},
};
} else {
console.log(`${m.user.email} is added`);
emails.push(m.user.email);
return {
label: m.user.fullName,
value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon },
};
}
}),
];
console.log(results);
}
if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) {
results = [
...results,
{
label: input,
value: {
id: input,
type: 1,
profileIcon: {
bgColor: '#ccc',
initials: input.charAt(0),
},
},
},
];
}
return results;
};
type UserOptionProps = {
innerProps: any;
isDisabled: boolean;
isFocused: boolean;
label: string;
data: any;
getValue: any;
};
const OptionWrapper = styled.div<{ isFocused: boolean }>`
cursor: pointer;
padding: 4px 8px;
${props => props.isFocused && `background: rgba(${props.theme.colors.primary});`}
display: flex;
align-items: center;
`;
const OptionContent = styled.div`
display: flex;
flex-direction: column;
margin-left: 12px;
`;
const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>`
display: flex;
align-items: center;
font-size: ${p => p.fontSize}px;
color: rgba(${p => (p.quiet ? p.theme.colors.text.primary : p.theme.colors.text.primary)});
`;
const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
console.log(data);
return !isDisabled ? (
<OptionWrapper {...innerProps} isFocused={isFocused}>
<TaskAssignee
size={32}
member={{
id: '',
fullName: data.value.label,
profileIcon: data.value.profileIcon,
}}
/>
<OptionContent>
<OptionLabel fontSize={16} quiet={false}>
{label}
</OptionLabel>
{data.value.type === 2 && (
<OptionLabel fontSize={14} quiet>
Joined
</OptionLabel>
)}
</OptionContent>
</OptionWrapper>
) : null;
};
const OptionValueWrapper = styled.div`
background: rgba(${props => props.theme.colors.bg.primary});
border-radius: 4px;
margin: 2px;
padding: 3px 6px 3px 4px;
display: flex;
align-items: center;
`;
const OptionValueLabel = styled.span`
font-size: 12px;
color: rgba(${props => props.theme.colors.text.secondary});
`;
const OptionValueRemove = styled.button`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
outline: none;
padding: 0;
margin: 0;
margin-left: 4px;
`;
const OptionValue = ({ data, removeProps }: any) => {
return (
<OptionValueWrapper>
<OptionValueLabel>{data.label}</OptionValueLabel>
<OptionValueRemove {...removeProps}>
<Cross width={14} height={14} />
</OptionValueRemove>
</OptionValueWrapper>
);
};
const InviteButton = styled(Button)`
margin-top: 12px;
height: 32px;
padding: 4px 12px;
width: 100%;
justify-content: center;
`;
const InviteContainer = styled.div`
min-height: 300px;
display: flex;
flex-direction: column;
`;
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({
projectID,
users,
projectMembers,
onInviteProjectMembers,
}) => {
const client = useApolloClient();
const [invitedUsers, setInvitedUsers] = useState<Array<any> | null>(null);
return (
<Popup tab={0} title="Invite a user">
<InviteContainer>
<AsyncSelect
getOptionValue={option => option.value.id}
placeholder="Email address or username"
noOptionsMessage={() => null}
onChange={(e: any) => {
setInvitedUsers(e);
}}
isMulti
autoFocus
cacheOptions
styles={colourStyles}
defaultOption
components={{
MultiValue: OptionValue,
Option: UserOption,
IndicatorSeparator: null,
DropdownIndicator: null,
}}
loadOptions={(i, cb) => fetchMembers(client, projectID, {}, i, cb)}
/>
</InviteContainer>
<InviteButton
onClick={() => {
if (invitedUsers) {
onInviteProjectMembers(
invitedUsers.map(user => {
if (user.value.type === 0) {
return {
userID: user.value.id,
};
}
return {
email: user.value.id,
};
}),
);
}
}}
disabled={invitedUsers === null}
hoverVariant="none"
fontSize="16px"
>
Send Invite
</InviteButton>
</Popup>
);
};
type TaskRouteProps = { type TaskRouteProps = {
taskID: string; taskID: string;
}; };
interface QuickCardEditorState {
isOpen: boolean;
target: React.RefObject<HTMLElement> | null;
taskID: string | null;
taskGroupID: string | null;
}
interface ProjectParams { interface ProjectParams {
projectID: string; projectID: string;
} }
const initialQuickCardEditorState: QuickCardEditorState = {
taskID: null,
taskGroupID: null,
isOpen: false,
target: null,
};
const Project = () => { const Project = () => {
const { projectID } = useParams<ProjectParams>(); const { projectID } = useParams<ProjectParams>();
const history = useHistory(); const history = useHistory();
const match = useRouteMatch(); 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 [updateTaskDescription] = useUpdateTaskDescriptionMutation();
const taskLabelsRef = useRef<Array<TaskLabel>>([]); const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
const [updateTaskName] = useUpdateTaskNameMutation();
const { data, error } = useFindProjectQuery({
variables: { projectID },
pollInterval: polling.PROJECT,
});
const [toggleTaskLabel] = useToggleTaskLabelMutation({ const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => { onCompleted: (newTaskLabel) => {
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels; taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
}, },
}); });
const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY);
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
const [deleteTask] = useDeleteTaskMutation({ const [deleteTask] = useDeleteTaskMutation({
update: (client, resp) => update: (client, resp) =>
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (resp.data) { if (resp.data) {
const taskGroupIdx = draftCache.findProject.taskGroups.findIndex( const taskGroupIdx = draftCache.findProject.taskGroups.findIndex(
tg => tg.tasks.findIndex(t => t.id === resp.data?.deleteTask.taskID) !== -1, (tg) => tg.tasks.findIndex((t) => t.id === resp.data?.deleteTask.taskID) !== -1,
); );
if (taskGroupIdx !== -1) { if (taskGroupIdx !== -1) {
draftCache.findProject.taskGroups[taskGroupIdx].tasks = cache.findProject.taskGroups[ draftCache.findProject.taskGroups[taskGroupIdx].tasks = cache.findProject.taskGroups[
taskGroupIdx taskGroupIdx
].tasks.filter(t => t.id !== resp.data?.deleteTask.taskID); ].tasks.filter((t) => t.id !== resp.data?.deleteTask.taskID);
} }
} }
}), }),
{ projectID }, { projectID: data ? data.findProject.id : '' },
), ),
}); });
const [updateTaskName] = useUpdateTaskNameMutation();
const { loading, data, error } = useFindProjectQuery({
variables: { projectID },
});
const [updateProjectName] = useUpdateProjectNameMutation({ const [updateProjectName] = useUpdateProjectNameMutation({
update: (client, newName) => { update: (client, newName) => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
draftCache.findProject.name = newName.data?.updateProjectName.name ?? ''; draftCache.findProject.name = newName.data?.updateProjectName.name ?? '';
}), }),
{ projectID }, { projectID: data ? data.findProject.id : '' },
); );
}, },
}); });
@ -463,8 +110,8 @@ const Project = () => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (response.data) { if (response.data) {
draftCache.findProject.members = [ draftCache.findProject.members = [
...cache.findProject.members, ...cache.findProject.members,
@ -476,7 +123,7 @@ const Project = () => {
]; ];
} }
}), }),
{ projectID }, { projectID: data ? data.findProject.id : '' },
); );
}, },
}); });
@ -485,13 +132,13 @@ const Project = () => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
draftCache.findProject.invitedMembers = cache.findProject.invitedMembers.filter( draftCache.findProject.invitedMembers = cache.findProject.invitedMembers.filter(
m => m.email !== response.data?.deleteInvitedProjectMember.invitedMember.email ?? '', (m) => m.email !== response.data?.deleteInvitedProjectMember.invitedMember.email ?? '',
); );
}), }),
{ projectID }, { projectID: data ? data.findProject.id : '' },
); );
}, },
}); });
@ -500,39 +147,23 @@ const Project = () => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
draftCache.findProject.members = cache.findProject.members.filter( draftCache.findProject.members = cache.findProject.members.filter(
m => m.id !== response.data?.deleteProjectMember.member.id, (m) => m.id !== response.data?.deleteProjectMember.member.id,
); );
}), }),
{ projectID }, { projectID: data ? data.findProject.id : '' },
); );
}, },
}); });
const { user } = useCurrentUser();
const location = useLocation();
const { showPopup, hidePopup } = usePopup();
const $labelsRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<Array<ProjectLabel>>([]);
useEffect(() => { useEffect(() => {
if (data) { if (data) {
document.title = `${data.findProject.name} | Taskcafé`; document.title = `${data.findProject.name} | Taskcafé`;
} }
}, [data]); }, [data]);
if (loading) {
return (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
<BoardLoading />
</>
);
}
if (error) {
history.push('/projects');
}
if (data) { if (data) {
labelsRef.current = data.findProject.labels; labelsRef.current = data.findProject.labels;
@ -540,29 +171,29 @@ const Project = () => {
<> <>
<GlobalTopNavbar <GlobalTopNavbar
onChangeRole={(userID, roleCode) => { onChangeRole={(userID, roleCode) => {
updateProjectMemberRole({ variables: { userID, roleCode, projectID } }); updateProjectMemberRole({ variables: { userID, roleCode, projectID: data ? data.findProject.id : '' } });
}} }}
onChangeProjectOwner={uid => { onChangeProjectOwner={() => {
hidePopup(); hidePopup();
}} }}
onRemoveFromBoard={userID => { onRemoveFromBoard={(userID) => {
deleteProjectMember({ variables: { userID, projectID } }); deleteProjectMember({ variables: { userID, projectID: data ? data.findProject.id : '' } });
hidePopup(); hidePopup();
}} }}
onRemoveInvitedFromBoard={email => { onRemoveInvitedFromBoard={(email) => {
deleteInvitedProjectMember({ variables: { projectID, email } }); deleteInvitedProjectMember({ variables: { projectID: data ? data.findProject.id : '', email } });
hidePopup(); hidePopup();
}} }}
onSaveProjectName={projectName => { onSaveProjectName={(projectName) => {
updateProjectName({ variables: { projectID, name: projectName } }); updateProjectName({ variables: { projectID: data ? data.findProject.id : '', name: projectName } });
}} }}
onInviteUser={$target => { onInviteUser={($target) => {
showPopup( showPopup(
$target, $target,
<UserManagementPopup <UserManagementPopup
projectID={projectID} projectID={data ? data.findProject.id : ''}
onInviteProjectMembers={members => { onInviteProjectMembers={(members) => {
inviteProjectMembers({ variables: { projectID, members } }); inviteProjectMembers({ variables: { projectID: data ? data.findProject.id : '', members } });
hidePopup(); hidePopup();
}} }}
users={data.users} users={data.users}
@ -570,7 +201,14 @@ const Project = () => {
/>, />,
); );
}} }}
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 }]} menuType={[{ name: 'Board', link: location.pathname }]}
currentTab={0} currentTab={0}
projectMembers={data.findProject.members} projectMembers={data.findProject.members}
@ -595,54 +233,60 @@ const Project = () => {
/> />
<Route <Route
path={`${match.path}/board/c/:taskID`} path={`${match.path}/board/c/:taskID`}
render={(routeProps: RouteComponentProps<TaskRouteProps>) => ( render={() => {
<Details return (
refreshCache={NOOP} <Details
availableMembers={data.findProject.members} refreshCache={NOOP}
projectURL={`${match.url}/board`} availableMembers={data.findProject.members}
taskID={routeProps.match.params.taskID} projectURL={`${match.url}/board`}
onTaskNameChange={(updatedTask, newName) => { onTaskNameChange={(updatedTask, newName) => {
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } }); updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
}} }}
onTaskDescriptionChange={(updatedTask, newDescription) => { onTaskDescriptionChange={(updatedTask, newDescription) => {
updateTaskDescription({ updateTaskDescription({
variables: { taskID: updatedTask.id, description: newDescription }, variables: { taskID: updatedTask.id, description: newDescription },
optimisticResponse: { optimisticResponse: {
__typename: 'Mutation', __typename: 'Mutation',
updateTaskDescription: { updateTaskDescription: {
__typename: 'Task', __typename: 'Task',
id: updatedTask.id, id: updatedTask.id,
description: newDescription, description: newDescription,
},
}, },
}, });
}); }}
}} onDeleteTask={(deletedTask) => {
onDeleteTask={deletedTask => { deleteTask({ variables: { taskID: deletedTask.id } });
deleteTask({ variables: { taskID: deletedTask.id } }); history.push(`${match.url}/board`);
history.push(`${match.url}/board`); }}
}} onOpenAddLabelPopup={(task, $targetRef) => {
onOpenAddLabelPopup={(task, $targetRef) => { taskLabelsRef.current = task.labels;
taskLabelsRef.current = task.labels; showPopup(
showPopup( $targetRef,
$targetRef, <LabelManagerEditor
<LabelManagerEditor onLabelToggle={(labelID) => {
onLabelToggle={labelID => { toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } }); }}
}} taskID={task.id}
labelColors={data.labelColors} labelColors={data.labelColors}
labels={labelsRef} taskLabels={taskLabelsRef}
taskLabels={taskLabelsRef} projectID={projectID}
projectID={projectID} />,
/>, );
); }}
}} />
/> );
)} }}
/> />
</> </>
); );
} }
return <div>Error</div>; return (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
<BoardLoading />
</>
);
}; };
export default Project; export default Project;

View File

@ -9,21 +9,23 @@ import {
GetProjectsDocument, GetProjectsDocument,
GetProjectsQuery, GetProjectsQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import FormInput from 'shared/components/FormInput';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import NewProject from 'shared/components/NewProject'; import NewProject from 'shared/components/NewProject';
import { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import Input from 'shared/components/Input'; import ControlledInput from 'shared/components/ControlledInput';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import produce from 'immer'; import produce from 'immer';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import theme from 'App/ThemeStyles'; import theme from 'App/ThemeStyles';
import polling from 'shared/utils/polling';
import { mixin } from '../shared/utils/styles'; import { mixin } from '../shared/utils/styles';
type CreateTeamData = { teamName: string }; type CreateTeamData = { name: string };
type CreateTeamFormProps = { type CreateTeamFormProps = {
onCreateTeam: (teamName: string) => void; onCreateTeam: (teamName: string) => void;
@ -35,28 +37,30 @@ const CreateTeamButton = styled(Button)`
width: 100%; width: 100%;
`; `;
const ErrorText = styled.span`
font-size: 14px;
color: ${(props) => props.theme.colors.danger};
`;
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => { const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
const { register, handleSubmit } = useForm<CreateTeamData>(); const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateTeamData>();
const createTeam = (data: CreateTeamData) => { const createTeam = (data: CreateTeamData) => {
onCreateTeam(data.teamName); onCreateTeam(data.name);
}; };
return ( return (
<CreateTeamFormContainer onSubmit={handleSubmit(createTeam)}> <CreateTeamFormContainer onSubmit={handleSubmit(createTeam)}>
<Input {errors.name && <ErrorText>{errors.name.message}</ErrorText>}
width="100%" <FormInput width="100%" label="Team name" variant="alternate" {...register('name')} />
label="Team name"
id="teamName"
name="teamName"
variant="alternate"
ref={register({ required: 'Team name is required' })}
/>
<CreateTeamButton type="submit">Create</CreateTeamButton> <CreateTeamButton type="submit">Create</CreateTeamButton>
</CreateTeamFormContainer> </CreateTeamFormContainer>
); );
}; };
const ProjectAddTile = styled.div` const ProjectAddTile = styled.div`
background-color: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)}; background-color: ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
background-size: cover; background-size: cover;
background-position: 50%; background-position: 50%;
color: #fff; color: #fff;
@ -70,7 +74,7 @@ const ProjectAddTile = styled.div`
`; `;
const ProjectTile = styled(Link)<{ color: string }>` const ProjectTile = styled(Link)<{ color: string }>`
background-color: ${props => props.color}; background-color: ${(props) => props.color};
background-size: cover; background-size: cover;
background-position: 50%; background-position: 50%;
color: #fff; color: #fff;
@ -141,7 +145,7 @@ const ProjectTileName = styled.div<{ centered?: boolean }>`
max-height: 40px; max-height: 40px;
width: 100%; width: 100%;
word-wrap: break-word; word-wrap: break-word;
${props => props.centered && 'text-align: center;'} ${(props) => props.centered && 'text-align: center;'}
`; `;
const Wrapper = styled.div` const Wrapper = styled.div`
@ -150,6 +154,7 @@ const Wrapper = styled.div`
flex-direction: row; flex-direction: row;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
overflow-y: auto;
`; `;
const ProjectSectionTitleWrapper = styled.div` const ProjectSectionTitleWrapper = styled.div`
@ -178,7 +183,7 @@ const SectionActionLink = styled(Link)`
const ProjectSectionTitle = styled.h3` const ProjectSectionTitle = styled.h3`
font-size: 16px; font-size: 16px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
`; `;
const ProjectsContainer = styled.div` const ProjectsContainer = styled.div`
@ -202,14 +207,14 @@ type ShowNewProject = {
const Projects = () => { const Projects = () => {
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' }); const { loading, data } = useGetProjectsQuery({ pollInterval: polling.PROJECTS, fetchPolicy: 'cache-and-network' });
useEffect(() => { useEffect(() => {
document.title = 'Taskcafé'; document.title = 'Taskcafé';
}, []); }, []);
const [createProject] = useCreateProjectMutation({ const [createProject] = useCreateProjectMutation({
update: (client, newProject) => { update: (client, newProject) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache => updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (newProject.data) { if (newProject.data) {
draftCache.projects.push({ ...newProject.data.createProject }); draftCache.projects.push({ ...newProject.data.createProject });
} }
@ -222,8 +227,8 @@ const Projects = () => {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [createTeam] = useCreateTeamMutation({ const [createTeam] = useCreateTeamMutation({
update: (client, createData) => { update: (client, createData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache => updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (createData.data) { if (createData.data) {
draftCache.teams.push({ ...createData.data?.createTeam }); draftCache.teams.push({ ...createData.data?.createTeam });
} }
@ -231,16 +236,13 @@ const Projects = () => {
); );
}, },
}); });
if (loading) {
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
}
const colors = theme.colors.multiColors; const colors = theme.colors.multiColors;
if (data && user) { if (data && user) {
const { projects, teams, organizations } = data; const { projects, teams, organizations } = data;
const organizationID = organizations[0].id ?? null; const organizationID = organizations[0].id ?? null;
const personalProjects = projects const personalProjects = projects
.filter(p => p.team === null) .filter((p) => p.team === null)
.sort((a, b) => { .sort((a, b) => {
const textA = a.name.toUpperCase(); const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase(); const textB = b.name.toUpperCase();
@ -252,12 +254,12 @@ const Projects = () => {
const textB = b.name.toUpperCase(); const textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
}) })
.map(team => { .map((team) => {
return { return {
id: team.id, id: team.id,
name: team.name, name: team.name,
projects: projects projects: projects
.filter(project => project.team && project.team.id === team.id) .filter((project) => project.team && project.team.id === team.id)
.sort((a, b) => { .sort((a, b) => {
const textA = a.name.toUpperCase(); const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase(); const textB = b.name.toUpperCase();
@ -270,10 +272,10 @@ const Projects = () => {
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} /> <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<Wrapper> <Wrapper>
<ProjectsContainer> <ProjectsContainer>
{user.roles.org === 'admin' && ( {true && ( // TODO: add permision check
<AddTeamButton <AddTeamButton
variant="outline" variant="outline"
onClick={$target => { onClick={($target) => {
showPopup( showPopup(
$target, $target,
<Popup <Popup
@ -284,7 +286,7 @@ const Projects = () => {
}} }}
> >
<CreateTeamForm <CreateTeamForm
onCreateTeam={teamName => { onCreateTeam={(teamName) => {
if (organizationID) { if (organizationID) {
createTeam({ variables: { name: teamName, organizationID } }); createTeam({ variables: { name: teamName, organizationID } });
hidePopup(); hidePopup();
@ -305,7 +307,7 @@ const Projects = () => {
<ProjectList> <ProjectList>
{personalProjects.map((project, idx) => ( {personalProjects.map((project, idx) => (
<ProjectListItem key={project.id}> <ProjectListItem key={project.id}>
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}> <ProjectTile color={colors[idx % 5]} to={`/p/${project.shortId}`}>
<ProjectTileFade /> <ProjectTileFade />
<ProjectTileDetails> <ProjectTileDetails>
<ProjectTileName>{project.name}</ProjectTileName> <ProjectTileName>{project.name}</ProjectTileName>
@ -327,12 +329,12 @@ const Projects = () => {
</ProjectListItem> </ProjectListItem>
</ProjectList> </ProjectList>
</div> </div>
{projectTeams.map(team => { {projectTeams.map((team) => {
return ( return (
<div key={team.id}> <div key={team.id}>
<ProjectSectionTitleWrapper> <ProjectSectionTitleWrapper>
<ProjectSectionTitle>{team.name}</ProjectSectionTitle> <ProjectSectionTitle>{team.name}</ProjectSectionTitle>
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && ( {true && ( // TODO: add permision check
<SectionActions> <SectionActions>
<SectionActionLink to={`/teams/${team.id}`}> <SectionActionLink to={`/teams/${team.id}`}>
<SectionAction variant="outline">Projects</SectionAction> <SectionAction variant="outline">Projects</SectionAction>
@ -349,7 +351,7 @@ const Projects = () => {
<ProjectList> <ProjectList>
{team.projects.map((project, idx) => ( {team.projects.map((project, idx) => (
<ProjectListItem key={project.id}> <ProjectListItem key={project.id}>
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}> <ProjectTile color={colors[idx % 5]} to={`/p/${project.shortId}`}>
<ProjectTileFade /> <ProjectTileFade />
<ProjectTileDetails> <ProjectTileDetails>
<ProjectTileName>{project.name}</ProjectTileName> <ProjectTileName>{project.name}</ProjectTileName>
@ -357,7 +359,7 @@ const Projects = () => {
</ProjectTile> </ProjectTile>
</ProjectListItem> </ProjectListItem>
))} ))}
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && ( {true && ( // TODO: add permision check
<ProjectListItem> <ProjectListItem>
<ProjectAddTile <ProjectAddTile
onClick={() => { onClick={() => {
@ -395,7 +397,7 @@ const Projects = () => {
</> </>
); );
} }
return <div>Error!</div>; return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
}; };
export default Projects; export default Projects;

View File

@ -17,6 +17,7 @@ const UsersRegister = () => {
<Register <Register
registered={registered} registered={registered}
onSubmit={(data, setComplete, setError) => { onSubmit={(data, setComplete, setError) => {
let isRedirected = false;
if (data.password !== data.password_confirm) { if (data.password !== data.password_confirm) {
setError('password', { type: 'error', message: 'Passwords must match' }); setError('password', { type: 'error', message: 'Passwords must match' });
setError('password_confirm', { type: 'error', message: 'Passwords must match' }); setError('password_confirm', { type: 'error', message: 'Passwords must match' });
@ -35,23 +36,26 @@ const UsersRegister = () => {
}, },
}), }),
}) })
.then(async x => { .then(async (x) => {
const response = await x.json(); const response = await x.json();
const { setup } = response; const { setup } = response;
console.log(response);
if (setup) { if (setup) {
history.replace(`/confirm?confirmToken=xxxx`); history.replace(`/confirm?confirmToken=xxxx`);
isRedirected = true;
} else if (params.confirmToken) { } else if (params.confirmToken) {
history.replace(`/confirm?confirmToken=${params.confirmToken}`); history.replace(`/confirm?confirmToken=${params.confirmToken}`);
isRedirected = true;
} else { } else {
setRegistered(true); setRegistered(true);
} }
}) })
.catch(() => { .catch((e) => {
toast('There was an issue trying to register'); toast('There was an issue trying to register');
}); });
} }
setComplete(true); if (!isRedirected) {
setComplete(true);
}
}} }}
/> />
</LoginWrapper> </LoginWrapper>

View File

@ -2,8 +2,9 @@ import React, { useState } from 'react';
import Input from 'shared/components/Input'; import Input from 'shared/components/Input';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import produce from 'immer'; import produce from 'immer';
import polling from 'shared/utils/polling';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { useCurrentUser, PermissionLevel, PermissionObjectType } from 'App/context'; import { useCurrentUser } from 'App/context';
import Select from 'shared/components/Select'; import Select from 'shared/components/Select';
import { import {
useGetTeamQuery, useGetTeamQuery,
@ -35,7 +36,7 @@ const UserMember = styled(Member)`
padding: 4px 0; padding: 4px 0;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)}; background: ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
} }
border-radius: 6px; 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" /> <SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
<TeamMemberList> <TeamMemberList>
{users {users
.filter(u => u.id !== teamMembers.find(p => p.id === u.id)?.id) .filter((u) => u.id !== teamMembers.find((p) => p.id === u.id)?.id)
.map(user => ( .map((user) => (
<UserMember <UserMember
key={user.id} key={user.id}
onCardMemberClick={() => onAddTeamMember(user.id)} onCardMemberClick={() => onAddTeamMember(user.id)}
@ -115,7 +116,7 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
position: relative; position: relative;
text-decoration: none; text-decoration: none;
${props => ${(props) =>
props.disabled props.disabled
? css` ? css`
user-select: none; user-select: none;
@ -136,7 +137,7 @@ export const Content = styled.div`
export const CurrentPermission = styled.span` export const CurrentPermission = styled.span`
margin-left: 4px; margin-left: 4px;
color: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.4)}; color: ${(props) => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
`; `;
export const Separator = styled.div` export const Separator = styled.div`
@ -147,13 +148,13 @@ export const Separator = styled.div`
export const WarningText = styled.span` export const WarningText = styled.span`
display: flex; display: flex;
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)}; color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.4)};
padding: 6px; padding: 6px;
`; `;
export const DeleteDescription = styled.div` export const DeleteDescription = styled.div`
font-size: 14px; font-size: 14px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
`; `;
export const RemoveMemberButton = styled(Button)` export const RemoveMemberButton = styled(Button)`
@ -220,13 +221,13 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
<MiniProfileActions> <MiniProfileActions>
<MiniProfileActionWrapper> <MiniProfileActionWrapper>
{permissions {permissions
.filter(p => (subject.role && subject.role.code === 'owner') || p.code !== 'owner') .filter((p) => (subject.role && subject.role.code === 'owner') || p.code !== 'owner')
.map(perm => ( .map((perm) => (
<MiniProfileActionItem <MiniProfileActionItem
disabled={subject.role && perm.code !== subject.role.code && !canChangeRole} disabled={subject.role && perm.code !== subject.role.code && !canChangeRole}
key={perm.code} key={perm.code}
onClick={() => { onClick={() => {
if (onChangeRole && subject.role && perm.code !== subject.role.code) { if (subject.role && perm.code !== subject.role.code) {
switch (perm.code) { switch (perm.code) {
case 'owner': case 'owner':
onChangeRole(RoleCode.Owner); onChangeRole(RoleCode.Owner);
@ -275,8 +276,8 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
<Select <Select
label="New projects owner" label="New projects owner"
value={orphanedProjectOwner} value={orphanedProjectOwner}
onChange={value => setOrphanedProjectOwner(value)} onChange={(value) => setOrphanedProjectOwner(value)}
options={members.filter(m => m.id !== subject.id).map(m => ({ label: m.fullName, value: m.id }))} options={members.filter((m) => m.id !== subject.id).map((m) => ({ label: m.fullName, value: m.id }))}
/> />
</> </>
)} )}
@ -306,14 +307,14 @@ const MemberItemOption = styled(Button)`
`; `;
const MemberList = styled.div` const MemberList = styled.div`
border-top: 1px solid ${props => props.theme.colors.border}; border-top: 1px solid ${(props) => props.theme.colors.border};
`; `;
const MemberListItem = styled.div` const MemberListItem = styled.div`
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid ${props => props.theme.colors.border}; border-bottom: 1px solid ${(props) => props.theme.colors.border};
min-height: 40px; min-height: 40px;
padding: 12px 0 12px 40px; padding: 12px 0 12px 40px;
position: relative; position: relative;
@ -337,11 +338,11 @@ const MemberProfile = styled(TaskAssignee)`
`; `;
const MemberItemName = styled.p` const MemberItemName = styled.p`
color: ${props => props.theme.colors.text.secondary}; color: ${(props) => props.theme.colors.text.secondary};
`; `;
const MemberItemUsername = styled.p` const MemberItemUsername = styled.p`
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
`; `;
const MemberListHeader = styled.div` const MemberListHeader = styled.div`
@ -350,12 +351,12 @@ const MemberListHeader = styled.div`
`; `;
const ListTitle = styled.h3` const ListTitle = styled.h3`
font-size: 18px; font-size: 18px;
color: ${props => props.theme.colors.text.secondary}; color: ${(props) => props.theme.colors.text.secondary};
margin-bottom: 12px; margin-bottom: 12px;
`; `;
const ListDesc = styled.span` const ListDesc = styled.span`
font-size: 16px; font-size: 16px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
`; `;
const FilterSearch = styled(Input)` const FilterSearch = styled(Input)`
margin: 0; margin: 0;
@ -387,11 +388,11 @@ const FilterTabItem = styled.li`
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
padding: 6px 8px; padding: 6px 8px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
&:hover { &:hover {
border-radius: 6px; border-radius: 6px;
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
color: ${props => props.theme.colors.text.secondary}; color: ${(props) => props.theme.colors.text.secondary};
} }
`; `;
@ -419,8 +420,12 @@ type MembersProps = {
const Members: React.FC<MembersProps> = ({ teamID }) => { const Members: React.FC<MembersProps> = ({ teamID }) => {
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetTeamQuery({ variables: { teamID } }); const { loading, data } = useGetTeamQuery({
const { user, setUserRoles } = useCurrentUser(); variables: { teamID },
fetchPolicy: 'cache-and-network',
pollInterval: polling.MEMBERS,
});
const { user } = useCurrentUser();
const warning = const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.'; 'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
const [createTeamMember] = useCreateTeamMemberMutation({ const [createTeamMember] = useCreateTeamMemberMutation({
@ -428,8 +433,8 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
updateApolloCache<GetTeamQuery>( updateApolloCache<GetTeamQuery>(
client, client,
GetTeamDocument, GetTeamDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (response.data) { if (response.data) {
draftCache.findTeam.members.push({ draftCache.findTeam.members.push({
...response.data.createTeamMember.teamMember, ...response.data.createTeamMember.teamMember,
@ -442,35 +447,22 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
); );
}, },
}); });
const [updateTeamMemberRole] = useUpdateTeamMemberRoleMutation({ const [updateTeamMemberRole] = useUpdateTeamMemberRoleMutation();
onCompleted: r => {
if (user) {
setUserRoles(
produce(user.roles, draftRoles => {
draftRoles.teams.set(r.updateTeamMemberRole.teamID, r.updateTeamMemberRole.member.role.code);
}),
);
}
},
});
const [deleteTeamMember] = useDeleteTeamMemberMutation({ const [deleteTeamMember] = useDeleteTeamMemberMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<GetTeamQuery>( updateApolloCache<GetTeamQuery>(
client, client,
GetTeamDocument, GetTeamDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
draftCache.findTeam.members = cache.findTeam.members.filter( draftCache.findTeam.members = cache.findTeam.members.filter(
member => member.id !== response.data?.deleteTeamMember.userID, (member) => member.id !== response.data?.deleteTeamMember.userID,
); );
}), }),
{ teamID }, { teamID },
); );
}, },
}); });
if (loading) {
return <span>loading</span>;
}
if (data && user) { if (data && user) {
return ( return (
@ -490,15 +482,15 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
</ListDesc> </ListDesc>
<ListActions> <ListActions>
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" /> <FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, data.findTeam.id) && ( {true && ( // TODO: add permission check
<InviteMemberButton <InviteMemberButton
onClick={$target => { onClick={($target) => {
showPopup( showPopup(
$target, $target,
<UserManagementPopup <UserManagementPopup
users={data.users} users={data.users}
teamMembers={data.findTeam.members} teamMembers={data.findTeam.members}
onAddTeamMember={userID => { onAddTeamMember={(userID) => {
createTeamMember({ variables: { userID, teamID } }); createTeamMember({ variables: { userID, teamID } });
}} }}
/>, />,
@ -512,7 +504,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
</ListActions> </ListActions>
</MemberListHeader> </MemberListHeader>
<MemberList> <MemberList>
{data.findTeam.members.map(member => ( {data.findTeam.members.map((member) => (
<MemberListItem> <MemberListItem>
<MemberProfile showRoleIcons size={32} onMemberProfile={NOOP} member={member} /> <MemberProfile showRoleIcons size={32} onMemberProfile={NOOP} member={member} />
<MemberListItemDetails> <MemberListItemDetails>
@ -523,22 +515,23 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
<MemberItemOption variant="flat">On 2 projects</MemberItemOption> <MemberItemOption variant="flat">On 2 projects</MemberItemOption>
<MemberItemOption <MemberItemOption
variant="outline" variant="outline"
onClick={$target => { onClick={($target) => {
showPopup( showPopup(
$target, $target,
<TeamRoleManagerPopup <TeamRoleManagerPopup
currentUserID={user.id ?? ''} currentUserID={user ?? ''}
subject={member} subject={member}
members={data.findTeam.members} members={data.findTeam.members}
warning={member.role && member.role.code === 'owner' ? warning : null} warning={member.role && member.role.code === 'owner' ? warning : null}
canChangeRole={user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)} // canChangeRole={user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)} TODO: add permission check
onChangeRole={roleCode => { canChangeRole
onChangeRole={(roleCode) => {
updateTeamMemberRole({ variables: { userID: member.id, teamID, roleCode } }); updateTeamMemberRole({ variables: { userID: member.id, teamID, roleCode } });
}} }}
onRemoveFromTeam={ onRemoveFromTeam={
member.role && member.role.code === 'owner' member.role && member.role.code === 'owner'
? undefined ? undefined
: newOwnerID => { : (newOwnerID) => {
deleteTeamMember({ variables: { teamID, newOwnerID, userID: member.id } }); deleteTeamMember({ variables: { teamID, newOwnerID, userID: member.id } });
hidePopup(); hidePopup();
} }
@ -558,7 +551,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
); );
} }
return <div>error</div>; return <div>loading</div>;
}; };
export default Members; export default Members;

View File

@ -9,6 +9,7 @@ import {
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Input from 'shared/components/Input'; import Input from 'shared/components/Input';
import theme from 'App/ThemeStyles'; import theme from 'App/ThemeStyles';
import polling from 'shared/utils/polling';
const FilterSearch = styled(Input)` const FilterSearch = styled(Input)`
margin: 0; margin: 0;
@ -155,10 +156,11 @@ type TeamProjectsProps = {
}; };
const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => { const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
const { loading, data } = useGetTeamQuery({ variables: { teamID } }); const { loading, data } = useGetTeamQuery({
if (loading) { variables: { teamID },
return <span>loading</span>; fetchPolicy: 'cache-and-network',
} pollInterval: polling.TEAM_PROJECTS,
});
if (data) { if (data) {
return ( return (
<ProjectsContainer> <ProjectsContainer>
@ -189,7 +191,7 @@ const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
</ProjectsContainer> </ProjectsContainer>
); );
} }
return <span>error</span>; return <span>loading</span>;
}; };
export default TeamProjects; export default TeamProjects;

View File

@ -13,7 +13,7 @@ import { usePopup, Popup } from 'shared/components/PopupMenu';
import { History } from 'history'; import { History } from 'history';
import produce from 'immer'; import produce from 'immer';
import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings'; import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { PermissionObjectType, PermissionLevel, useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import Members from './Members'; import Members from './Members';
import Projects from './Projects'; import Projects from './Projects';
@ -94,27 +94,13 @@ const Teams = () => {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [currentTab, setCurrentTab] = useState(0); const [currentTab, setCurrentTab] = useState(0);
const match = useRouteMatch(); const match = useRouteMatch();
if (loading) {
return (
<GlobalTopNavbar
menuType={[
{ name: 'Projects', link: `${match.url}` },
{ name: 'Members', link: `${match.url}/members` },
]}
currentTab={currentTab}
onSetTab={tab => {
setCurrentTab(tab);
}}
onSaveProjectName={NOOP}
projectID={null}
name={null}
/>
);
}
if (data && user) { if (data && user) {
/*
TODO: re-add permission check
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) { if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
return <Redirect to="/" />; return <Redirect to="/" />;
} }
*/
return ( return (
<> <>
<GlobalTopNavbar <GlobalTopNavbar
@ -146,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; export default Teams;

View File

@ -1,34 +1,45 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import axios from 'axios'; import { ApolloClient } from '@apollo/client';
import createAuthRefreshInterceptor from 'axios-auth-refresh'; import { ApolloProvider } from '@apollo/client/react';
import { ApolloProvider } from '@apollo/react-hooks';
import { ApolloClient } from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error';
import { enableMapSet } from 'immer'; import { enableMapSet } from 'immer';
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import updateLocale from 'dayjs/plugin/updateLocale'; import updateLocale from 'dayjs/plugin/updateLocale';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween'; import isBetween from 'dayjs/plugin/isBetween';
import weekday from 'dayjs/plugin/weekday'; import weekday from 'dayjs/plugin/weekday';
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken'; import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import log from 'loglevel';
import remote from 'loglevel-plugin-remote';
import cache from './App/cache'; import cache from './App/cache';
import App from './App'; import App from './App';
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8 if (process.env.REACT_APP_NODE_ENV === 'production') {
remote.apply(log, { format: remote.json });
switch (process.env.REACT_APP_LOG_LEVEL) {
case 'info':
log.setLevel(log.levels.INFO);
break;
case 'debug':
log.setLevel(log.levels.DEBUG);
break;
default:
log.setLevel(log.levels.ERROR);
}
}
enableMapSet();
dayjs.extend(isSameOrAfter); dayjs.extend(isSameOrAfter);
dayjs.extend(weekday); dayjs.extend(weekday);
dayjs.extend(isBetween); dayjs.extend(isBetween);
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
enableMapSet();
dayjs.extend(updateLocale); dayjs.extend(updateLocale);
dayjs.extend(duration);
dayjs.extend(relativeTime);
dayjs.updateLocale('en', { dayjs.updateLocale('en', {
week: { week: {
dow: 1, // First day of week is Monday dow: 1, // First day of week is Monday
@ -36,131 +47,7 @@ dayjs.updateLocale('en', {
}, },
}); });
let forward$; const client = new ApolloClient({ uri: '/graphql', cache });
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}`); // eslint-disable-line no-console
}
return undefined;
});
const requestLink = new ApolloLink(
(operation, forward) =>
new Observable((observer: any) => {
let handle: any;
Promise.resolve(operation)
.then((op: any) => {
const accessToken = getAccessToken();
if (accessToken) {
op.setContext({
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
}
})
.then(() => {
handle = forward(operation).subscribe({
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
})
.catch(observer.error.bind(observer));
return () => {
if (handle) {
handle.unsubscribe();
}
};
}),
);
const client = new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(
({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`), // eslint-disable-line no-console
);
}
if (networkError) {
console.log(`[Network error]: ${networkError}`); // eslint-disable-line no-console
}
}),
errorLink,
requestLink,
new HttpLink({
uri: '/graphql',
credentials: 'same-origin',
}),
]),
cache,
});
ReactDOM.render( ReactDOM.render(
<ApolloProvider client={client}> <ApolloProvider client={client}>

View File

@ -1,19 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import theme from 'App/ThemeStyles';
import AddList from '.';
export default {
component: AddList,
title: 'AddList',
parameters: {
backgrounds: [
{ name: 'gray', value: theme.colors.bg.secondary, 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 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 { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';

View File

@ -49,6 +49,7 @@ export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCa
<ListNameEditorWrapper> <ListNameEditorWrapper>
<ListNameEditor <ListNameEditor
ref={$editorRef} ref={$editorRef}
height={40}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
value={listName} value={listName}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setListName(e.currentTarget.value)} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setListName(e.currentTarget.value)}

View File

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

View File

@ -10,6 +10,215 @@ import Button from 'shared/components/Button';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
const UserSelect = styled(Select)`
margin: 8px 0;
padding: 8px 0;
`;
const NewUserPassInput = styled(Input)`
margin: 8px 0;
`;
const InviteMemberButton = styled(Button)`
padding: 7px 12px;
`;
const UserPassBar = styled.div`
display: flex;
padding-top: 8px;
`;
const UserPassConfirmButton = styled(Button)`
width: 100%;
padding: 7px 12px;
`;
const UserPassButton = styled(Button)`
width: 50%;
padding: 7px 12px;
& ~ & {
margin-left: 6px;
}
`;
const MemberItemOptions = styled.div``;
const MemberItemOption = styled(Button)`
padding: 7px 9px;
margin: 4px 0 4px 8px;
float: left;
min-width: 95px;
`;
const MemberList = styled.div`
border-top: 1px solid ${(props) => props.theme.colors.border};
`;
const MemberListItem = styled.div`
display: flex;
flex-flow: row wrap;
justify-content: space-between;
border-bottom: 1px solid ${(props) => props.theme.colors.border};
min-height: 40px;
padding: 12px 0 12px 40px;
position: relative;
`;
const MemberListItemDetails = styled.div`
float: left;
flex: 1 0 auto;
padding-left: 8px;
`;
const InviteIcon = styled(UserPlus)`
padding-right: 4px;
`;
const MemberProfile = styled(TaskAssignee)`
position: absolute;
top: 16px;
left: 0;
margin: 0;
`;
const MemberItemName = styled.p`
color: ${(props) => props.theme.colors.text.secondary};
`;
const MemberItemUsername = styled.p`
color: ${(props) => props.theme.colors.text.primary};
`;
const MemberListHeader = styled.div`
display: flex;
flex-direction: column;
`;
const ListTitle = styled.h3`
font-size: 18px;
color: ${(props) => props.theme.colors.text.secondary};
margin-bottom: 12px;
`;
const ListDesc = styled.span`
font-size: 16px;
color: ${(props) => props.theme.colors.text.primary};
`;
const FilterSearch = styled(Input)`
margin: 0;
`;
const ListActions = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
margin-bottom: 18px;
`;
const MemberListWrapper = styled.div`
flex: 1 1;
`;
const Container = styled.div`
padding: 2.2rem;
display: flex;
width: 100%;
max-width: 1400px;
position: relative;
margin: 0 auto;
`;
const TabNav = styled.div`
float: left;
width: 220px;
height: 100%;
display: block;
position: relative;
`;
const TabNavContent = styled.ul`
display: block;
width: auto;
border-bottom: 0 !important;
border-right: 1px solid rgba(0, 0, 0, 0.05);
`;
const TabNavItem = styled.li`
padding: 0.35rem 0.3rem;
height: 48px;
display: block;
position: relative;
`;
const TabNavItemButton = styled.button<{ active: boolean }>`
cursor: pointer;
display: flex;
align-items: center;
padding-top: 10px !important;
padding-bottom: 10px !important;
padding-left: 12px !important;
padding-right: 8px !important;
width: 100%;
position: relative;
color: ${(props) => (props.active ? `${props.theme.colors.secondary}` : props.theme.colors.text.primary)};
&:hover {
color: ${(props) => `${props.theme.colors.primary}`};
}
&:hover svg {
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;
padding-left: 9px;
font-size: 14px;
`;
const TabNavLine = styled.span<{ top: number }>`
left: auto;
right: 0;
width: 2px;
height: 48px;
transform: scaleX(1);
top: ${(props) => props.top}px;
background: linear-gradient(
30deg,
${(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;
`;
const TabContentWrapper = styled.div`
position: relative;
display: block;
overflow: hidden;
width: 100%;
margin-left: 1rem;
`;
const TabContent = styled.div`
position: relative;
width: 100%;
display: block;
padding: 0;
padding: 1.5rem;
background-color: #10163a;
border-radius: 0.5rem;
`;
const items = [{ name: 'Members' }];
export const RoleCheckmark = styled(Checkmark)` export const RoleCheckmark = styled(Checkmark)`
padding-left: 4px; padding-left: 4px;
`; `;
@ -54,7 +263,7 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
position: relative; position: relative;
text-decoration: none; text-decoration: none;
${props => ${(props) =>
props.disabled props.disabled
? css` ? css`
user-select: none; user-select: none;
@ -75,7 +284,7 @@ export const Content = styled.div`
export const CurrentPermission = styled.span` export const CurrentPermission = styled.span`
margin-left: 4px; margin-left: 4px;
color: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.4)}; color: ${(props) => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
`; `;
export const Separator = styled.div` export const Separator = styled.div`
@ -86,13 +295,13 @@ export const Separator = styled.div`
export const WarningText = styled.span` export const WarningText = styled.span`
display: flex; display: flex;
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)}; color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.4)};
padding: 6px; padding: 6px;
`; `;
export const DeleteDescription = styled.div` export const DeleteDescription = styled.div`
font-size: 14px; font-size: 14px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
`; `;
export const RemoveMemberButton = styled(Button)` export const RemoveMemberButton = styled(Button)`
@ -161,8 +370,8 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
<MiniProfileActions> <MiniProfileActions>
<MiniProfileActionWrapper> <MiniProfileActionWrapper>
{permissions {permissions
.filter(p => (user.role && user.role.code === 'owner') || p.code !== 'owner') .filter((p) => (user.role && user.role.code === 'owner') || p.code !== 'owner')
.map(perm => ( .map((perm) => (
<MiniProfileActionItem <MiniProfileActionItem
disabled={user.role && perm.code !== user.role.code && !canChangeRole} disabled={user.role && perm.code !== user.role.code && !canChangeRole}
key={perm.code} key={perm.code}
@ -213,9 +422,9 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
Choose a new user to take over ownership of the users teams & projects. Choose a new user to take over ownership of the users teams & projects.
</DeleteDescription> </DeleteDescription>
<UserSelect <UserSelect
onChange={v => setDeleteUser(v)} onChange={(v) => setDeleteUser(v)}
value={deleteUser} value={deleteUser}
options={users.map(u => ({ label: u.fullName, value: u.id }))} options={users.map((u) => ({ label: u.fullName, value: u.id }))}
/> />
</> </>
)} )}
@ -240,7 +449,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
Removing this user from the organzation will remove them from assigned tasks, projects, and teams. Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
</DeleteDescription> </DeleteDescription>
<DeleteDescription>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} teams.`}</DeleteDescription> <DeleteDescription>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} teams.`}</DeleteDescription>
<UserSelect onChange={NOOP} value={null} options={users.map(u => ({ label: u.fullName, value: u.id }))} /> <UserSelect onChange={NOOP} value={null} options={users.map((u) => ({ label: u.fullName, value: u.id }))} />
<UserPassConfirmButton <UserPassConfirmButton
onClick={() => { onClick={() => {
// onDeleteUser(); // onDeleteUser();
@ -293,211 +502,6 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
); );
}; };
const UserSelect = styled(Select)`
margin: 8px 0;
padding: 8px 0;
`;
const NewUserPassInput = styled(Input)`
margin: 8px 0;
`;
const InviteMemberButton = styled(Button)`
padding: 7px 12px;
`;
const UserPassBar = styled.div`
display: flex;
padding-top: 8px;
`;
const UserPassConfirmButton = styled(Button)`
width: 100%;
padding: 7px 12px;
`;
const UserPassButton = styled(Button)`
width: 50%;
padding: 7px 12px;
& ~ & {
margin-left: 6px;
}
`;
const MemberItemOptions = styled.div``;
const MemberItemOption = styled(Button)`
padding: 7px 9px;
margin: 4px 0 4px 8px;
float: left;
min-width: 95px;
`;
const MemberList = styled.div`
border-top: 1px solid ${props => props.theme.colors.border};
`;
const MemberListItem = styled.div`
display: flex;
flex-flow: row wrap;
justify-content: space-between;
border-bottom: 1px solid ${props => props.theme.colors.border};
min-height: 40px;
padding: 12px 0 12px 40px;
position: relative;
`;
const MemberListItemDetails = styled.div`
float: left;
flex: 1 0 auto;
padding-left: 8px;
`;
const InviteIcon = styled(UserPlus)`
padding-right: 4px;
`;
const MemberProfile = styled(TaskAssignee)`
position: absolute;
top: 16px;
left: 0;
margin: 0;
`;
const MemberItemName = styled.p`
color: ${props => props.theme.colors.text.secondary};
`;
const MemberItemUsername = styled.p`
color: ${props => props.theme.colors.text.primary};
`;
const MemberListHeader = styled.div`
display: flex;
flex-direction: column;
`;
const ListTitle = styled.h3`
font-size: 18px;
color: ${props => props.theme.colors.text.secondary};
margin-bottom: 12px;
`;
const ListDesc = styled.span`
font-size: 16px;
color: ${props => props.theme.colors.text.primary};
`;
const FilterSearch = styled(Input)`
margin: 0;
`;
const ListActions = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
margin-bottom: 18px;
`;
const MemberListWrapper = styled.div`
flex: 1 1;
`;
const Container = styled.div`
padding: 2.2rem;
display: flex;
width: 100%;
max-width: 1400px;
position: relative;
margin: 0 auto;
`;
const TabNav = styled.div`
float: left;
width: 220px;
height: 100%;
display: block;
position: relative;
`;
const TabNavContent = styled.ul`
display: block;
width: auto;
border-bottom: 0 !important;
border-right: 1px solid rgba(0, 0, 0, 0.05);
`;
const TabNavItem = styled.li`
padding: 0.35rem 0.3rem;
height: 48px;
display: block;
position: relative;
`;
const TabNavItemButton = styled.button<{ active: boolean }>`
cursor: pointer;
display: flex;
align-items: center;
padding-top: 10px !important;
padding-bottom: 10px !important;
padding-left: 12px !important;
padding-right: 8px !important;
width: 100%;
position: relative;
color: ${props => (props.active ? `${props.theme.colors.secondary}` : props.theme.colors.text.primary)};
&:hover {
color: ${props => `${props.theme.colors.primary}`};
}
&:hover svg {
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;
padding-left: 9px;
font-size: 14px;
`;
const TabNavLine = styled.span<{ top: number }>`
left: auto;
right: 0;
width: 2px;
height: 48px;
transform: scaleX(1);
top: ${props => props.top}px;
background: linear-gradient(30deg, ${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;
`;
const TabContentWrapper = styled.div`
position: relative;
display: block;
overflow: hidden;
width: 100%;
margin-left: 1rem;
`;
const TabContent = styled.div`
position: relative;
width: 100%;
display: block;
padding: 0;
padding: 1.5rem;
background-color: #10163a;
border-radius: 0.5rem;
`;
const items = [{ name: 'Members' }];
type NavItemProps = { type NavItemProps = {
active: boolean; active: boolean;
name: string; name: string;
@ -591,7 +595,7 @@ const Admin: React.FC<AdminProps> = ({
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" /> <FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
{canInviteUser && ( {canInviteUser && (
<InviteMemberButton <InviteMemberButton
onClick={$target => { onClick={($target) => {
onAddUser($target); onAddUser($target);
}} }}
> >
@ -602,7 +606,7 @@ const Admin: React.FC<AdminProps> = ({
</ListActions> </ListActions>
</MemberListHeader> </MemberListHeader>
<MemberList> <MemberList>
{users.map(member => { {users.map((member) => {
const projectTotal = member.owned.projects.length + member.member.projects.length; const projectTotal = member.owned.projects.length + member.member.projects.length;
return ( return (
<MemberListItem> <MemberListItem>
@ -615,7 +619,7 @@ const Admin: React.FC<AdminProps> = ({
<MemberItemOption variant="flat">{`On ${projectTotal} projects`}</MemberItemOption> <MemberItemOption variant="flat">{`On ${projectTotal} projects`}</MemberItemOption>
<MemberItemOption <MemberItemOption
variant="outline" variant="outline"
onClick={$target => { onClick={($target) => {
showPopup( showPopup(
$target, $target,
<TeamRoleManagerPopup <TeamRoleManagerPopup
@ -626,7 +630,7 @@ const Admin: React.FC<AdminProps> = ({
onUpdateUserPassword(user, password); onUpdateUserPassword(user, password);
}} }}
canChangeRole={(member.role && member.role.code !== 'owner') ?? false} canChangeRole={(member.role && member.role.code !== 'owner') ?? false}
onChangeRole={roleCode => { onChangeRole={(roleCode) => {
updateUserRole({ variables: { userID: member.id, roleCode } }); updateUserRole({ variables: { userID: member.id, roleCode } });
}} }}
onDeleteUser={onDeleteUser} onDeleteUser={onDeleteUser}
@ -640,7 +644,7 @@ const Admin: React.FC<AdminProps> = ({
</MemberListItem> </MemberListItem>
); );
})} })}
{invitedUsers.map(member => { {invitedUsers.map((member) => {
return ( return (
<MemberListItem> <MemberListItem>
<MemberProfile <MemberProfile
@ -664,7 +668,7 @@ const Admin: React.FC<AdminProps> = ({
<MemberItemOptions> <MemberItemOptions>
<MemberItemOption <MemberItemOption
variant="outline" variant="outline"
onClick={$target => { onClick={($target) => {
showPopup( showPopup(
$target, $target,
<TeamRoleManagerPopup <TeamRoleManagerPopup

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

@ -6,11 +6,11 @@ const Text = styled.span<{ fontSize: string; justifyTextContent: string; hasIcon
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: ${props => props.justifyTextContent}; justify-content: ${(props) => props.justifyTextContent};
transition: all 0.2s ease; transition: all 0.2s ease;
font-size: ${props => props.fontSize}; font-size: ${(props) => props.fontSize};
color: ${props => props.theme.colors.text.secondary}; color: ${(props) => props.theme.colors.text.secondary};
${props => ${(props) =>
props.hasIcon && props.hasIcon &&
css` css`
padding-left: 4px; padding-left: 4px;
@ -23,11 +23,11 @@ const Base = styled.button<{ color: string; disabled: boolean }>`
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: 0.75rem 2rem; padding: 0.75rem 2rem;
border-radius: ${props => props.theme.borderRadius.alternate}; border-radius: ${(props) => props.theme.borderRadius.alternate};
display: flex; display: flex;
align-items: center; align-items: center;
${props => ${(props) =>
props.disabled && props.disabled &&
css` css`
opacity: 0.5; opacity: 0.5;
@ -37,8 +37,8 @@ const Base = styled.button<{ color: string; disabled: boolean }>`
`; `;
const Filled = styled(Base)<{ hoverVariant: HoverVariant }>` const Filled = styled(Base)<{ hoverVariant: HoverVariant }>`
background: ${props => props.theme.colors[props.color]}; background: ${(props) => props.theme.colors[props.color]};
${props => ${(props) =>
props.hoverVariant === 'boxShadow' && props.hoverVariant === 'boxShadow' &&
css` css`
&:hover { &:hover {
@ -48,9 +48,9 @@ const Filled = styled(Base)<{ hoverVariant: HoverVariant }>`
`; `;
const Outline = styled(Base)<{ invert: boolean }>` const Outline = styled(Base)<{ invert: boolean }>`
border: 1px solid ${props => props.theme.colors[props.color]}; border: 1px solid ${(props) => props.theme.colors[props.color]};
background: transparent; background: transparent;
${props => ${(props) =>
props.invert props.invert
? css` ? css`
background: ${props.theme.colors[props.color]}); background: ${props.theme.colors[props.color]});
@ -74,7 +74,7 @@ const Outline = styled(Base)<{ invert: boolean }>`
const Flat = styled(Base)` const Flat = styled(Base)`
background: transparent; background: transparent;
&:hover { &:hover {
background: ${props => mixin.rgba(props.theme.colors[props.color], 0.2)}; background: ${(props) => mixin.rgba(props.theme.colors[props.color], 0.2)};
} }
`; `;
@ -87,7 +87,7 @@ const LineX = styled.span<{ color: string }>`
bottom: -2px; bottom: -2px;
left: 50%; left: 50%;
transform: translate(-50%); transform: translate(-50%);
background: ${props => mixin.rgba(props.theme.colors[props.color], 1)}; background: ${(props) => mixin.rgba(props.theme.colors[props.color], 1)};
`; `;
const LineDown = styled(Base)` const LineDown = styled(Base)`
@ -96,7 +96,7 @@ const LineDown = styled(Base)`
border-width: 0; border-width: 0;
border-style: solid; border-style: solid;
border-bottom-width: 2px; border-bottom-width: 2px;
border-color: ${props => mixin.rgba(props.theme.colors[props.color], 0.2)}; border-color: ${(props) => mixin.rgba(props.theme.colors[props.color], 0.2)};
&:hover ${LineX} { &:hover ${LineX} {
width: 100%; width: 100%;
@ -109,17 +109,14 @@ const LineDown = styled(Base)`
const Gradient = styled(Base)` const Gradient = styled(Base)`
background: linear-gradient( background: linear-gradient(
30deg, 30deg,
${props => mixin.rgba(props.theme.colors[props.color], 1)}, ${(props) => mixin.rgba(props.theme.colors[props.color], 1)},
${props => mixin.rgba(props.theme.colors[props.color], 0.5)} ${(props) => mixin.rgba(props.theme.colors[props.color], 0.5)}
); );
text-shadow: 1px 2px 4px rgba(0, 0, 0, 0.3); text-shadow: 1px 2px 4px rgba(0, 0, 0, 0.3);
&:hover {
transform: translateY(-2px);
}
`; `;
const Relief = styled(Base)` const Relief = styled(Base)`
background: ${props => mixin.rgba(props.theme.colors[props.color], 1)}; background: ${(props) => mixin.rgba(props.theme.colors[props.color], 1)};
-webkit-box-shadow: 0 -3px 0 0 rgba(0, 0, 0, 0.2) inset; -webkit-box-shadow: 0 -3px 0 0 rgba(0, 0, 0, 0.2) inset;
box-shadow: inset 0 -3px 0 0 rgba(0, 0, 0, 0.2); box-shadow: inset 0 -3px 0 0 rgba(0, 0, 0, 0.2);

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,43 @@
import styled, { css, keyframes } from 'styled-components'; import styled, { css, keyframes } from 'styled-components';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
import TextareaAutosize from 'react-autosize-textarea'; import TextareaAutosize from 'react-autosize-textarea';
import { CheckCircle, CheckSquareOutline, Clock } from 'shared/icons'; import { CheckCircle, CheckSquareOutline, Clock, Bubble } from 'shared/icons';
import TaskAssignee from 'shared/components/TaskAssignee'; import TaskAssignee from 'shared/components/TaskAssignee';
export const CardMember = styled(TaskAssignee)<{ zIndex: number }>` export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
box-shadow: 0 0 0 2px ${props => props.theme.colors.bg.secondary}, 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)}; inset 0 0 0 1px ${(props) => mixin.rgba(props.theme.colors.bg.secondary, 0.07)};
z-index: ${props => props.zIndex}; z-index: ${(props) => props.zIndex};
position: relative; position: relative;
`; `;
export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'normal' }>`
${props => export const CommentsIcon = styled(Bubble)<{ color: 'success' | 'normal' }>`
${(props) =>
props.color === 'success' && props.color === 'success' &&
css` css`
fill: ${props.theme.colors.success}; fill: ${props.theme.colors.success};
stroke: ${props.theme.colors.success}; stroke: ${props.theme.colors.success};
`} `}
`; `;
export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'normal' }>`
${(props) =>
props.color === 'success' &&
css`
fill: ${props.theme.colors.success};
stroke: ${props.theme.colors.success};
`}
`;
export const ClockIcon = styled(Clock)<{ color: string }>` export const ClockIcon = styled(Clock)<{ color: string }>`
fill: ${props => props.color}; fill: ${(props) => props.color};
`; `;
export const EditorTextarea = styled(TextareaAutosize)` export const EditorTextarea = styled(TextareaAutosize)`
overflow: hidden; overflow: hidden;
overflow-wrap: break-word; overflow-wrap: break-word;
resize: none; resize: none;
height: 90px; height: 54px;
width: 100%; width: 100%;
background: none; background: none;
@ -38,7 +49,7 @@ export const EditorTextarea = styled(TextareaAutosize)`
padding: 0; padding: 0;
font-size: 14px; font-size: 14px;
line-height: 18px; line-height: 18px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
&:focus { &:focus {
border: none; border: none;
outline: none; outline: none;
@ -52,6 +63,22 @@ export const ListCardBadges = styled.div`
margin-left: -2px; margin-left: -2px;
`; `;
export const CommentsBadge = styled.div`
color: #5e6c84;
display: flex;
align-items: center;
margin: 0 6px 4px 0;
font-size: 12px;
max-width: 100%;
min-height: 20px;
overflow: hidden;
position: relative;
padding: 2px;
text-decoration: none;
text-overflow: ellipsis;
vertical-align: top;
`;
export const ListCardBadge = styled.div` export const ListCardBadge = styled.div`
color: #5e6c84; color: #5e6c84;
display: flex; display: flex;
@ -74,7 +101,7 @@ export const DescriptionBadge = styled(ListCardBadge)`
export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>` export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>`
font-size: 12px; font-size: 12px;
${props => ${(props) =>
props.isPastDue && props.isPastDue &&
css` css`
padding-left: 4px; padding-left: 4px;
@ -89,7 +116,7 @@ export const ListCardBadgeText = styled.span<{ color?: 'success' | 'normal' }>`
padding: 0 4px 0 6px; padding: 0 4px 0 6px;
vertical-align: top; vertical-align: top;
white-space: nowrap; white-space: nowrap;
${props => props.color === 'success' && `color: ${props.theme.colors.success};`} ${(props) => props.color === 'success' && `color: ${props.theme.colors.success};`}
`; `;
export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>` export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>`
@ -100,7 +127,7 @@ export const ListCardContainer = styled.div<{ isActive: boolean; editable: boole
cursor: pointer !important; cursor: pointer !important;
position: relative; position: relative;
background-color: ${props => background-color: ${(props) =>
props.isActive && !props.editable props.isActive && !props.editable
? mixin.darken(props.theme.colors.bg.secondary, 0.1) ? mixin.darken(props.theme.colors.bg.secondary, 0.1)
: `${props.theme.colors.bg.secondary}`}; : `${props.theme.colors.bg.secondary}`};
@ -117,7 +144,7 @@ export const ListCardDetails = styled.div<{ complete: boolean }>`
position: relative; position: relative;
z-index: 10; z-index: 10;
${props => props.complete && 'opacity: 0.6;'} ${(props) => props.complete && 'opacity: 0.6;'}
`; `;
const labelVariantExpandAnimation = keyframes` const labelVariantExpandAnimation = keyframes`
@ -155,7 +182,7 @@ export const ListCardLabelsWrapper = styled.div`
`; `;
export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>` export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
${props => ${(props) =>
props.variant === 'small' props.variant === 'small'
? css` ? css`
height: 8px; height: 8px;
@ -181,14 +208,14 @@ export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
color: #fff; color: #fff;
display: flex; display: flex;
position: relative; position: relative;
background-color: ${props => props.color}; background-color: ${(props) => props.color};
`; `;
export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>` export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>`
&:hover { &:hover {
opacity: 0.8; opacity: 0.8;
} }
${props => ${(props) =>
props.toggleLabels && props.toggleLabels &&
props.toggleDirection === 'expand' && props.toggleDirection === 'expand' &&
css` css`
@ -199,7 +226,7 @@ export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirectio
animation: ${labelTextVariantExpandAnimation} 0.45s ease-out; animation: ${labelTextVariantExpandAnimation} 0.45s ease-out;
} }
`} `}
${props => ${(props) =>
props.toggleLabels && props.toggleLabels &&
props.toggleDirection === 'shrink' && props.toggleDirection === 'shrink' &&
css` css`
@ -223,22 +250,23 @@ export const ListCardOperation = styled.span`
top: 2px; top: 2px;
z-index: 100; z-index: 100;
&:hover { &:hover {
background-color: ${props => mixin.darken(props.theme.colors.bg.secondary, 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; clear: both;
margin: 0 0 4px; margin: 0 0 4px;
overflow: hidden; overflow: hidden;
text-decoration: none; text-decoration: none;
color: ${(props) => props.theme.colors.text.primary};
display: block;
align-items: center;
`;
export const CardTitleText = styled.span`
word-wrap: break-word; word-wrap: break-word;
line-height: 18px; line-height: 18px;
font-size: 14px; font-size: 14px;
color: ${props => props.theme.colors.text.primary};
display: flex;
align-items: center;
`; `;
export const CardMembers = styled.div` export const CardMembers = styled.div`
@ -248,9 +276,10 @@ export const CardMembers = styled.div`
`; `;
export const CompleteIcon = styled(CheckCircle)` export const CompleteIcon = styled(CheckCircle)`
fill: ${props => props.theme.colors.success}; fill: ${(props) => props.theme.colors.success};
margin-right: 4px; margin-right: 4px;
flex-shrink: 0; flex-shrink: 0;
margin-bottom: -2px;
`; `;
export const EditorContent = styled.div` export const EditorContent = styled.div`

View File

@ -22,6 +22,9 @@ import {
ListCardOperation, ListCardOperation,
CardTitle, CardTitle,
CardMembers, CardMembers,
CardTitleText,
CommentsIcon,
CommentsBadge,
} from './Styles'; } from './Styles';
type DueDate = { type DueDate = {
@ -46,6 +49,7 @@ type Props = {
dueDate?: DueDate; dueDate?: DueDate;
checklists?: Checklist | null; checklists?: Checklist | null;
labels?: Array<ProjectLabel>; labels?: Array<ProjectLabel>;
comments?: { unread: boolean; total: number } | null;
watched?: boolean; watched?: boolean;
wrapperProps?: any; wrapperProps?: any;
members?: Array<TaskUser> | null; members?: Array<TaskUser> | null;
@ -57,18 +61,21 @@ type Props = {
onCardTitleChange?: (name: string) => void; onCardTitleChange?: (name: string) => void;
labelVariant?: CardLabelVariant; labelVariant?: CardLabelVariant;
toggleLabels?: boolean; toggleLabels?: boolean;
isPublic?: boolean;
toggleDirection?: 'shrink' | 'expand'; toggleDirection?: 'shrink' | 'expand';
}; };
const Card = React.forwardRef( const Card = React.forwardRef(
( (
{ {
isPublic = false,
wrapperProps, wrapperProps,
onContextMenu, onContextMenu,
taskID, taskID,
taskGroupID, taskGroupID,
complete, complete,
toggleLabels = false, toggleLabels = false,
comments,
toggleDirection = 'shrink', toggleDirection = 'shrink',
setToggleLabels, setToggleLabels,
onClick, onClick,
@ -119,9 +126,11 @@ const Card = React.forwardRef(
} }
}; };
const onTaskContext = (e: React.MouseEvent) => { const onTaskContext = (e: React.MouseEvent) => {
e.preventDefault(); if (!isPublic) {
e.stopPropagation(); e.preventDefault();
onOpenComposer(); e.stopPropagation();
onOpenComposer();
}
}; };
const onOperationClick = (e: React.MouseEvent<HTMLOrSVGElement>) => { const onOperationClick = (e: React.MouseEvent<HTMLOrSVGElement>) => {
e.preventDefault(); e.preventDefault();
@ -133,7 +142,7 @@ const Card = React.forwardRef(
onMouseEnter={() => setActive(true)} onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)} onMouseLeave={() => setActive(false)}
ref={$cardRef} ref={$cardRef}
onClick={e => { onClick={(e) => {
if (onClick) { if (onClick) {
onClick(e); onClick(e);
} }
@ -144,9 +153,9 @@ const Card = React.forwardRef(
{...wrapperProps} {...wrapperProps}
> >
<ListCardInnerContainer ref={$innerCardRef}> <ListCardInnerContainer ref={$innerCardRef}>
{isActive && !editable && ( {!isPublic && isActive && !editable && (
<ListCardOperation <ListCardOperation
onClick={e => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onContextMenu) { if (onContextMenu) {
onContextMenu($innerCardRef, taskID, taskGroupID); onContextMenu($innerCardRef, taskID, taskGroupID);
@ -162,7 +171,7 @@ const Card = React.forwardRef(
<ListCardLabels <ListCardLabels
toggleLabels={toggleLabels} toggleLabels={toggleLabels}
toggleDirection={toggleDirection} toggleDirection={toggleDirection}
onClick={e => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onCardLabelClick) { if (onCardLabelClick) {
onCardLabelClick(); onCardLabelClick();
@ -172,7 +181,7 @@ const Card = React.forwardRef(
{labels {labels
.slice() .slice()
.sort((a, b) => a.labelColor.position - b.labelColor.position) .sort((a, b) => a.labelColor.position - b.labelColor.position)
.map(label => ( .map((label) => (
<ListCardLabel <ListCardLabel
onAnimationEnd={() => { onAnimationEnd={() => {
if (setToggleLabels) { if (setToggleLabels) {
@ -193,13 +202,13 @@ const Card = React.forwardRef(
<EditorContent> <EditorContent>
{complete && <CompleteIcon width={16} height={16} />} {complete && <CompleteIcon width={16} height={16} />}
<EditorTextarea <EditorTextarea
onChange={e => { onChange={(e) => {
setCardTitle(e.currentTarget.value); setCardTitle(e.currentTarget.value);
if (onCardTitleChange) { if (onCardTitleChange) {
onCardTitleChange(e.currentTarget.value); onCardTitleChange(e.currentTarget.value);
} }
}} }}
onClick={e => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@ -210,13 +219,13 @@ const Card = React.forwardRef(
) : ( ) : (
<CardTitle> <CardTitle>
{complete && <CompleteIcon width={16} height={16} />} {complete && <CompleteIcon width={16} height={16} />}
{`${title}${position ? ` - ${position}` : ''}`} <CardTitleText>{`${title}${position ? ` - ${position}` : ''}`}</CardTitleText>
</CardTitle> </CardTitle>
)} )}
<ListCardBadges> <ListCardBadges>
{watched && ( {watched && (
<ListCardBadge> <ListCardBadge>
<Eye width={8} height={8} /> <Eye width={12} height={12} />
</ListCardBadge> </ListCardBadge>
)} )}
{dueDate && ( {dueDate && (
@ -230,6 +239,12 @@ const Card = React.forwardRef(
<List width={8} height={8} /> <List width={8} height={8} />
</DescriptionBadge> </DescriptionBadge>
)} )}
{comments && (
<CommentsBadge>
<CommentsIcon color={comments.unread ? 'success' : 'normal'} width={8} height={8} />
<ListCardBadgeText color={comments.unread ? 'success' : 'normal'}>{comments.total}</ListCardBadgeText>
</CommentsBadge>
)}
{checklists && ( {checklists && (
<ListCardBadge> <ListCardBadge>
<ChecklistIcon <ChecklistIcon
@ -251,7 +266,7 @@ const Card = React.forwardRef(
size={28} size={28}
zIndex={members.length - idx} zIndex={members.length - idx}
member={member} member={member}
onMemberProfile={$target => { onMemberProfile={($target) => {
if (onCardMemberClick) { if (onCardMemberClick) {
onCardMemberClick($target, taskID, member.id); onCardMemberClick($target, taskID, member.id);
} }

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,4 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown'; import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import useOnOutsideClick from 'shared/hooks/onOutsideClick'; import useOnOutsideClick from 'shared/hooks/onOutsideClick';
@ -25,6 +25,11 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
const $cardRef = useRef<HTMLDivElement>(null); const $cardRef = useRef<HTMLDivElement>(null);
useOnOutsideClick($cardRef, true, onClose, null); useOnOutsideClick($cardRef, true, onClose, null);
useOnEscapeKeyDown(isOpen, onClose); useOnEscapeKeyDown(isOpen, onClose);
useEffect(() => {
if ($cardRef.current) {
$cardRef.current.scrollIntoView();
}
});
return ( return (
<CardComposerWrapper isOpen={isOpen} ref={$cardRef}> <CardComposerWrapper isOpen={isOpen} ref={$cardRef}>
<Card <Card
@ -33,8 +38,10 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
taskGroupID="" taskGroupID=""
editable editable
onEditCard={(_taskGroupID, _taskID, name) => { onEditCard={(_taskGroupID, _taskID, name) => {
onCreateCard(name); if (cardName.trim() !== '') {
setCardName(''); onCreateCard(name.trim());
setCardName('');
}
}} }}
onCardTitleChange={name => { onCardTitleChange={name => {
setCardName(name); setCardName(name);
@ -45,8 +52,10 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
<AddCardButton <AddCardButton
variant="relief" variant="relief"
onClick={() => { onClick={() => {
onCreateCard(cardName); if (cardName.trim() !== '') {
setCardName(''); onCreateCard(cardName.trim());
setCardName('');
}
}} }}
> >
Add Card Add Card

View File

@ -1,155 +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 NOOP from 'shared/utils/noop';
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 ${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 => (
<ChecklistItem
key={item.id}
wrapperProps={{}}
handleProps={{}}
checklistID="id"
itemID={item.id}
name={item.name}
complete={item.complete}
onDeleteItem={NOOP}
onChangeName={NOOP}
onToggleItem={NOOP}
/>
))}
</Checklist>
</Container>
</ThemeProvider>
</>
);
};

View File

@ -21,14 +21,7 @@ import {
SubTitle, SubTitle,
} from './Styles'; } from './Styles';
const Confirm = ({ onConfirmUser, hasConfirmToken }: ConfirmProps) => { const Confirm = ({ hasFailed, hasConfirmToken }: ConfirmProps) => {
const [hasFailed, setFailed] = useState(false);
const setHasFailed = () => {
setFailed(true);
};
useEffect(() => {
onConfirmUser(setHasFailed);
});
return ( return (
<Wrapper> <Wrapper>
<Column> <Column>

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 { User } from 'shared/icons';
import Input from '.';
export default {
component: Input,
title: 'Input',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const Wrapper = styled.div`
background: ${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 width={20} height={20} />} width="100%" placeholder="Placeholder" />
</Wrapper>
</ThemeProvider>
</>
);
};

View File

@ -16,7 +16,7 @@ const InputWrapper = styled.div<{ width: string }>`
`; `;
const InputLabel = styled.span<{ width: string }>` const InputLabel = styled.span<{ width: string }>`
width: ${props => props.width}; width: ${(props) => props.width};
padding: 0.7rem !important; padding: 0.7rem !important;
color: #c2c6dc; color: #c2c6dc;
left: 0; left: 0;
@ -40,13 +40,13 @@ const InputInput = styled.input<{
focusBg: string; focusBg: string;
borderColor: string; borderColor: string;
}>` }>`
width: ${props => props.width}; width: ${(props) => props.width};
font-size: 14px; font-size: 14px;
border: 1px solid rgba(0, 0, 0, 0.2); border: 1px solid rgba(0, 0, 0, 0.2);
border-color: ${props => props.borderColor}; border-color: ${(props) => props.borderColor};
background: #262c49; background: #262c49;
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15); 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; line-height: 16px;
color: #c2c6dc; color: #c2c6dc;
position: relative; position: relative;
@ -55,13 +55,13 @@ const InputInput = styled.input<{
&:focus { &:focus {
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15); box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
border: 1px solid rgba(115, 103, 240); border: 1px solid rgba(115, 103, 240);
background: ${props => props.focusBg}; background: ${(props) => props.focusBg};
} }
&:focus ~ ${InputLabel} { &:focus ~ ${InputLabel} {
color: ${props => props.theme.colors.primary}; color: ${(props) => props.theme.colors.primary};
transform: translate(-3px, -90%); transform: translate(-3px, -90%);
} }
${props => ${(props) =>
props.hasValue && props.hasValue &&
css` css`
& ~ ${InputLabel} { & ~ ${InputLabel} {
@ -94,11 +94,13 @@ type ControlledInputProps = {
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void; onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
value?: string; value?: string;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void; onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
disabled?: boolean;
}; };
const ControlledInput = ({ const ControlledInput = ({
width = 'auto', width = 'auto',
variant = 'normal', variant = 'normal',
disabled = false,
type = 'text', type = 'text',
autocomplete, autocomplete,
autoFocus = false, autoFocus = false,
@ -126,8 +128,9 @@ const ControlledInput = ({
return ( return (
<InputWrapper className={className} width={width}> <InputWrapper className={className} width={width}>
<InputInput <InputInput
disabled={disabled}
hasValue={hasValue} hasValue={hasValue}
onChange={e => { onChange={(e) => {
if (onChange) { if (onChange) {
setHasValue(e.currentTarget.value !== '' || floatingLabel); setHasValue(e.currentTarget.value !== '' || floatingLabel);
onChange(e); onChange(e);

View File

@ -1,65 +0,0 @@
import React, { createRef, useState } from 'react';
import styled from 'styled-components';
import { action } from '@storybook/addon-actions';
import DropdownMenu from '.';
import theme from '../../../App/ThemeStyles';
export default {
component: DropdownMenu,
title: 'DropdownMenu',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#f8f8f8' },
{ name: 'darkBlue', value: theme.colors.bg.secondary, 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

@ -1,74 +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 { 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

@ -1,7 +1,8 @@
import styled from 'styled-components'; import styled, { css } from 'styled-components';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
import Input from 'shared/components/Input'; import ControlledInput from 'shared/components/ControlledInput';
import { Bell, Clock } from 'shared/icons';
export const Wrapper = styled.div` export const Wrapper = styled.div`
display: flex display: flex
@ -17,25 +18,30 @@ display: flex
z-index: 10000; z-index: 10000;
margin-top: 0; margin-top: 0;
} }
& .react-datepicker__close-icon::after {
background: none;
font-size: 16px;
color: ${(props) => props.theme.colors.text.primary};
}
& .react-datepicker-time__header { & .react-datepicker-time__header {
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
} }
& .react-datepicker__time-list-item { & .react-datepicker__time-list-item {
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
} }
& .react-datepicker__time-container .react-datepicker__time & .react-datepicker__time-container .react-datepicker__time
.react-datepicker__time-box ul.react-datepicker__time-list .react-datepicker__time-box ul.react-datepicker__time-list
li.react-datepicker__time-list-item:hover { li.react-datepicker__time-list-item:hover {
color: ${props => props.theme.colors.text.secondary}; color: ${(props) => props.theme.colors.text.secondary};
background: ${props => props.theme.colors.bg.secondary}; background: ${(props) => props.theme.colors.bg.secondary};
} }
& .react-datepicker__time-container .react-datepicker__time { & .react-datepicker__time-container .react-datepicker__time {
background: ${props => props.theme.colors.bg.primary}; background: ${(props) => props.theme.colors.bg.primary};
} }
& .react-datepicker--time-only { & .react-datepicker--time-only {
background: ${props => props.theme.colors.bg.primary}; background: ${(props) => props.theme.colors.bg.primary};
border: 1px solid ${props => props.theme.colors.border}; border: 1px solid ${(props) => props.theme.colors.border};
} }
& .react-datepicker * { & .react-datepicker * {
@ -75,12 +81,12 @@ display: flex
} }
& .react-datepicker__day--selected { & .react-datepicker__day--selected {
border-radius: 50%; border-radius: 50%;
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
color: #fff; color: #fff;
} }
& .react-datepicker__day--selected:hover { & .react-datepicker__day--selected:hover {
border-radius: 50%; border-radius: 50%;
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
color: #fff; color: #fff;
} }
& .react-datepicker__header { & .react-datepicker__header {
@ -88,9 +94,27 @@ display: flex
border: none; border: none;
} }
& .react-datepicker__header--time { & .react-datepicker__header--time {
border-bottom: 1px solid ${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` export const DueDatePickerWrapper = styled.div`
@ -110,6 +134,44 @@ export const RemoveDueDate = styled(Button)`
margin: 0 0 0 4px; 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` export const CancelDueDate = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
@ -119,15 +181,168 @@ export const CancelDueDate = styled.div`
cursor: pointer; cursor: pointer;
`; `;
export const DueDateInput = styled(Input)` export const DueDateInput = styled(ControlledInput)`
margin-top: 15px; margin-top: 15px;
margin-bottom: 5px; margin-bottom: 5px;
padding-right: 10px; padding-right: 10px;
`; `;
export const ActionWrapper = styled.div` export const ActionsSeparator = styled.div`
padding-top: 8px; margin-top: 8px;
height: 1px;
width: 100%; width: 100%;
background: #414561;
display: flex; 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: 86px;
}
& .react-datepicker__input-container input {
padding-bottom: 4px;
padding-top: 4px;
width: 100%;
}
& .react-period-select__indicators {
display: none;
}
& .react-period {
width: 100%;
max-width: 86px;
}
& .react-period-select__single-value {
color: #c2c6dc;
margin-left: 0;
margin-right: 0;
}
& .react-period-select__value-container {
padding-left: 0;
padding-right: 0;
}
& .react-period-select__control {
border: 1px solid rgba(0, 0, 0, 0.2);
min-height: 30px;
border-color: rgb(65, 69, 97);
background: #262c49;
box-shadow: 0 0 0 0 rgb(0 0 0 / 15%);
color: #c2c6dc;
padding-right: 12px;
padding-left: 12px;
padding-bottom: 4px;
padding-top: 4px;
width: 100%;
position: relative;
border-radius: 5px;
transition: all 0.3s ease;
font-size: 13px;
line-height: 20px;
padding: 0 12px;
}
`;
export const ActionClock = styled(Clock)`
align-self: center;
fill: ${(props) => props.theme.colors.primary};
margin: 0 8px;
flex: 0 0 auto;
`;
export const ActionBell = styled(Bell)`
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<{ disabled?: boolean }>`
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};
}
${(props) =>
props.disabled &&
css`
opacity: 0.8;
cursor: not-allowed;
`}
align-items: center;
display: inline-flex;
justify-content: center;
position: relative;
`;
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};
}
`;
export const ControlWrapper = styled.div`
display: flex;
align-items: center;
margin-top: 8px;
`;
export const RightWrapper = styled.div`
flex: 1 1 50%;
display: flex;
align-items: center;
flex-direction: row-reverse;
`;
export const LeftWrapper = styled.div`
flex: 1 1 50%;
display: flex;
align-items: center;
`;
export const SaveButton = styled(Button)`
padding: 6px 12px;
justify-content: center;
margin-right: 4px;
`;
export const RemoveButton = styled.div`
width: 100%;
justify-content: center;
`; `;

View File

@ -1,18 +1,47 @@
import React, { useState, useEffect, forwardRef } from 'react'; import React, { useState, useEffect, forwardRef, useRef, useCallback } from 'react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import styled from 'styled-components'; import styled from 'styled-components';
import DatePicker from 'react-datepicker'; import DatePicker from 'react-datepicker';
import _ from 'lodash'; import _ from 'lodash';
import { colourStyles } from 'shared/components/Select';
import produce from 'immer';
import Select from 'react-select';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { getYear, getMonth } from 'date-fns'; import { getYear, getMonth } from 'date-fns';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import { Bell, Clock, Cross, Plus, Trash } from 'shared/icons';
import { Wrapper, ActionWrapper, RemoveDueDate, DueDateInput, DueDatePickerWrapper, ConfirmAddDueDate } from './Styles'; import {
Wrapper,
RemoveDueDate,
SaveButton,
RightWrapper,
LeftWrapper,
DueDateInput,
DueDatePickerWrapper,
ConfirmAddDueDate,
DateRangeInputs,
AddDateRange,
ActionIcon,
ActionsWrapper,
ClearButton,
ActionsSeparator,
ActionClock,
ActionLabel,
ControlWrapper,
RemoveButton,
ActionBell,
} from './Styles';
type DueDateManagerProps = { type DueDateManagerProps = {
task: Task; task: Task;
onDueDateChange: (task: Task, newDueDate: Date) => void; onDueDateChange: (
task: Task,
newDueDate: Date,
hasTime: boolean,
notifications: { current: Array<NotificationInternal>; removed: Array<string> },
) => void;
onRemoveDueDate: (task: Task) => void; onRemoveDueDate: (task: Task) => void;
onCancel: () => void; onCancel: () => void;
}; };
@ -25,6 +54,39 @@ const FormField = styled.div`
width: 50%; width: 50%;
display: inline-block; display: inline-block;
`; `;
const NotificationCount = styled.input``;
const ActionPlus = styled(Plus)`
position: absolute;
fill: ${(props) => props.theme.colors.bg.primary} !important;
stroke: ${(props) => props.theme.colors.bg.primary};
`;
const ActionInput = styled.input`
border: 1px solid rgba(0, 0, 0, 0.2);
margin-left: auto;
margin-right: 4px;
border-color: rgb(65, 69, 97);
background: #262c49;
box-shadow: 0 0 0 0 rgb(0 0 0 / 15%);
color: #c2c6dc;
position: relative;
border-radius: 5px;
transition: all 0.3s ease;
font-size: 13px;
line-height: 20px;
padding: 0 12px;
padding-bottom: 4px;
padding-top: 4px;
width: 100%;
max-width: 48px;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
`;
const HeaderSelectLabel = styled.div` const HeaderSelectLabel = styled.div`
display: inline-block; display: inline-block;
position: relative; position: relative;
@ -43,7 +105,7 @@ const HeaderSelectLabel = styled.div`
color: #c2c6dc; color: #c2c6dc;
&:hover { &:hover {
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
color: #c2c6dc; color: #c2c6dc;
} }
`; `;
@ -52,16 +114,22 @@ const HeaderSelect = styled.select`
text-decoration: underline; text-decoration: underline;
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
padding: 4px 6px;
background: none; background: none;
outline: none; outline: none;
border: none; border: none;
border-radius: 3px; border-radius: 3px;
appearance: none; appearance: none;
width: 100%;
display: inline-block;
&:hover { & option {
background: ${props => props.theme.colors.bg.secondary}; color: #c2c6dc;
border: 1px solid ${props => props.theme.colors.primary}; 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; outline: none !important;
box-shadow: none; box-shadow: none;
color: #c2c6dc; color: #c2c6dc;
@ -93,7 +161,7 @@ const HeaderButton = styled.button`
border: none; border: none;
border-radius: 3px; border-radius: 3px;
&:hover { &:hover {
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
color: #fff; color: #fff;
} }
`; `;
@ -109,15 +177,81 @@ const HeaderActions = styled.div`
} }
`; `;
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => { const notificationPeriodOptions = [
const now = dayjs(); { value: 'minute', label: 'Minutes' },
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>(); { value: 'hour', label: 'Hours' },
const [startDate, setStartDate] = useState(new Date()); { value: 'day', label: 'Days' },
{ value: 'week', label: 'Weeks' },
];
useEffect(() => { type NotificationInternal = {
const newDate = dayjs(startDate).format('YYYY-MM-DD'); internalId: string;
setValue('endDate', newDate); externalId: string | null;
}, [startDate]); period: number;
duration: { value: string; label: string };
};
type NotificationEntryProps = {
notification: NotificationInternal;
onChange: (period: number, duration: { value: string; label: string }) => void;
onRemove: () => void;
};
const NotificationEntry: React.FC<NotificationEntryProps> = ({ notification, onChange, onRemove }) => {
return (
<>
<ActionBell width={16} height={16} />
<ActionLabel>Notification</ActionLabel>
<ActionInput
value={notification.period}
onChange={(e) => {
onChange(parseInt(e.currentTarget.value, 10), notification.duration);
}}
onKeyPress={(e) => {
const isNumber = /^[0-9]$/i.test(e.key);
if (!isNumber && e.key !== 'Backspace') {
e.preventDefault();
}
}}
dir="ltr"
autoComplete="off"
min="0"
type="number"
/>
<Select
menuPlacement="top"
className="react-period"
classNamePrefix="react-period-select"
styles={colourStyles}
isSearchable={false}
defaultValue={notification.duration}
options={notificationPeriodOptions}
onChange={(e) => {
if (e !== null) {
onChange(notification.period, e);
}
}}
/>
<ActionIcon onClick={() => onRemove()}>
<Cross width={16} height={16} />
</ActionIcon>
</>
);
};
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
const currentDueDate = task.dueDate.at ? dayjs(task.dueDate.at).toDate() : null;
const {
register,
handleSubmit,
setValue,
setError,
formState: { errors },
control,
} = useForm<DueDateFormData>();
const [startDate, setStartDate] = useState<Date | null>(currentDueDate);
const [endDate, setEndDate] = useState<Date | null>(currentDueDate);
const [hasTime, enableTime] = useState(task.hasTime ?? false);
const years = _.range(2010, getYear(new Date()) + 10, 1); const years = _.range(2010, getYear(new Date()) + 10, 1);
const months = [ const months = [
@ -134,134 +268,216 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
'November', 'November',
'December', 'December',
]; ];
const saveDueDate = (data: any) => {
const newDate = dayjs(`${data.endDate} ${dayjs(data.endTime).format('h:mm A')}`, 'YYYY-MM-DD h:mm A'); const [isRange, setIsRange] = useState(false);
if (newDate.isValid()) { const [notDuration, setNotDuration] = useState(10);
onDueDateChange(task, newDate.toDate()); const [removedNotifications, setRemovedNotifications] = useState<Array<string>>([]);
} const [notifications, setNotifications] = useState<Array<NotificationInternal>>(
}; task.dueDate.notifications
const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => { ? task.dueDate.notifications.map((c, idx) => {
return ( const duration =
<DueDateInput notificationPeriodOptions.find((o) => o.value === c.duration.toLowerCase()) ?? notificationPeriodOptions[0];
id="endTime" return {
value={value} internalId: `n${idx}`,
name="endTime" externalId: c.id,
ref={$ref} period: c.period,
width="100%" duration,
variant="alternate" };
label="Time" })
onClick={onClick} : [],
/> );
);
});
return ( return (
<Wrapper> <Wrapper>
<Form onSubmit={handleSubmit(saveDueDate)}> <DateRangeInputs>
<FormField> <DatePicker
<DueDateInput selected={startDate}
id="endDate" onChange={(date) => {
name="endDate" if (!Array.isArray(date) && date !== null) {
width="100%" setStartDate(date);
variant="alternate" }
label="Date" }}
defaultValue={now.format('YYYY-MM-DD')} popperClassName="picker-hidden"
ref={register({ dateFormat="yyyy-MM-dd"
required: 'End date is required.', disabledKeyboardNavigation
})} placeholderText="Select due date"
/> />
</FormField> {isRange ? (
<FormField>
<Controller
control={control}
defaultValue={now.toDate()}
name="endTime"
render={({ onChange, onBlur, value }) => (
<DatePicker
onChange={onChange}
selected={value}
onBlur={onBlur}
showTimeSelect
showTimeSelectOnly
timeIntervals={15}
timeCaption="Time"
dateFormat="h:mm aa"
customInput={<CustomTimeInput />}
/>
)}
/>
</FormField>
<DueDatePickerWrapper>
<DatePicker <DatePicker
useWeekdaysShort
renderCustomHeader={({
date,
changeYear,
changeMonth,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<HeaderActions>
<HeaderButton onClick={decreaseMonth} disabled={prevMonthButtonDisabled}>
Prev
</HeaderButton>
<HeaderSelectLabel>
{months[date.getMonth()]}
<HeaderSelect
value={getYear(date)}
onChange={({ target: { value } }) => changeYear(parseInt(value, 10))}
>
{years.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</HeaderSelect>
</HeaderSelectLabel>
<HeaderSelectLabel>
{date.getFullYear()}
<HeaderSelect
value={months[getMonth(date)]}
onChange={({ target: { value } }) => changeMonth(months.indexOf(value))}
>
{months.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</HeaderSelect>
</HeaderSelectLabel>
<HeaderButton onClick={increaseMonth} disabled={nextMonthButtonDisabled}>
Next
</HeaderButton>
</HeaderActions>
)}
selected={startDate} selected={startDate}
inline onChange={(date) => {
onChange={date => { if (!Array.isArray(date)) {
if (date) {
setStartDate(date); setStartDate(date);
} }
}} }}
popperClassName="picker-hidden"
dateFormat="yyyy-MM-dd"
placeholderText="Select from date"
/> />
</DueDatePickerWrapper> ) : (
<ActionWrapper> <AddDateRange>Add date range</AddDateRange>
<ConfirmAddDueDate type="submit" onClick={NOOP}> )}
Save </DateRangeInputs>
</ConfirmAddDueDate> <DatePicker
<RemoveDueDate selected={startDate}
variant="outline" onChange={(date) => {
color="danger" if (!Array.isArray(date)) {
setStartDate(date);
}
}}
startDate={startDate}
useWeekdaysShort
renderCustomHeader={({
date,
changeYear,
changeMonth,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<HeaderActions>
<HeaderButton onClick={decreaseMonth} disabled={prevMonthButtonDisabled}>
Prev
</HeaderButton>
<HeaderSelectLabel>
{months[date.getMonth()]}
<HeaderSelect
value={months[getMonth(date)]}
onChange={({ target: { value } }) => changeMonth(months.indexOf(value))}
>
{months.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</HeaderSelect>
</HeaderSelectLabel>
<HeaderSelectLabel>
{date.getFullYear()}
<HeaderSelect value={getYear(date)} onChange={({ target: { value } }) => changeYear(parseInt(value, 10))}>
{years.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</HeaderSelect>
</HeaderSelectLabel>
<HeaderButton onClick={increaseMonth} disabled={nextMonthButtonDisabled}>
Next
</HeaderButton>
</HeaderActions>
)}
inline
/>
<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>
)}
{notifications.map((n, idx) => (
<ActionsWrapper key={n.internalId}>
<NotificationEntry
notification={n}
onChange={(period, duration) => {
setNotifications((prev) =>
produce(prev, (draft) => {
draft[idx].duration = duration;
draft[idx].period = period;
}),
);
}}
onRemove={() => {
setNotifications((prev) =>
produce(prev, (draft) => {
draft.splice(idx, 1);
if (n.externalId !== null) {
setRemovedNotifications((prev) => {
if (n.externalId !== null) {
return [...prev, n.externalId];
}
return prev;
});
}
}),
);
}}
/>
</ActionsWrapper>
))}
<ControlWrapper>
<LeftWrapper>
<SaveButton
onClick={() => { onClick={() => {
onRemoveDueDate(task); if (startDate && notifications.findIndex((n) => Number.isNaN(n.period)) === -1) {
onDueDateChange(task, startDate, hasTime, { current: notifications, removed: removedNotifications });
}
}} }}
> >
Remove Save
</RemoveDueDate> </SaveButton>
</ActionWrapper> {currentDueDate !== null && (
</Form> <ActionIcon
onClick={() => {
onRemoveDueDate(task);
}}
>
<Trash width={16} height={16} />
</ActionIcon>
)}
</LeftWrapper>
<RightWrapper>
<ActionIcon
disabled={notifications.length === 3}
onClick={() => {
setNotifications((prev) => [
...prev,
{
externalId: null,
internalId: `n${prev.length + 1}`,
duration: notificationPeriodOptions[0],
period: 10,
},
]);
}}
>
<Bell width={16} height={16} />
<ActionPlus width={8} height={8} />
</ActionIcon>
{!hasTime && (
<ActionIcon
onClick={() => {
if (startDate === null) {
const today = new Date();
today.setHours(12, 30, 0);
setStartDate(today);
}
enableTime(true);
}}
>
<Clock width={16} height={16} />
</ActionIcon>
)}
</RightWrapper>
</ControlWrapper>
</Wrapper> </Wrapper>
); );
}; };

View File

@ -0,0 +1,202 @@
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;
width: ${(props) => props.width};
display: flex;
align-items: flex-start;
flex-direction: column;
position: relative;
justify-content: center;
margin-bottom: 2.2rem;
margin-top: 24px;
`;
const InputLabel = styled.span<{ width: string }>`
width: ${(props) => props.width};
padding: 0.7rem !important;
color: #c2c6dc;
left: 0;
top: 0;
transition: all 0.2s ease;
position: absolute;
border-radius: 5px;
overflow: hidden;
font-size: 0.85rem;
cursor: text;
font-size: 12px;
user-select: none;
pointer-events: none;
}
`;
const InputInput = styled.input<{
hasValue: boolean;
hasIcon: boolean;
width: string;
focusBg: string;
borderColor: string;
}>`
width: ${(props) => props.width};
font-size: 14px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-color: ${(props) => props.borderColor};
background: #262c49;
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
${(props) => (props.hasIcon ? 'padding: 0.7rem 1rem 0.7rem 3rem;' : 'padding: 0.7rem;')}
&:-webkit-autofill, &:-webkit-autofill:hover, &:-webkit-autofill:focus, &:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 30px #262c49 inset !important;
}
&:-webkit-autofill {
-webkit-text-fill-color: #c2c6dc !important;
}
line-height: 16px;
color: #c2c6dc;
position: relative;
border-radius: 5px;
transition: all 0.3s ease;
&:focus {
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
border: 1px solid ${(props) => props.theme.colors.primary};
background: ${(props) => props.focusBg};
}
&:focus ~ ${InputLabel} {
color: ${(props) => props.theme.colors.primary};
transform: translate(-3px, -90%);
}
${(props) =>
props.hasValue &&
css`
& ~ ${InputLabel} {
color: ${props.theme.colors.primary};
transform: translate(-3px, -90%);
}
`}
`;
const Icon = styled.div`
display: flex;
left: 16px;
position: absolute;
`;
type FormInputProps = {
variant?: 'normal' | 'alternate';
disabled?: boolean;
label?: string;
width?: string;
floatingLabel?: boolean;
placeholder?: string;
icon?: JSX.Element;
type?: string;
autocomplete?: boolean;
autoFocus?: boolean;
autoSelect?: boolean;
id?: string;
name?: string;
onChange: any;
onBlur: any;
className?: string;
defaultValue?: string;
value?: string;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
};
function useCombinedRefs(...refs: any) {
const targetRef = React.useRef();
React.useEffect(() => {
refs.forEach((ref: any) => {
if (!ref) return;
if (typeof ref === 'function') {
ref(targetRef.current);
} else {
ref.current = targetRef.current;
}
});
}, [refs]);
return targetRef;
}
const FormInput = React.forwardRef(
(
{
disabled = false,
width = 'auto',
variant = 'normal',
type = 'text',
autoFocus = false,
autoSelect = false,
autocomplete,
label,
placeholder,
onBlur,
onChange,
icon,
name,
className,
onClick,
floatingLabel,
defaultValue,
value,
id,
}: FormInputProps,
$ref: any,
) => {
const [hasValue, setHasValue] = useState(defaultValue !== '');
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
// TODO(jordanknott): This is super ugly, find a better approach?
const $innerRef = React.useRef<HTMLInputElement>(null);
const combinedRef: any = useCombinedRefs($ref, $innerRef);
useEffect(() => {
if (combinedRef && combinedRef.current) {
if (autoFocus) {
combinedRef.current.focus();
}
if (autoSelect) {
combinedRef.current.select();
}
}
}, []);
return (
<InputWrapper className={className} width={width}>
<InputInput
onChange={(e) => {
setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false);
onChange(e);
}}
disabled={disabled}
hasValue={hasValue}
ref={combinedRef}
id={id}
type={type}
name={name}
onClick={onClick}
autoComplete={autocomplete ? 'on' : 'off'}
defaultValue={defaultValue}
onBlur={onBlur}
value={value}
hasIcon={typeof icon !== 'undefined'}
width={width}
placeholder={placeholder}
focusBg={focusBg}
borderColor={borderColor}
/>
{label && <InputLabel width={width}>{label}</InputLabel>}
<Icon>{icon && icon}</Icon>
</InputWrapper>
);
},
);
export default FormInput;

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 { User } from 'shared/icons';
import Input from '.';
export default {
component: Input,
title: 'Input',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const Wrapper = styled.div`
background: ${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 width={20} height={20} />} width="100%" placeholder="Placeholder" />
</Wrapper>
</ThemeProvider>
</>
);
};

View File

@ -1,146 +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 NOOP from 'shared/utils/noop';
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={NOOP} onCreateCard={NOOP} 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={NOOP} onCreateCard={NOOP} 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={NOOP} onCreateCard={NOOP} 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={NOOP} onCreateCard={NOOP} isOpen />
</ListCards>
</List>
);
};

View File

@ -72,6 +72,9 @@ export const HeaderName = styled(TextareaAutosize)`
box-shadow: none; box-shadow: none;
font-weight: 600; font-weight: 600;
margin: -4px 0; margin: -4px 0;
&:disabled {
opacity: 1;
}
letter-spacing: normal; letter-spacing: normal;
word-spacing: normal; word-spacing: normal;

View File

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

View File

@ -1,104 +0,0 @@
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import theme from 'App/ThemeStyles';
import Lists from '.';
export default {
component: Lists,
title: 'Lists',
parameters: {
backgrounds: [
{ name: 'gray', value: theme.colors.bg.secondary, 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,11 +1,8 @@
import styled from 'styled-components'; import styled from 'styled-components';
export const Container = styled.div` export const Container = styled.div`
flex: 1;
user-select: none; user-select: none;
white-space: nowrap; white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
::-webkit-scrollbar { ::-webkit-scrollbar {
height: 10px; height: 10px;

View File

@ -4,6 +4,7 @@ import List, { ListCards } from 'shared/components/List';
import Card from 'shared/components/Card'; import Card from 'shared/components/Card';
import CardComposer from 'shared/components/CardComposer'; import CardComposer from 'shared/components/CardComposer';
import AddList from 'shared/components/AddList'; import AddList from 'shared/components/AddList';
import log from 'loglevel';
import { import {
isPositionChanged, isPositionChanged,
getSortedDraggables, getSortedDraggables,
@ -111,24 +112,16 @@ function shouldStatusFilter(task: Task, filter: TaskStatusFilter) {
const TODAY = REFERENCE.clone().startOf('day'); const TODAY = REFERENCE.clone().startOf('day');
return completedAt.isSame(TODAY, 'd'); return completedAt.isSame(TODAY, 'd');
case TaskSince.YESTERDAY: case TaskSince.YESTERDAY:
const YESTERDAY = REFERENCE.clone() const YESTERDAY = REFERENCE.clone().subtract(1, 'day').startOf('day');
.subtract(1, 'day')
.startOf('day');
return completedAt.isSameOrAfter(YESTERDAY, 'd'); return completedAt.isSameOrAfter(YESTERDAY, 'd');
case TaskSince.ONE_WEEK: case TaskSince.ONE_WEEK:
const ONE_WEEK = REFERENCE.clone() const ONE_WEEK = REFERENCE.clone().subtract(7, 'day').startOf('day');
.subtract(7, 'day')
.startOf('day');
return completedAt.isSameOrAfter(ONE_WEEK, 'd'); return completedAt.isSameOrAfter(ONE_WEEK, 'd');
case TaskSince.TWO_WEEKS: case TaskSince.TWO_WEEKS:
const TWO_WEEKS = REFERENCE.clone() const TWO_WEEKS = REFERENCE.clone().subtract(14, 'day').startOf('day');
.subtract(14, 'day')
.startOf('day');
return completedAt.isSameOrAfter(TWO_WEEKS, 'd'); return completedAt.isSameOrAfter(TWO_WEEKS, 'd');
case TaskSince.THREE_WEEKS: case TaskSince.THREE_WEEKS:
const THREE_WEEKS = REFERENCE.clone() const THREE_WEEKS = REFERENCE.clone().subtract(21, 'day').startOf('day');
.subtract(21, 'day')
.startOf('day');
return completedAt.isSameOrAfter(THREE_WEEKS, 'd'); return completedAt.isSameOrAfter(THREE_WEEKS, 'd');
default: default:
return true; return true;
@ -151,6 +144,7 @@ interface SimpleProps {
onCardMemberClick: OnCardMemberClick; onCardMemberClick: OnCardMemberClick;
onCardLabelClick: () => void; onCardLabelClick: () => void;
cardLabelVariant: CardLabelVariant; cardLabelVariant: CardLabelVariant;
isPublic?: boolean;
taskStatusFilter?: TaskStatusFilter; taskStatusFilter?: TaskStatusFilter;
taskMetaFilters?: TaskMetaFilters; taskMetaFilters?: TaskMetaFilters;
taskSorting?: TaskSorting; taskSorting?: TaskSorting;
@ -188,6 +182,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
onExtraMenuOpen, onExtraMenuOpen,
onCardMemberClick, onCardMemberClick,
taskStatusFilter = initTaskStatusFilter, taskStatusFilter = initTaskStatusFilter,
isPublic = false,
taskMetaFilters = initTaskMetaFilters, taskMetaFilters = initTaskMetaFilters,
taskSorting = initTaskSorting, taskSorting = initTaskSorting,
}) => { }) => {
@ -201,14 +196,14 @@ const SimpleLists: React.FC<SimpleProps> = ({
let beforeDropDraggables: Array<DraggableElement> | null = null; let beforeDropDraggables: Array<DraggableElement> | null = null;
if (isList) { if (isList) {
const droppedGroup = taskGroups.find(taskGroup => taskGroup.id === draggableId); const droppedGroup = taskGroups.find((taskGroup) => taskGroup.id === draggableId);
if (droppedGroup) { if (droppedGroup) {
droppedDraggable = { droppedDraggable = {
id: draggableId, id: draggableId,
position: droppedGroup.position, position: droppedGroup.position,
}; };
beforeDropDraggables = getSortedDraggables( beforeDropDraggables = getSortedDraggables(
taskGroups.map(taskGroup => { taskGroups.map((taskGroup) => {
return { id: taskGroup.id, position: taskGroup.position }; return { id: taskGroup.id, position: taskGroup.position };
}), }),
); );
@ -232,13 +227,13 @@ const SimpleLists: React.FC<SimpleProps> = ({
} }
} else { } else {
const curTaskGroup = taskGroups.findIndex( const curTaskGroup = taskGroups.findIndex(
taskGroup => taskGroup.tasks.findIndex(task => task.id === draggableId) !== -1, (taskGroup) => taskGroup.tasks.findIndex((task) => task.id === draggableId) !== -1,
); );
let targetTaskGroup = curTaskGroup; let targetTaskGroup = curTaskGroup;
if (!isSameList) { if (!isSameList) {
targetTaskGroup = taskGroups.findIndex(taskGroup => taskGroup.id === destination.droppableId); targetTaskGroup = taskGroups.findIndex((taskGroup) => taskGroup.id === destination.droppableId);
} }
const droppedTask = taskGroups[curTaskGroup].tasks.find(task => task.id === draggableId); const droppedTask = taskGroups[curTaskGroup].tasks.find((task) => task.id === draggableId);
if (droppedTask) { if (droppedTask) {
droppedDraggable = { droppedDraggable = {
@ -246,7 +241,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
position: droppedTask.position, position: droppedTask.position,
}; };
beforeDropDraggables = getSortedDraggables( beforeDropDraggables = getSortedDraggables(
taskGroups[targetTaskGroup].tasks.map(task => { taskGroups[targetTaskGroup].tasks.map((task) => {
return { id: task.id, position: task.position }; return { id: task.id, position: task.position };
}), }),
); );
@ -268,6 +263,9 @@ const SimpleLists: React.FC<SimpleProps> = ({
id: destination.droppableId, id: destination.droppableId,
}, },
}; };
log.debug(
`action=move taskId=${droppedTask.id} source=${source.droppableId} dest=${destination.droppableId} oldPos=${droppedTask.position} newPos=${newPosition}`,
);
onTaskDrop(newTask, droppedTask.taskGroup.id); onTaskDrop(newTask, droppedTask.taskGroup.id);
} }
} }
@ -284,7 +282,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
<BoardWrapper> <BoardWrapper>
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<Droppable direction="horizontal" type="column" droppableId="root"> <Droppable direction="horizontal" type="column" droppableId="root">
{provided => ( {(provided) => (
<Container {...provided.droppableProps} ref={provided.innerRef}> <Container {...provided.droppableProps} ref={provided.innerRef}>
{taskGroups {taskGroups
.slice() .slice()
@ -292,14 +290,15 @@ const SimpleLists: React.FC<SimpleProps> = ({
.map((taskGroup: TaskGroup, index: number) => { .map((taskGroup: TaskGroup, index: number) => {
return ( return (
<Draggable draggableId={taskGroup.id} key={taskGroup.id} index={index}> <Draggable draggableId={taskGroup.id} key={taskGroup.id} index={index}>
{columnDragProvided => ( {(columnDragProvided) => (
<Droppable type="tasks" droppableId={taskGroup.id}> <Droppable type="tasks" droppableId={taskGroup.id}>
{(columnDropProvided, snapshot) => ( {(columnDropProvided, snapshot) => (
<List <List
name={taskGroup.name} name={taskGroup.name}
onOpenComposer={id => setCurrentComposer(id)} onOpenComposer={(id) => setCurrentComposer(id)}
isComposerOpen={currentComposer === taskGroup.id} isComposerOpen={currentComposer === taskGroup.id}
onSaveName={name => onChangeTaskGroupName(taskGroup.id, name)} onSaveName={(name) => onChangeTaskGroupName(taskGroup.id, name)}
isPublic={isPublic}
ref={columnDragProvided.innerRef} ref={columnDragProvided.innerRef}
wrapperProps={columnDragProvided.draggableProps} wrapperProps={columnDragProvided.draggableProps}
headerProps={columnDragProvided.dragHandleProps} headerProps={columnDragProvided.dragHandleProps}
@ -311,8 +310,8 @@ const SimpleLists: React.FC<SimpleProps> = ({
<ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}> <ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}>
{taskGroup.tasks {taskGroup.tasks
.slice() .slice()
.filter(t => shouldStatusFilter(t, taskStatusFilter)) .filter((t) => shouldStatusFilter(t, taskStatusFilter))
.filter(t => shouldMetaFilter(t, taskMetaFilters)) .filter((t) => shouldMetaFilter(t, taskMetaFilters))
.sort((a: any, b: any) => a.position - b.position) .sort((a: any, b: any) => a.position - b.position)
.sort((a: any, b: any) => sortTasks(a, b, taskSorting)) .sort((a: any, b: any) => sortTasks(a, b, taskSorting))
.map((task: Task, taskIndex: any) => { .map((task: Task, taskIndex: any) => {
@ -323,12 +322,14 @@ const SimpleLists: React.FC<SimpleProps> = ({
index={taskIndex} index={taskIndex}
isDragDisabled={taskSorting.type !== TaskSortingType.NONE} isDragDisabled={taskSorting.type !== TaskSortingType.NONE}
> >
{taskProvided => { {(taskProvided) => {
return ( return (
<Card <Card
toggleDirection={toggleDirection} toggleDirection={toggleDirection}
toggleLabels={toggleLabels} toggleLabels={toggleLabels}
isPublic={isPublic}
labelVariant={cardLabelVariant} labelVariant={cardLabelVariant}
watched={task.watched}
wrapperProps={{ wrapperProps={{
...taskProvided.draggableProps, ...taskProvided.draggableProps,
...taskProvided.dragHandleProps, ...taskProvided.dragHandleProps,
@ -348,12 +349,12 @@ const SimpleLists: React.FC<SimpleProps> = ({
complete={task.complete ?? false} complete={task.complete ?? false}
taskGroupID={taskGroup.id} taskGroupID={taskGroup.id}
description="" description=""
labels={task.labels.map(label => label.projectLabel)} labels={task.labels.map((label) => label.projectLabel)}
dueDate={ dueDate={
task.dueDate task.dueDate.at
? { ? {
isPastDue: false, isPastDue: false,
formattedDate: dayjs(task.dueDate).format('MMM D, YYYY'), formattedDate: dayjs(task.dueDate.at).format('MMM D, YYYY'),
} }
: undefined : undefined
} }
@ -363,6 +364,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
onTaskClick(task); onTaskClick(task);
}} }}
checklists={task.badges && task.badges.checklist} checklists={task.badges && task.badges.checklist}
comments={task.badges && task.badges.comments}
onCardMemberClick={onCardMemberClick} onCardMemberClick={onCardMemberClick}
onContextMenu={onQuickEditorOpen} onContextMenu={onQuickEditorOpen}
/> />
@ -377,7 +379,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
onClose={() => { onClose={() => {
setCurrentComposer(''); setCurrentComposer('');
}} }}
onCreateCard={name => { onCreateCard={(name) => {
onCreateTask(taskGroup.id, name); onCreateTask(taskGroup.id, name);
}} }}
isOpen isOpen
@ -391,16 +393,18 @@ const SimpleLists: React.FC<SimpleProps> = ({
</Draggable> </Draggable>
); );
})} })}
<AddList
onSave={listName => {
onCreateTaskGroup(listName);
}}
/>
{provided.placeholder} {provided.placeholder}
</Container> </Container>
)} )}
</Droppable> </Droppable>
</DragDropContext> </DragDropContext>
{!isPublic && (
<AddList
onSave={(listName) => {
onCreateTaskGroup(listName);
}}
/>
)}
</BoardWrapper> </BoardWrapper>
</BoardContainer> </BoardContainer>
); );

View File

@ -24,7 +24,7 @@ export default function shouldMetaFilter(task: Task, filters: TaskMetaFilters) {
isFiltered = shouldFilter(!(task.dueDate && task.dueDate !== null)); isFiltered = shouldFilter(!(task.dueDate && task.dueDate !== null));
} }
if (task.dueDate) { if (task.dueDate) {
const taskDueDate = dayjs(task.dueDate); const taskDueDate = dayjs(task.dueDate.at);
const today = dayjs(); const today = dayjs();
let start; let start;
let end; let end;
@ -36,61 +36,31 @@ export default function shouldMetaFilter(task: Task, filters: TaskMetaFilters) {
isFiltered = shouldFilter(taskDueDate.isSame(today, 'day')); isFiltered = shouldFilter(taskDueDate.isSame(today, 'day'));
break; break;
case DueDateFilterType.TOMORROW: case DueDateFilterType.TOMORROW:
isFiltered = shouldFilter( isFiltered = shouldFilter(taskDueDate.isBefore(today.clone().add(1, 'day').endOf('day')));
taskDueDate.isBefore(
today
.clone()
.add(1, 'day')
.endOf('day'),
),
);
break; break;
case DueDateFilterType.THIS_WEEK: case DueDateFilterType.THIS_WEEK:
start = today start = today.clone().weekday(0).startOf('day');
.clone() end = today.clone().weekday(6).endOf('day');
.weekday(0)
.startOf('day');
end = today
.clone()
.weekday(6)
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end)); isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break; break;
case DueDateFilterType.NEXT_WEEK: case DueDateFilterType.NEXT_WEEK:
start = today start = today.clone().weekday(0).add(7, 'day').startOf('day');
.clone() end = today.clone().weekday(6).add(7, 'day').endOf('day');
.weekday(0)
.add(7, 'day')
.startOf('day');
end = today
.clone()
.weekday(6)
.add(7, 'day')
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end)); isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break; break;
case DueDateFilterType.ONE_WEEK: case DueDateFilterType.ONE_WEEK:
start = today.clone().startOf('day'); start = today.clone().startOf('day');
end = today end = today.clone().add(7, 'day').endOf('day');
.clone()
.add(7, 'day')
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end)); isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break; break;
case DueDateFilterType.TWO_WEEKS: case DueDateFilterType.TWO_WEEKS:
start = today.clone().startOf('day'); start = today.clone().startOf('day');
end = today end = today.clone().add(14, 'day').endOf('day');
.clone()
.add(14, 'day')
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end)); isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break; break;
case DueDateFilterType.THREE_WEEKS: case DueDateFilterType.THREE_WEEKS:
start = today.clone().startOf('day'); start = today.clone().startOf('day');
end = today end = today.clone().add(21, 'day').endOf('day');
.clone()
.add(21, 'day')
.endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end)); isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break; break;
default: default:
@ -104,7 +74,7 @@ export default function shouldMetaFilter(task: Task, filters: TaskMetaFilters) {
} }
for (const member of filters.members) { for (const member of filters.members) {
if (task.assigned) { if (task.assigned) {
if (task.assigned.findIndex(m => m.id === member.id) !== -1) { if (task.assigned.findIndex((m) => m.id === member.id) !== -1) {
isFiltered = ShouldFilter.VALID; isFiltered = ShouldFilter.VALID;
} }
} }
@ -116,7 +86,7 @@ export default function shouldMetaFilter(task: Task, filters: TaskMetaFilters) {
} }
for (const label of filters.labels) { for (const label of filters.labels) {
if (task.labels) { if (task.labels) {
if (task.labels.findIndex(m => m.projectLabel.id === label.id) !== -1) { if (task.labels.findIndex((m) => m.projectLabel.id === label.id) !== -1) {
isFiltered = ShouldFilter.VALID; isFiltered = ShouldFilter.VALID;
} }
} }

View File

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

View File

@ -1,67 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import styled from 'styled-components';
import Login from '.';
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export default {
component: Login,
title: 'Login',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#cdd3e1', default: true },
],
},
};
const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
`;
const LoginWrapper = styled.div`
width: 60%;
`;
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<Container>
<LoginWrapper>
<Login onSubmit={action('on submit')} />
</LoginWrapper>
</Container>
</>
);
};
export const WithSubmission = () => {
const onSubmit = async (data: LoginFormData, setComplete: (val: boolean) => void, setError: any) => {
await sleep(2000);
if (data.username !== 'test' || data.password !== 'test') {
setError('username', 'invalid', 'Invalid username');
setError('password', 'invalid', 'Invalid password');
}
setComplete(true);
};
return (
<>
<NormalizeStyles />
<BaseStyles />
<Container>
<LoginWrapper>
<Login onSubmit={onSubmit} />
</LoginWrapper>
</Container>
</>
);
};

View File

@ -1,6 +1,7 @@
import styled from 'styled-components'; import styled from 'styled-components';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
import AccessAccount from 'shared/undraw/AccessAccount';
export const Wrapper = styled.div` export const Wrapper = styled.div`
background: #eff2f7; background: #eff2f7;
@ -15,6 +16,12 @@ export const Column = styled.div`
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@media (max-width: 600px) {
svg {
display: none;
}
position: absolute;
}
`; `;
export const LoginFormWrapper = styled.div` export const LoginFormWrapper = styled.div`
@ -25,18 +32,47 @@ export const LoginFormWrapper = styled.div`
export const LoginFormContainer = styled.div` export const LoginFormContainer = styled.div`
min-height: 505px; min-height: 505px;
padding: 2rem; padding: 2rem;
@media (max-width: 600px) {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
border: solid black 1px;
width: 600px;
height: 1100px;
box-shadow: 20px 20px 50px black;
}
@media (min-height: 641px) and (max-height: 653px) {
margin-top: 25%;
}
@media (min-height: 654px) and (max-height: 823px) and (max-width: 500px) {
margin-top: -20%;
}
@media (min-height: 480px) and (max-height: 639px) {
margin-top: 20%;
}
`; `;
export const Title = styled.h1` export const Title = styled.h1`
color: #ebeefd; color: #ebeefd;
font-size: 18px; font-size: 18px;
margin-bottom: 14px; margin-bottom: 14px;
@media (max-width: 600px) {
font-size: 38px;
margin-top: 50px;
text-align: center;
}
`; `;
export const SubTitle = styled.h2` export const SubTitle = styled.h2`
color: #c2c6dc; color: #c2c6dc;
font-size: 14px; font-size: 14px;
margin-bottom: 14px; margin-bottom: 14px;
@media (max-width: 600px) {
margin-top: 30px;
font-size: 24.5px;
margin-bottom: 90px;
text-align: center;
}
`; `;
export const Form = styled.form` export const Form = styled.form`
display: flex; display: flex;
@ -48,6 +84,10 @@ export const FormLabel = styled.label`
font-size: 12px; font-size: 12px;
position: relative; position: relative;
margin-top: 14px; margin-top: 14px;
@media (max-width: 600px) {
font-size: 35px;
font-weight: bold;
}
`; `;
export const FormTextInput = styled.input` export const FormTextInput = styled.input`
@ -59,28 +99,92 @@ export const FormTextInput = styled.input`
font-size: 1rem; font-size: 1rem;
color: #c2c6dc; color: #c2c6dc;
border-radius: 5px; border-radius: 5px;
@media (max-width: 600px) {
border: 5px solid rgba(0, 0, 0, 0.2);
border-radius: 5%;
font-size: 30px;
background-color: #353D64;
color: black;
padding: 0.7rem 1rem 1rem 3rem;
text-align: center;
&::placeholder {
visibility: hidden;
}
&:not(:placeholder-shown) {
background-color: white;
}
}
`; `;
export const FormIcon = styled.div` export const FormIcon = styled.div`
top: 30px; top: 30px;
left: 16px; left: 16px;
position: absolute; position: absolute;
@media (max-width: 600px) {
svg {
width: 40px;
height: 40px;
display: inline;
position: absolute;
top: 30px;
left: -5px;
}
}
`; `;
export const FormError = styled.span` export const FormError = styled.span`
font-size: 0.875rem; font-size: 0.875rem;
color: ${props => props.theme.colors.danger}; color: ${(props) => props.theme.colors.danger};
@media (max-width: 600px) {
font-size: 1.8rem;
}
`; `;
export const LoginButton = styled(Button)``; export const LoginButton = styled(Button)`
@media (max-width: 600px) {
span {
font-size: 40px;
text-align: center;
width: 100%;
}
align-self: center;
position: absolute;
right: 0px;
margin-top: 40%;
width: 100%;
&:hover {
box-shadow: 5px 5px 20px white;
}
}
`;
export const ActionButtons = styled.div` export const ActionButtons = styled.div`
margin-top: 17.5px; margin-top: 17.5px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@media (max-width: 600px) {
width: 150px;
align-content: center;
font-size: 50px;
}
`; `;
export const RegisterButton = styled(Button)``; export const RegisterButton = styled(Button)`
@media (max-width: 600px) {
span {
font-size: 40px;
text-align: center;
width: 100%;
}
width: 100%;
position: absolute;
left: 0px;
margin-top: 29%;
}
`;
export const LogoTitle = styled.div` export const LogoTitle = styled.div`
font-size: 24px; font-size: 24px;
@ -88,6 +192,9 @@ export const LogoTitle = styled.div`
margin-left: 12px; margin-left: 12px;
transition: visibility, opacity, transform 0.25s ease; transition: visibility, opacity, transform 0.25s ease;
color: #7367f0; color: #7367f0;
@media (max-width: 600px) {
font-size: 60px;
}
`; `;
export const LogoWrapper = styled.div` export const LogoWrapper = styled.div`
@ -100,5 +207,16 @@ export const LogoWrapper = styled.div`
padding-bottom: 16px; padding-bottom: 16px;
margin-bottom: 24px; margin-bottom: 24px;
color: rgb(222, 235, 255); color: rgb(222, 235, 255);
border-bottom: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0.65)}; border-bottom: 1px solid ${(props) => mixin.rgba(props.theme.colors.alternate, 0.65)};
@media (max-width: 600px) {
svg {
display: inline;
width: 80px;
height: 80px;
}
}
`; `;

View File

@ -1,8 +1,8 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import AccessAccount from 'shared/undraw/AccessAccount'; import AccessAccount from 'shared/undraw/AccessAccount';
import { User, Lock, Taskcafe } from 'shared/icons'; import { User, Lock, Taskcafe } from 'shared/icons';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useHistory } from 'react-router';
import LoadingSpinner from 'shared/components/LoadingSpinner'; import LoadingSpinner from 'shared/components/LoadingSpinner';
import { import {
Form, Form,
@ -25,11 +25,28 @@ import {
const Login = ({ onSubmit }: LoginProps) => { const Login = ({ onSubmit }: LoginProps) => {
const [isComplete, setComplete] = useState(true); const [isComplete, setComplete] = useState(true);
const { register, handleSubmit, errors, setError, formState } = useForm<LoginFormData>(); const [showRegistration, setShowRegistration] = useState(false);
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<LoginFormData>();
const loginSubmit = (data: LoginFormData) => { const loginSubmit = (data: LoginFormData) => {
setComplete(false); setComplete(false);
onSubmit(data, setComplete, setError); onSubmit(data, setComplete, setError);
}; };
const history = useHistory();
useEffect(() => {
fetch('/settings').then(async (x) => {
const { isConfigured, allowPublicRegistration } = await x.json();
if (!isConfigured) {
history.push('/register');
} else if (allowPublicRegistration) {
setShowRegistration(true);
}
});
}, []);
return ( return (
<Wrapper> <Wrapper>
<Column> <Column>
@ -48,10 +65,9 @@ const Login = ({ onSubmit }: LoginProps) => {
<FormLabel htmlFor="username"> <FormLabel htmlFor="username">
Username Username
<FormTextInput <FormTextInput
placeholder="Username"
type="text" type="text"
id="username" {...register('username', { required: 'Username is required' })}
name="username"
ref={register({ required: 'Username is required' })}
/> />
<FormIcon> <FormIcon>
<User width={20} height={20} /> <User width={20} height={20} />
@ -61,10 +77,9 @@ const Login = ({ onSubmit }: LoginProps) => {
<FormLabel htmlFor="password"> <FormLabel htmlFor="password">
Password Password
<FormTextInput <FormTextInput
placeholder="Password"
type="password" type="password"
id="password" {...register('password', { required: 'Password is required' })}
name="password"
ref={register({ required: 'Password is required' })}
/> />
<FormIcon> <FormIcon>
<Lock width={20} height={20} /> <Lock width={20} height={20} />
@ -73,7 +88,7 @@ const Login = ({ onSubmit }: LoginProps) => {
{errors.password && <FormError>{errors.password.message}</FormError>} {errors.password && <FormError>{errors.password.message}</FormError>}
<ActionButtons> <ActionButtons>
<RegisterButton variant="outline">Register</RegisterButton> {showRegistration ? <RegisterButton variant="outline">Register</RegisterButton> : <div />}
{!isComplete && <LoadingSpinner size="32px" thickness="2px" borderSize="48px" />} {!isComplete && <LoadingSpinner size="32px" thickness="2px" borderSize="48px" />}
<LoginButton type="submit" disabled={!isComplete}> <LoginButton type="submit" disabled={!isComplete}>
Login Login

View File

@ -1,18 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import MemberManager from '.';
export default {
component: MemberManager,
title: 'MemberManager',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
export const Default = () => {
return <MemberManager availableMembers={[]} activeMembers={[]} onMemberChange={action('member change')} />;
};

View File

@ -1,32 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import Modal from '.';
export default {
component: Modal,
title: 'Modal',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#cdd3e1', default: true },
],
},
};
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<Modal
width={1040}
onClose={action('on close')}
renderContent={() => {
return <h1>Hello!</h1>;
}}
/>
</>
);
};

View File

@ -1,40 +0,0 @@
import React from 'react';
import styled from 'styled-components';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import { Home } from 'shared/icons';
import Navbar, { ActionButton, ButtonContainer, PrimaryLogo } from '.';
export default {
component: Navbar,
title: 'Navbar',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#cdd3e1' },
],
},
};
const MainContent = styled.div`
padding: 0 0 50px 80px;
`;
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<Navbar>
<PrimaryLogo />
<ButtonContainer>
<ActionButton name="Home">
<Home width={28} height={28} />
</ActionButton>
<ActionButton name="Home">
<Home width={28} height={28} />
</ActionButton>
</ButtonContainer>
</Navbar>
</>
);
};

View File

@ -1,32 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import NOOP from 'shared/utils/noop';
import NewProject from '.';
export default {
component: NewProject,
title: 'NewProject',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<NewProject
initialTeamID={null}
onCreateProject={action('create project')}
teams={[{ name: 'General', id: 'general', createdAt: '' }]}
onClose={NOOP}
/>
</>
);
};

View File

@ -98,8 +98,8 @@ const ProjectName = styled.input`
font-weight: 400; font-weight: 400;
&:focus { &:focus {
background: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.15)}; background: ${(props) => mixin.darken(props.theme.colors.bg.secondary, 0.15)};
box-shadow: ${props => props.theme.colors.primary} 0px 0px 0px 1px; box-shadow: ${(props) => props.theme.colors.primary} 0px 0px 0px 1px;
} }
`; `;
const ProjectNameLabel = styled.label` const ProjectNameLabel = styled.label`
@ -210,8 +210,8 @@ const CreateButton = styled.button`
&:hover { &:hover {
color: #fff; color: #fff;
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
border-color: ${props => props.theme.colors.primary}; border-color: ${(props) => props.theme.colors.primary};
} }
`; `;
type NewProjectProps = { type NewProjectProps = {
@ -224,7 +224,7 @@ type NewProjectProps = {
const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose, onCreateProject }) => { const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose, onCreateProject }) => {
const [projectName, setProjectName] = useState(''); const [projectName, setProjectName] = useState('');
const [team, setTeam] = useState<null | string>(initialTeamID); const [team, setTeam] = useState<null | string>(initialTeamID);
const options = [{ label: 'No team', value: 'no-team' }, ...teams.map(t => ({ label: t.name, value: t.id }))]; const options = [{ label: 'No team', value: 'no-team' }, ...teams.map((t) => ({ label: t.name, value: t.id }))];
return ( return (
<Overlay> <Overlay>
<Content> <Content>
@ -234,7 +234,7 @@ const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose,
onClose(); onClose();
}} }}
> >
<ArrowLeft color="#c2c6dc" /> <ArrowLeft width={16} height={16} color="#c2c6dc" />
</HeaderLeft> </HeaderLeft>
<HeaderRight <HeaderRight
onClick={() => { onClick={() => {
@ -263,7 +263,7 @@ const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose,
onChange={(e: any) => { onChange={(e: any) => {
setTeam(e.value); setTeam(e.value);
}} }}
value={options.find(d => d.value === team)} value={options.find((d) => d.value === team)}
styles={colourStyles} styles={colourStyles}
classNamePrefix="teamSelect" classNamePrefix="teamSelect"
options={options} options={options}

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