59 Commits

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

Before the login flow was:

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

now it looks like this:

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

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

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

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

@ -21,4 +21,4 @@ windows:
- database:
root: ./
panes:
- pgcli postgres://taskcafe:taskcafe_test@localhost:5432/taskcafe
- pgcli postgres://taskcafe:taskcafe_test@localhost:8855/taskcafe

View File

@ -4,17 +4,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.4.0] - 2021-09-04
### Added
- Task sorting & filtering
- Redesigned the Task Details UI
- Implement task group actions (duplicate/delete all tasks/sort)
- 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
- removed CORS middleware to fix security issue
- Added 3 retries with backoff to initial database connection [(#47)](https://github.com/JordanKnott/taskcafe/issues/47)
- Can now actually set a due date
- Any open popups are hidden when closing the Task Details window
## [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!
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.

View File

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

71
Pipfile.lock generated
View File

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

View File

@ -18,27 +18,34 @@
<img alt="Docker pulls" src="https://img.shields.io/docker/pulls/taskcafe/taskcafe" />
</a>
</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">
Was this project useful? Please consider <a href="https://www.buymeacoffee.com/jordanknott">donating</a> to help me improve it!
</p>
**Please note that this project is still in active development. Some options may not work yet! For updates on development, join the Discord server**
<p align="center">
This project is still in <strong>alpha development</strong></p>
![Taskcafe](./.github/taskcafe_preview.png)
## 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
- Add colors & named labels
- Add due dates
- Descriptions written in Markdown
- Assign members
- Checklists
- Mark tasks as complete
This project is still in active development, so some options may not be fully implemented yet.
**For updates on development, join the [Discord server](https://discord.gg/JkQDruh).**
For a list of planned features, check out the [Roadmap](https://github.com/JordanKnott/taskcafe/wiki/Roadmap)!

View File

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

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
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 Dashboard from 'Dashboard';
import Admin from 'Admin';
import MyTasks from 'MyTasks';
import Confirm from 'Confirm';
import Projects from 'Projects';
import Project from 'Projects/Project';
@ -12,8 +13,6 @@ import Login from 'Auth';
import Register from 'Register';
import Profile from 'Profile';
import styled from 'styled-components';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context';
const MainContent = styled.div`
@ -25,61 +24,67 @@ const MainContent = styled.div`
flex-grow: 1;
`;
const AuthorizedRoutes = () => {
const history = useHistory();
type ValidateTokenResponse = {
valid: boolean;
userID: string;
};
const UserRequiredRoute: React.FC<any> = ({ children }) => {
const { user } = useCurrentUser();
const location = useLocation();
console.log('user required', user);
if (user) {
return children;
}
return (
<Redirect
to={{
pathname: '/login',
state: { redirect: location.pathname },
}}
/>
);
};
const Routes: React.FC = () => {
const [loading, setLoading] = useState(true);
const { setUser } = useCurrentUser();
useEffect(() => {
fetch('/auth/refresh_token', {
fetch('/auth/validate', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken, setup } = response;
if (setup) {
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
}
}).then(async (x) => {
const response: ValidateTokenResponse = await x.json();
const { valid, userID } = response;
if (valid) {
setUser(userID);
}
setLoading(false);
});
}, []);
return loading ? null : (
<Switch>
<MainContent>
<Route exact path="/" component={Dashboard} />
<Route exact path="/projects" component={Projects} />
<Route path="/projects/:projectID" component={Project} />
<Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
</MainContent>
</Switch>
);
};
type RoutesProps = {
history: H.History;
};
const Routes: React.FC<RoutesProps> = () => (
console.log('loading', loading);
if (loading) return null;
return (
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/register" component={Register} />
<Route exact path="/confirm" component={Confirm} />
<AuthorizedRoutes />
<Switch>
<MainContent>
<Route path="/projects/:projectID" component={Project} />
<UserRequiredRoute>
<Route exact path="/" component={Dashboard} />
<Route exact path="/projects" 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>
);
};
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: History<History.PoorMansUnknown>;
name: string;
projectID: string;
};
export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, projectID }) => {
const { hidePopup, setTab } = usePopup();
const [deleteProject] = useDeleteProjectMutation({
update: (client, deleteData) => {
const cacheData: any = client.readQuery({
query: GetProjectsDocument,
});
const newData = produce(cacheData, (draftState: any) => {
draftState.projects = draftState.projects.filter(
(project: any) => project.id !== deleteData.data.deleteProject.project.id,
);
});
client.writeQuery({
query: GetProjectsDocument,
data: {
...newData,
},
});
},
});
return (
<>
<Popup title={null} tab={0}>
<ProjectSettings
onDeleteProject={() => {
setTab(1, { 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,262 @@
import React from 'react';
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
import LoggedOutNavbar from 'shared/components/TopNavbar/LoggedOut';
import { ProfileMenu } from 'shared/components/DropdownMenu';
import { useHistory, useRouteMatch } from 'react-router';
import { useCurrentUser } from 'App/context';
import { RoleCode, useTopNavbarQuery } from 'shared/generated/graphql';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import MiniProfile from 'shared/components/MiniProfile';
import cache from 'App/cache';
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
import theme from 'App/ThemeStyles';
import ProjectFinder from './ProjectFinder';
// TODO: Move to context based navbar?
type GlobalTopNavbarProps = {
nameOnly?: boolean;
projectID: string | null;
teamID?: string | null;
onChangeProjectOwner?: (userID: string) => void;
name: string | null;
currentTab?: number;
popupContent?: JSX.Element;
menuType?: Array<MenuItem>;
onChangeRole?: (userID: string, roleCode: RoleCode) => void;
projectMembers?: null | Array<TaskUser>;
projectInvitedMembers?: null | Array<InvitedUser>;
onSaveProjectName?: (projectName: string) => void;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
onSetTab?: (tab: number) => void;
onRemoveFromBoard?: (userID: string) => void;
onRemoveInvitedFromBoard?: (email: string) => void;
};
const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
currentTab,
onSetTab,
menuType,
teamID,
onChangeProjectOwner,
onChangeRole,
name,
popupContent,
projectMembers,
projectInvitedMembers,
onInviteUser,
onSaveProjectName,
onRemoveInvitedFromBoard,
onRemoveFromBoard,
}) => {
const { data } = useTopNavbarQuery();
const { showPopup, hidePopup } = usePopup();
const { setUser } = useCurrentUser();
const history = useHistory();
const onLogout = () => {
fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 200) {
cache.reset();
history.replace('/login');
setUser(null);
hidePopup();
}
});
};
const onProfileClick = ($target: React.RefObject<HTMLElement>) => {
showPopup(
$target,
<Popup title={null} tab={0}>
<ProfileMenu
onLogout={onLogout}
showAdminConsole // TODO: add permision check
onAdminConsole={() => {
history.push('/admin');
hidePopup();
}}
onProfile={() => {
history.push('/profile');
hidePopup();
}}
/>
</Popup>,
{ width: 195 },
);
};
const onOpenSettings = ($target: React.RefObject<HTMLElement>) => {
if (popupContent) {
showPopup($target, popupContent, { width: 185 });
}
};
const onNotificationClick = ($target: React.RefObject<HTMLElement>) => {
if (data) {
showPopup(
$target,
<NotificationPopup>
{data.notifications.map(notification => (
<NotificationItem
title={notification.entity.name}
description={`${notification.actor.name} added you as a meber to the task "${notification.entity.name}"`}
createdAt={notification.createdAt}
/>
))}
</NotificationPopup>,
{ width: 415, borders: false, diamondColor: theme.colors.primary },
);
}
};
// TODO: readd permision check
// const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
const userIsTeamOrProjectAdmin = true;
const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => {
const member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null;
if (member) {
showPopup(
$targetRef,
<MiniProfile
onRemoveFromBoard={() => {
if (onRemoveInvitedFromBoard) {
onRemoveInvitedFromBoard(member.email);
}
}}
invited
user={{
id: member.email,
fullName: member.email,
bio: 'Invited',
profileIcon: {
bgColor: '#000',
url: null,
initials: member.email.charAt(0),
},
}}
bio=""
/>,
);
}
};
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
if (member) {
showPopup(
$targetRef,
<MiniProfile
warning={member.role && member.role.code === 'owner' ? warning : null}
canChangeRole={userIsTeamOrProjectAdmin}
onChangeRole={roleCode => {
if (onChangeRole) {
onChangeRole(member.id, roleCode);
}
}}
onRemoveFromBoard={
member.role && member.role.code === 'owner'
? undefined
: () => {
if (onRemoveFromBoard) {
onRemoveFromBoard(member.id);
}
}
}
user={member}
bio=""
/>,
);
}
};
const user = data ? data.me?.user : null;
return (
<>
<TopNavbar
name={name}
menuType={menuType}
onOpenProjectFinder={$target => {
showPopup(
$target,
<Popup tab={0} title={null}>
<ProjectFinder />
</Popup>,
);
}}
currentTab={currentTab}
user={user ?? null}
canEditProjectName={userIsTeamOrProjectAdmin}
canInviteUser={userIsTeamOrProjectAdmin}
onMemberProfile={onMemberProfile}
onInvitedMemberProfile={onInvitedMemberProfile}
onInviteUser={onInviteUser}
onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner}
onNotificationClick={onNotificationClick}
onSetTab={onSetTab}
onRemoveFromBoard={onRemoveFromBoard}
onDashboardClick={() => {
history.push('/');
}}
onMyTasksClick={() => {
history.push('/tasks');
}}
projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
onProfileClick={onProfileClick}
onSaveName={onSaveProjectName}
onOpenSettings={onOpenSettings}
/>
</>
);
};
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
currentTab,
onSetTab,
menuType,
teamID,
onChangeProjectOwner,
onChangeRole,
name,
popupContent,
projectMembers,
projectInvitedMembers,
onInviteUser,
onSaveProjectName,
onRemoveInvitedFromBoard,
onRemoveFromBoard,
}) => {
const { user } = useCurrentUser();
const match = useRouteMatch();
if (user) {
return (
<LoggedInNavbar
currentTab={currentTab}
projectID={null}
onSetTab={onSetTab}
menuType={menuType}
teamID={teamID}
onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner}
name={name}
popupContent={popupContent}
projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
onInviteUser={onInviteUser}
onSaveProjectName={onSaveProjectName}
onRemoveInvitedFromBoard={onRemoveInvitedFromBoard}
onRemoveFromBoard={onRemoveFromBoard}
/>
);
}
return <LoggedOutNavbar match={match.url} name={name} menuType={menuType} />;
};
export default GlobalTopNavbar;

View File

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

View File

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

View File

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

View File

@ -1,79 +1,32 @@
import React, { useState, useEffect } from 'react';
import jwtDecode from 'jwt-decode';
import { createBrowserHistory } from 'history';
import { Router } from 'react-router';
import { BrowserRouter } from 'react-router-dom';
import { PopupProvider } from 'shared/components/PopupMenu';
import { ToastContainer } from 'react-toastify';
import { setAccessToken } from 'shared/utils/accessToken';
import styled, { ThemeProvider } from 'styled-components';
import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles';
import theme from './ThemeStyles';
import 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';
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();
type RefreshTokenResponse = {
accessToken: string;
isInstalled: boolean;
};
import './fonts.css';
const App = () => {
const [user, setUser] = useState<CurrentUserRaw | null>(null);
const setUserRoles = (roles: CurrentUserRoles) => {
if (user) {
setUser({
...user,
roles,
});
}
};
const [user, setUser] = useState<string | null>(null);
return (
<>
<UserContext.Provider value={{ user, setUser, setUserRoles }}>
<UserContext.Provider value={{ user, setUser }}>
<ThemeProvider theme={theme}>
<NormalizeStyles />
<BaseStyles />
<Router history={history}>
<BrowserRouter>
<PopupProvider>
<Routes history={history} />
<Routes />
</PopupProvider>
</Router>
<StyledContainer
</BrowserRouter>
<ToastedContainer
position="bottom-right"
autoClose={5000}
hideProgressBar

View File

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

View File

@ -1,18 +1,13 @@
import React, { useState } from 'react';
import axios from 'axios';
import React from 'react';
import Confirm from 'shared/components/Confirm';
import { useHistory, useLocation } from 'react-router';
import * as QueryString from 'query-string';
import { toast } from 'react-toastify';
import { Container, LoginWrapper } from './Styles';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context';
import { Container, LoginWrapper } from './Styles';
const UsersConfirm = () => {
const history = useHistory();
const location = useLocation();
const [registered, setRegistered] = useState(false);
const params = QueryString.parse(location.search);
const { setUser } = useCurrentUser();
return (
@ -31,18 +26,8 @@ const UsersConfirm = () => {
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);
const { userID } = response;
setUser(userID);
history.push('/');
} else {
setFailed();

View File

@ -0,0 +1,145 @@
import React, { useState, useEffect } from 'react';
import styled, { css } from 'styled-components';
import { Checkmark, User, Calendar, Tags, Clock } from 'shared/icons';
import { TaskMetaFilters, TaskMeta, TaskMetaMatch, DueDateFilterType } from 'shared/components/Lists';
import Input from 'shared/components/ControlledInput';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import produce from 'immer';
import { mixin } from 'shared/utils/styles';
import Member from 'shared/components/Member';
import { MyTasksSort } from 'shared/generated/graphql';
const FilterMember = styled(Member)`
margin: 2px 0;
&:hover {
cursor: pointer;
background: ${props => props.theme.colors.primary};
}
`;
export const Labels = styled.ul`
list-style: none;
margin: 0 8px;
padding: 0;
margin-bottom: 8px;
`;
export const Label = styled.li`
position: relative;
`;
export const CardLabel = styled.span<{ active: boolean; color: string }>`
${props =>
props.active &&
css`
margin-left: 4px;
box-shadow: -8px 0 ${mixin.darken(props.color, 0.12)};
border-radius: 3px;
`}
cursor: pointer;
font-weight: 700;
margin: 0 0 4px;
min-height: 20px;
padding: 6px 12px;
position: relative;
transition: padding 85ms, margin 85ms, box-shadow 85ms;
background-color: ${props => props.color};
color: #fff;
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-height: 31px;
`;
export const ActionsList = styled.ul`
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
`;
export const ActionItem = styled.li`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: ${props => props.theme.colors.primary};
}
`;
export const ActionTitle = styled.span`
margin-left: 20px;
`;
const ActionItemSeparator = styled.li`
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)};
font-size: 12px;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.75rem;
padding-bottom: 0.25rem;
`;
const ActiveIcon = styled(Checkmark)`
position: absolute;
right: 4px;
`;
const ItemIcon = styled.div`
position: absolute;
`;
const TaskNameInput = styled(Input)`
margin: 0;
`;
const ActionItemLine = styled.div`
height: 1px;
border-top: 1px solid #414561;
margin: 0.25rem !important;
`;
type MyTasksSortProps = {
sort: MyTasksSort;
onChangeSort: (sort: MyTasksSort) => void;
};
const MyTasksSortPopup: React.FC<MyTasksSortProps> = ({ sort: initialSort, onChangeSort }) => {
const [sort, setSort] = useState(initialSort);
const handleChangeSort = (f: MyTasksSort) => {
setSort(f);
onChangeSort(f);
};
return (
<>
<Popup tab={0} title={null}>
<ActionsList>
<ActionItem onClick={() => handleChangeSort(MyTasksSort.None)}>
{sort === MyTasksSort.None && <ActiveIcon width={16} height={16} />}
<ActionTitle>None</ActionTitle>
</ActionItem>
<ActionItem onClick={() => handleChangeSort(MyTasksSort.Project)}>
{sort === MyTasksSort.Project && <ActiveIcon width={16} height={16} />}
<ActionTitle>Project</ActionTitle>
</ActionItem>
<ActionItem onClick={() => handleChangeSort(MyTasksSort.DueDate)}>
{sort === MyTasksSort.DueDate && <ActiveIcon width={16} height={16} />}
<ActionTitle>Due Date</ActionTitle>
</ActionItem>
</ActionsList>
</Popup>
</>
);
};
export default MyTasksSortPopup;

View File

@ -0,0 +1,151 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { Checkmark } from 'shared/icons';
import { TaskStatusFilter, TaskStatus, TaskSince } from 'shared/components/Lists';
import { MyTasksStatus } from 'shared/generated/graphql';
import { Popup } from 'shared/components/PopupMenu';
export const ActionsList = styled.ul`
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
`;
export const ActionExtraMenuContainer = styled.div`
visibility: hidden;
position: absolute;
left: 100%;
top: -4px;
padding-left: 2px;
width: 100%;
`;
export const ActionItem = styled.li`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: ${props => props.theme.colors.primary};
}
&:hover ${ActionExtraMenuContainer} {
visibility: visible;
}
`;
export const ActionTitle = styled.span`
margin-left: 20px;
`;
export const ActionExtraMenu = styled.ul`
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
padding: 5px;
padding-top: 8px;
border-radius: 5px;
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
color: #c2c6dc;
background: #262c49;
border: 1px solid rgba(0, 0, 0, 0.1);
border-color: #414561;
`;
export const ActionExtraMenuItem = styled.li`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: ${props => props.theme.colors.primary};
}
`;
const ActionExtraMenuSeparator = styled.li`
color: ${props => props.theme.colors.text.primary};
font-size: 12px;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
`;
const ActiveIcon = styled(Checkmark)`
position: absolute;
`;
type MyTasksStatusProps = {
status: MyTasksStatus;
onChangeStatus: (status: MyTasksStatus) => void;
};
const MyTasksStatusPopup: React.FC<MyTasksStatusProps> = ({ status: initialStatus, onChangeStatus }) => {
const [status, setStatus] = useState(initialStatus);
const handleStatusChange = (f: MyTasksStatus) => {
setStatus(f);
onChangeStatus(f);
};
return (
<Popup tab={0} title={null}>
<ActionsList>
<ActionItem onClick={() => handleStatusChange(MyTasksStatus.Incomplete)}>
{status === MyTasksStatus.Incomplete && <ActiveIcon width={12} height={12} />}
<ActionTitle>Incomplete Tasks</ActionTitle>
</ActionItem>
<ActionItem>
{status !== MyTasksStatus.Incomplete && status !== MyTasksStatus.All && <ActiveIcon width={12} height={12} />}
<ActionTitle>Compelete Tasks</ActionTitle>
<ActionExtraMenuContainer>
<ActionExtraMenu>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteAll)}>
{status === MyTasksStatus.CompleteAll && <ActiveIcon width={12} height={12} />}
<ActionTitle>All completed tasks</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuSeparator>Marked complete since</ActionExtraMenuSeparator>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteToday)}>
{status === MyTasksStatus.CompleteToday && <ActiveIcon width={12} height={12} />}
<ActionTitle>Today</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteYesterday)}>
{status === MyTasksStatus.CompleteYesterday && <ActiveIcon width={12} height={12} />}
<ActionTitle>Yesterday</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteOneWeek)}>
{status === MyTasksStatus.CompleteOneWeek && <ActiveIcon width={12} height={12} />}
<ActionTitle>1 week</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteTwoWeek)}>
{status === MyTasksStatus.CompleteTwoWeek && <ActiveIcon width={12} height={12} />}
<ActionTitle>2 weeks</ActionTitle>
</ActionExtraMenuItem>
<ActionExtraMenuItem onClick={() => handleStatusChange(MyTasksStatus.CompleteThreeWeek)}>
{status === MyTasksStatus.CompleteThreeWeek && <ActiveIcon width={12} height={12} />}
<ActionTitle>3 weeks</ActionTitle>
</ActionExtraMenuItem>
</ActionExtraMenu>
</ActionExtraMenuContainer>
</ActionItem>
<ActionItem onClick={() => handleStatusChange(MyTasksStatus.All)}>
{status === MyTasksStatus.All && <ActiveIcon width={12} height={12} />}
<ActionTitle>All Tasks</ActionTitle>
</ActionItem>
</ActionsList>
</Popup>
);
};
export default MyTasksStatusPopup;

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import React, { useRef, useEffect } from 'react';
import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import { getAccessToken } from 'shared/utils/accessToken';
import Settings from 'shared/components/Settings';
import {
useMeQuery,
@ -49,12 +48,9 @@ const Projects = () => {
if (e.target.files) {
const fileData = new FormData();
fileData.append('file', e.target.files[0]);
const accessToken = getAccessToken();
axios
.post('/users/me/avatar', fileData, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
withCredentials: true,
})
.then(res => {
if ($fileUpload && $fileUpload.current) {
@ -66,7 +62,7 @@ const Projects = () => {
}}
/>
<GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />
{!loading && data && (
{!loading && data && data.me && (
<Settings
profile={data.me.user}
onProfileAvatarChange={() => {
@ -75,7 +71,7 @@ const Projects = () => {
}
}}
onResetPassword={(password, done) => {
updateUserPassword({ variables: { userID: user.id, password } });
updateUserPassword({ variables: { userID: user, password } });
toast('Password was changed!');
done();
}}

View File

@ -159,6 +159,7 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
width="100%"
onChange={e => handleNameChange(e.currentTarget.value)}
value={nameFilter}
autoFocus
variant="alternate"
placeholder="Task name..."
/>

View File

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

View File

@ -198,6 +198,7 @@ type ProjectBoardProps = {
};
export const BoardLoading = () => {
const { user } = useCurrentUser();
return (
<>
<ProjectBar>
@ -215,6 +216,7 @@ export const BoardLoading = () => {
<ProjectActionText>Filter</ProjectActionText>
</ProjectAction>
</ProjectActions>
{user && (
<ProjectActions>
<ProjectAction>
<Tags width={13} height={13} />
@ -229,6 +231,7 @@ export const BoardLoading = () => {
<ProjectActionText>Rules</ProjectActionText>
</ProjectAction>
</ProjectActions>
)}
</ProjectBar>
<EmptyBoard />
</>
@ -280,7 +283,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter(
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id,
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data?.deleteTaskGroup.taskGroup.id,
);
}),
{ projectID },
@ -296,10 +299,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache =>
produce(cache, draftCache => {
const { taskGroups } = cache.findProject;
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id);
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data?.createTask.taskGroup.id);
if (idx !== -1) {
if (newTaskData.data) {
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
}
}
}),
{ projectID },
);
@ -313,7 +318,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (newTaskGroupData.data) {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
}
}),
{ projectID },
);
@ -332,7 +339,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache =>
produce(cache, draftCache => {
const idx = cache.findProject.taskGroups.findIndex(
t => t.id === resp.data.deleteTaskGroupTasks.taskGroupID,
t => t.id === resp.data?.deleteTaskGroupTasks.taskGroupID,
);
if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks = [];
@ -348,7 +355,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (resp.data) {
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
}
}),
{ projectID },
);
@ -364,21 +373,26 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (newTask.data) {
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
if (previousTaskGroupID !== task.taskGroup.id) {
const { taskGroups } = cache.findProject;
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
const previousTask = cache.findProject.taskGroups[oldTaskGroupIdx].tasks.find(t => t.id === task.id);
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
(t: Task) => t.id !== task.id,
);
if (previousTask) {
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
...taskGroups[newTaskGroupIdx].tasks,
{ ...task },
{ ...previousTask },
];
}
}
}
}
}),
{ projectID },
);
@ -415,6 +429,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
name,
complete: false,
completedAt: null,
hasTime: false,
taskGroup: {
__typename: 'TaskGroup',
id: taskGroup.id,
@ -448,9 +463,6 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
}
};
if (loading) {
return <BoardLoading />;
}
const getTaskStatusFilterLabel = (filter: TaskStatusFilter) => {
if (filter.status === TaskStatus.COMPLETE) {
return 'Complete';
@ -460,7 +472,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
}
return 'All Tasks';
};
if (data && user) {
if (data) {
labelsRef.current = data.findProject.labels;
membersRef.current = data.findProject.members;
const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => {
@ -534,7 +546,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onChangeTaskMetaFilter={filter => {
setTaskMetaFilters(filter);
}}
userID={user?.id}
userID={user ?? ''}
labels={labelsRef}
members={membersRef}
/>,
@ -561,6 +573,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
);
})}
</ProjectActions>
{user && (
<ProjectActions>
<ProjectAction
onClick={$labelsRef => {
@ -587,8 +600,10 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<ProjectActionText>Rules</ProjectActionText>
</ProjectAction>
</ProjectActions>
)}
</ProjectBar>
<SimpleLists
isPublic={user === null}
onTaskClick={task => {
history.push(`${match.url}/c/${task.id}`);
}}
@ -752,6 +767,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onLabelToggle={labelID => {
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
}}
taskID={task.id}
labelColors={data.labelColors}
labels={labelsRef}
taskLabels={taskLabelsRef}
@ -785,12 +801,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<DueDateManager
task={task}
onRemoveDueDate={t => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
hidePopup();
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } });
// hidePopup();
}}
onDueDateChange={(t, newDueDate) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup();
onDueDateChange={(t, newDueDate, hasTime) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } });
// hidePopup();
}}
onCancel={NOOP}
/>
@ -807,7 +823,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
);
}
return <span>Error</span>;
return <BoardLoading />;
};
export default ProjectBoard;

View File

@ -1,9 +1,10 @@
import React, { useState } from 'react';
import Modal from 'shared/components/Modal';
import TaskDetails from 'shared/components/TaskDetails';
import TaskDetailsLoading from 'shared/components/TaskDetails/Loading';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import MemberManager from 'shared/components/MemberManager';
import { useRouteMatch, useHistory } from 'react-router';
import { useRouteMatch, useHistory, useParams } from 'react-router';
import {
useDeleteTaskChecklistMutation,
useUpdateTaskChecklistNameMutation,
@ -21,6 +22,9 @@ import {
useCreateTaskChecklistItemMutation,
FindTaskDocument,
FindTaskQuery,
useCreateTaskCommentMutation,
useDeleteTaskCommentMutation,
useUpdateTaskCommentMutation,
} from 'shared/generated/graphql';
import { useCurrentUser } from 'App/context';
import MiniProfile from 'shared/components/MiniProfile';
@ -32,6 +36,74 @@ import Input from 'shared/components/Input';
import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache';
import NOOP from 'shared/utils/noop';
import polling from 'shared/utils/polling';
export const ActionsList = styled.ul`
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
`;
export const ActionItem = styled.li`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: ${(props) => props.theme.colors.primary};
}
`;
export const ActionTitle = styled.span`
margin-left: 20px;
`;
const WarningLabel = styled.p`
font-size: 14px;
margin: 8px 12px;
`;
const DeleteConfirm = styled(Button)`
width: 100%;
padding: 8px 12px;
margin-bottom: 6px;
`;
type TaskCommentActionsProps = {
onDeleteComment: () => void;
onEditComment: () => void;
};
const TaskCommentActions: React.FC<TaskCommentActionsProps> = ({ onDeleteComment, onEditComment }) => {
const { setTab } = usePopup();
return (
<>
<Popup tab={0} title={null}>
<ActionsList>
<ActionItem>
<ActionTitle>Pin to top</ActionTitle>
</ActionItem>
<ActionItem onClick={() => onEditComment()}>
<ActionTitle>Edit comment</ActionTitle>
</ActionItem>
<ActionItem onClick={() => setTab(1)}>
<ActionTitle>Delete comment</ActionTitle>
</ActionItem>
</ActionsList>
</Popup>
<Popup tab={1} title="Delete comment?">
<WarningLabel>Deleting a comment can not be undone.</WarningLabel>
<DeleteConfirm onClick={() => onDeleteComment()} color="danger">
Delete comment
</DeleteConfirm>
</Popup>
</>
);
};
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
const total = checklists.reduce((prev: any, next: any) => {
@ -94,10 +166,8 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
defaultValue="Checklist"
width="100%"
label="Name"
id="name"
name="name"
variant="alternate"
ref={register({ required: 'Checklist name is required' })}
{...register('name', { required: 'Checklist name is required' })}
/>
<CreateChecklistButton type="submit">Create</CreateChecklistButton>
</CreateChecklistForm>
@ -105,7 +175,6 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
};
type DetailsProps = {
taskID: string;
projectURL: string;
onTaskNameChange: (task: Task, newName: string) => void;
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
@ -119,7 +188,6 @@ const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 };
const Details: React.FC<DetailsProps> = ({
projectURL,
taskID,
onTaskNameChange,
onTaskDescriptionChange,
onDeleteTask,
@ -128,46 +196,83 @@ const Details: React.FC<DetailsProps> = ({
refreshCache,
}) => {
const { user } = useCurrentUser();
const { taskID } = useParams<{ taskID: string }>();
const { showPopup, hidePopup } = usePopup();
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 [createTaskComment] = useCreateTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
draftCache.findTask.comments.push({
...response.data.createTaskComment.comment,
});
}
}),
{ taskID },
);
},
});
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
if (checklistID !== prevChecklistID) {
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID);
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
const { prevChecklistID, taskChecklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
if (taskChecklistID !== prevChecklistID) {
const oldIdx = cache.findTask.checklists.findIndex((c) => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex((c) => c.id === taskChecklistID);
if (oldIdx > -1 && newIdx > -1) {
const item = cache.findTask.checklists[oldIdx].items.find(i => i.id === checklistItem.id);
const item = cache.findTask.checklists[oldIdx].items.find((i) => i.id === checklistItem.id);
if (item) {
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
i => i.id !== checklistItem.id,
(i) => i.id !== checklistItem.id,
);
draftCache.findTask.checklists[newIdx].items.push({
...item,
position: checklistItem.position,
taskChecklistID: checklistID,
taskChecklistID,
});
}
}
}
}
}),
{ taskID },
);
},
});
const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({
update: client => {
update: (client) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
@ -184,11 +289,11 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
const { checklists } = cache.findTask;
draftCache.findTask.checklists = checklists.filter(
c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id,
(c) => c.id !== deleteData.data?.deleteTaskChecklist.taskChecklist.id,
);
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
@ -210,10 +315,12 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (createData.data) {
const item = createData.data.createTaskChecklist;
draftCache.findTask.checklists.push({ ...item });
}
}),
{ taskID },
);
@ -225,13 +332,14 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (deleteData.data) {
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
const targetIdx = cache.findTask.checklists.findIndex((c) => c.id === item.taskChecklistID);
if (targetIdx > -1) {
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
c => item.id !== c.id,
(c) => item.id !== c.id,
);
}
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
@ -240,6 +348,7 @@ const Details: React.FC<DetailsProps> = ({
complete,
total,
};
}
}),
{ taskID },
);
@ -250,11 +359,12 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (newTaskItem.data) {
const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask;
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
const idx = checklists.findIndex((c) => c.id === item.taskChecklistID);
if (idx !== -1) {
draftCache.findTask.checklists[idx].items.push({ ...item });
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
@ -264,12 +374,17 @@ const Details: React.FC<DetailsProps> = ({
total,
};
}
}
}),
{ taskID },
);
},
});
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
const { loading, data, refetch } = useFindTaskQuery({
variables: { taskID },
pollInterval: polling.TASK_DETAILS,
fetchPolicy: 'cache-and-network',
});
const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
onCompleted: () => {
@ -289,25 +404,46 @@ const Details: React.FC<DetailsProps> = ({
refreshCache();
},
});
if (loading) {
return null;
}
if (!data) {
return null;
}
const [updateTaskComment] = useUpdateTaskCommentMutation();
const [editableComment, setEditableComment] = useState<null | string>(null);
const isLoading = true;
return (
<>
<Modal
width={1070}
onClose={() => {
history.push(projectURL);
hidePopup();
}}
renderContent={() => {
return (
return data ? (
<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}
onChecklistDrop={checklist => {
onCreateComment={(task, message) => {
createTaskComment({ variables: { taskID: task.id, message } });
}}
onChecklistDrop={(checklist) => {
updateTaskChecklistLocation({
variables: { taskChecklistID: checklist.id, position: checklist.position },
@ -349,7 +485,7 @@ const Details: React.FC<DetailsProps> = ({
}}
onTaskNameChange={onTaskNameChange}
onTaskDescriptionChange={onTaskDescriptionChange}
onToggleTaskComplete={task => {
onToggleTaskComplete={(task) => {
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
}}
onDeleteTask={onDeleteTask}
@ -394,7 +530,7 @@ const Details: React.FC<DetailsProps> = ({
createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
}}
onMemberProfile={($targetRef, memberID) => {
const member = data.findTask.assigned.find(m => m.id === memberID);
const member = data.findTask.assigned.find((m) => m.id === memberID);
if (member) {
showPopup(
$targetRef,
@ -404,7 +540,7 @@ const Details: React.FC<DetailsProps> = ({
bio="None"
onRemoveFromTask={() => {
if (user) {
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
unassignTask({ variables: { taskID: data.findTask.id, userID: user ?? '' } });
}
}}
/>
@ -444,7 +580,7 @@ const Details: React.FC<DetailsProps> = ({
}}
>
<CreateChecklistPopup
onCreateChecklist={checklistData => {
onCreateChecklist={(checklistData) => {
let position = 65535;
if (data.findTask.checklists) {
const [lastChecklist] = data.findTask.checklists.slice(-1);
@ -494,13 +630,13 @@ const Details: React.FC<DetailsProps> = ({
>
<DueDateManager
task={task}
onRemoveDueDate={t => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
hidePopup();
onRemoveDueDate={(t) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } });
// hidePopup();
}}
onDueDateChange={(t, newDueDate) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup();
onDueDateChange={(t, newDueDate, hasTime) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } });
// hidePopup();
}}
onCancel={NOOP}
/>
@ -509,6 +645,8 @@ const Details: React.FC<DetailsProps> = ({
);
}}
/>
) : (
<TaskDetailsLoading />
);
}}
/>

View File

@ -8,11 +8,13 @@ import {
FindProjectDocument,
useCreateProjectLabelMutation,
FindProjectQuery,
useToggleTaskLabelMutation,
} from 'shared/generated/graphql';
import LabelManager from 'shared/components/PopupMenu/LabelManager';
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
type LabelManagerEditorProps = {
taskID?: string;
labels: React.RefObject<Array<ProjectLabel>>;
taskLabels: null | React.RefObject<Array<TaskLabel>>;
projectID: string;
@ -21,6 +23,7 @@ type LabelManagerEditorProps = {
};
const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
taskID,
labels: labelsRef,
projectID,
labelColors,
@ -29,14 +32,22 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
}) => {
const [currentLabel, setCurrentLabel] = useState('');
const { setTab, hidePopup } = usePopup();
const [toggleTaskLabel] = useToggleTaskLabelMutation();
const [createProjectLabel] = useCreateProjectLabelMutation({
onCompleted: data => {
if (taskID) {
toggleTaskLabel({ variables: { taskID, projectLabelID: data.createProjectLabel.id } });
}
},
update: (client, newLabelData) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (newLabelData.data) {
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel });
}
}),
{
projectID,
@ -53,7 +64,7 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
cache =>
produce(cache, draftCache => {
draftCache.findProject.labels = cache.findProject.labels.filter(
label => label.id !== newLabelData.data.deleteProjectLabel.id,
label => label.id !== newLabelData.data?.deleteProjectLabel.id,
);
}),
{ projectID },

View File

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

View File

@ -0,0 +1,64 @@
import styled from 'styled-components';
import Button from 'shared/components/Button';
export const OptionWrapper = styled.div<{ isFocused: boolean }>`
cursor: pointer;
padding: 4px 8px;
${props => props.isFocused && `background: rgba(${props.theme.colors.primary});`}
display: flex;
align-items: center;
`;
export const OptionContent = styled.div`
display: flex;
flex-direction: column;
margin-left: 12px;
`;
export const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>`
display: flex;
align-items: center;
font-size: ${p => p.fontSize}px;
color: rgba(${p => (p.quiet ? p.theme.colors.text.primary : p.theme.colors.text.primary)});
`;
export const OptionValueWrapper = styled.div`
background: rgba(${props => props.theme.colors.bg.primary});
border-radius: 4px;
margin: 2px;
padding: 3px 6px 3px 4px;
display: flex;
align-items: center;
`;
export const OptionValueLabel = styled.span`
font-size: 12px;
color: rgba(${props => props.theme.colors.text.secondary});
`;
export const OptionValueRemove = styled.button`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
outline: none;
padding: 0;
margin: 0;
margin-left: 4px;
`;
export const InviteButton = styled(Button)`
margin-top: 12px;
height: 32px;
padding: 4px 12px;
width: 100%;
justify-content: center;
`;
export const InviteContainer = styled.div`
min-height: 300px;
display: flex;
flex-direction: column;
`;

View File

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

View File

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

View File

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

View File

@ -1,10 +1,9 @@
// LOC830
import React, { useState, useRef, useEffect, useContext } from 'react';
import React, { useRef, useEffect } from 'react';
import updateApolloCache from 'shared/utils/cache';
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
import styled from 'styled-components/macro';
import AsyncSelect from 'react-select/async';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import GlobalTopNavbar from 'App/TopNavbar';
import ProjectPopup from 'App/TopNavbar/ProjectPopup';
import { usePopup } from 'shared/components/PopupMenu';
import {
useParams,
Route,
@ -23,434 +22,83 @@ import {
useFindProjectQuery,
useDeleteInvitedProjectMemberMutation,
useUpdateTaskNameMutation,
useCreateTaskMutation,
useDeleteTaskMutation,
useUpdateTaskLocationMutation,
useUpdateTaskGroupLocationMutation,
useCreateTaskGroupMutation,
useUpdateTaskDescriptionMutation,
FindProjectDocument,
FindProjectQuery,
} from 'shared/generated/graphql';
import produce from 'immer';
import UserContext, { useCurrentUser } from 'App/context';
import Input from 'shared/components/Input';
import Member from 'shared/components/Member';
import EmptyBoard from 'shared/components/EmptyBoard';
import NOOP from 'shared/utils/noop';
import { Lock, Cross } from 'shared/icons';
import Button from 'shared/components/Button';
import { useApolloClient } from '@apollo/react-hooks';
import TaskAssignee from 'shared/components/TaskAssignee';
import gql from 'graphql-tag';
import { colourStyles } from 'shared/components/Select';
import useStateWithLocalStorage from 'shared/hooks/useStateWithLocalStorage';
import localStorage from 'shared/utils/localStorage';
import Board, { BoardLoading } from './Board';
import Details from './Details';
import LabelManagerEditor from './LabelManagerEditor';
import { mixin } from '../../shared/utils/styles';
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>
);
};
import UserManagementPopup from './UserManagementPopup';
import polling from 'shared/utils/polling';
type TaskRouteProps = {
taskID: string;
};
interface QuickCardEditorState {
isOpen: boolean;
target: React.RefObject<HTMLElement> | null;
taskID: string | null;
taskGroupID: string | null;
}
interface ProjectParams {
projectID: string;
}
const initialQuickCardEditorState: QuickCardEditorState = {
taskID: null,
taskGroupID: null,
isOpen: false,
target: null,
};
const Project = () => {
const { projectID } = useParams<ProjectParams>();
const history = useHistory();
const match = useRouteMatch();
const location = useLocation();
const { showPopup, hidePopup } = usePopup();
const labelsRef = useRef<Array<ProjectLabel>>([]);
const [value, setValue] = useStateWithLocalStorage(localStorage.CARD_LABEL_VARIANT_STORAGE_KEY);
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
const [updateTaskDescription] = useUpdateTaskDescriptionMutation();
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
const [updateTaskName] = useUpdateTaskNameMutation();
const { data, error } = useFindProjectQuery({
variables: { projectID },
pollInterval: polling.PROJECT,
});
const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => {
onCompleted: (newTaskLabel) => {
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
},
});
const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY);
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
const [deleteTask] = useDeleteTaskMutation({
update: (client, resp) =>
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (resp.data) {
const taskGroupIdx = draftCache.findProject.taskGroups.findIndex(
tg => tg.tasks.findIndex(t => t.id === resp.data.deleteTask.taskID) !== -1,
(tg) => tg.tasks.findIndex((t) => t.id === resp.data?.deleteTask.taskID) !== -1,
);
if (taskGroupIdx !== -1) {
draftCache.findProject.taskGroups[taskGroupIdx].tasks = cache.findProject.taskGroups[
taskGroupIdx
].tasks.filter(t => t.id !== resp.data.deleteTask.taskID);
].tasks.filter((t) => t.id !== resp.data?.deleteTask.taskID);
}
}
}),
{ projectID },
),
});
const [updateTaskName] = useUpdateTaskNameMutation();
const { loading, data, error } = useFindProjectQuery({
variables: { projectID },
});
const [updateProjectName] = useUpdateProjectNameMutation({
update: (client, newName) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.name = newName.data.updateProjectName.name;
(cache) =>
produce(cache, (draftCache) => {
draftCache.findProject.name = newName.data?.updateProjectName.name ?? '';
}),
{ projectID },
);
@ -462,8 +110,9 @@ const Project = () => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
draftCache.findProject.members = [
...cache.findProject.members,
...response.data.inviteProjectMembers.members,
@ -472,6 +121,7 @@ const Project = () => {
...cache.findProject.invitedMembers,
...response.data.inviteProjectMembers.invitedMembers,
];
}
}),
{ projectID },
);
@ -482,10 +132,10 @@ const Project = () => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
draftCache.findProject.invitedMembers = cache.findProject.invitedMembers.filter(
m => m.email !== response.data.deleteInvitedProjectMember.invitedMember.email,
(m) => m.email !== response.data?.deleteInvitedProjectMember.invitedMember.email ?? '',
);
}),
{ projectID },
@ -497,10 +147,10 @@ const Project = () => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
draftCache.findProject.members = cache.findProject.members.filter(
m => m.id !== response.data.deleteProjectMember.member.id,
(m) => m.id !== response.data?.deleteProjectMember.member.id,
);
}),
{ projectID },
@ -508,28 +158,12 @@ const Project = () => {
},
});
const { user } = useCurrentUser();
const location = useLocation();
const { showPopup, hidePopup } = usePopup();
const $labelsRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<Array<ProjectLabel>>([]);
useEffect(() => {
if (data) {
document.title = `${data.findProject.name} | Taskcafé`;
}
}, [data]);
if (loading) {
return (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
<BoardLoading />
</>
);
}
if (error) {
history.push('/projects');
}
if (data) {
labelsRef.current = data.findProject.labels;
@ -539,26 +173,26 @@ const Project = () => {
onChangeRole={(userID, roleCode) => {
updateProjectMemberRole({ variables: { userID, roleCode, projectID } });
}}
onChangeProjectOwner={uid => {
onChangeProjectOwner={() => {
hidePopup();
}}
onRemoveFromBoard={userID => {
onRemoveFromBoard={(userID) => {
deleteProjectMember({ variables: { userID, projectID } });
hidePopup();
}}
onRemoveInvitedFromBoard={email => {
onRemoveInvitedFromBoard={(email) => {
deleteInvitedProjectMember({ variables: { projectID, email } });
hidePopup();
}}
onSaveProjectName={projectName => {
onSaveProjectName={(projectName) => {
updateProjectName({ variables: { projectID, name: projectName } });
}}
onInviteUser={$target => {
onInviteUser={($target) => {
showPopup(
$target,
<UserManagementPopup
projectID={projectID}
onInviteProjectMembers={members => {
onInviteProjectMembers={(members) => {
inviteProjectMembers({ variables: { projectID, members } });
hidePopup();
}}
@ -567,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 }]}
currentTab={0}
projectMembers={data.findProject.members}
@ -592,12 +233,12 @@ const Project = () => {
/>
<Route
path={`${match.path}/board/c/:taskID`}
render={(routeProps: RouteComponentProps<TaskRouteProps>) => (
render={() => {
return (
<Details
refreshCache={NOOP}
availableMembers={data.findProject.members}
projectURL={`${match.url}/board`}
taskID={routeProps.match.params.taskID}
onTaskNameChange={(updatedTask, newName) => {
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
}}
@ -614,7 +255,7 @@ const Project = () => {
},
});
}}
onDeleteTask={deletedTask => {
onDeleteTask={(deletedTask) => {
deleteTask({ variables: { taskID: deletedTask.id } });
history.push(`${match.url}/board`);
}}
@ -623,9 +264,10 @@ const Project = () => {
showPopup(
$targetRef,
<LabelManagerEditor
onLabelToggle={labelID => {
onLabelToggle={(labelID) => {
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
}}
taskID={task.id}
labelColors={data.labelColors}
labels={labelsRef}
taskLabels={taskLabelsRef}
@ -634,12 +276,18 @@ const Project = () => {
);
}}
/>
)}
);
}}
/>
</>
);
}
return <div>Error</div>;
return (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
<BoardLoading />
</>
);
};
export default Project;

View File

@ -12,18 +12,19 @@ import {
import { Link } from 'react-router-dom';
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 { usePopup, Popup } from 'shared/components/PopupMenu';
import { useForm } from 'react-hook-form';
import Input from 'shared/components/Input';
import ControlledInput from 'shared/components/ControlledInput';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import NOOP from 'shared/utils/noop';
import theme from 'App/ThemeStyles';
import polling from 'shared/utils/polling';
import { mixin } from '../shared/utils/styles';
type CreateTeamData = { teamName: string };
type CreateTeamData = { name: string };
type CreateTeamFormProps = {
onCreateTeam: (teamName: string) => void;
@ -35,28 +36,30 @@ const CreateTeamButton = styled(Button)`
width: 100%;
`;
const ErrorText = styled.span`
font-size: 14px;
color: ${(props) => props.theme.colors.danger};
`;
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
const { register, handleSubmit } = useForm<CreateTeamData>();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateTeamData>();
const createTeam = (data: CreateTeamData) => {
onCreateTeam(data.teamName);
onCreateTeam(data.name);
};
return (
<CreateTeamFormContainer onSubmit={handleSubmit(createTeam)}>
<Input
width="100%"
label="Team name"
id="teamName"
name="teamName"
variant="alternate"
ref={register({ required: 'Team name is required' })}
/>
{errors.name && <ErrorText>{errors.name.message}</ErrorText>}
<ControlledInput width="100%" label="Team name" variant="alternate" {...register('name')} />
<CreateTeamButton type="submit">Create</CreateTeamButton>
</CreateTeamFormContainer>
);
};
const ProjectAddTile = styled.div`
background-color: ${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-position: 50%;
color: #fff;
@ -70,7 +73,7 @@ const ProjectAddTile = styled.div`
`;
const ProjectTile = styled(Link)<{ color: string }>`
background-color: ${props => props.color};
background-color: ${(props) => props.color};
background-size: cover;
background-position: 50%;
color: #fff;
@ -141,7 +144,7 @@ const ProjectTileName = styled.div<{ centered?: boolean }>`
max-height: 40px;
width: 100%;
word-wrap: break-word;
${props => props.centered && 'text-align: center;'}
${(props) => props.centered && 'text-align: center;'}
`;
const Wrapper = styled.div`
@ -150,6 +153,7 @@ const Wrapper = styled.div`
flex-direction: row;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
`;
const ProjectSectionTitleWrapper = styled.div`
@ -178,7 +182,7 @@ const SectionActionLink = styled(Link)`
const ProjectSectionTitle = styled.h3`
font-size: 16px;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
`;
const ProjectsContainer = styled.div`
@ -202,15 +206,17 @@ type ShowNewProject = {
const Projects = () => {
const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' });
const { loading, data } = useGetProjectsQuery({ pollInterval: polling.PROJECTS, fetchPolicy: 'cache-and-network' });
useEffect(() => {
document.title = 'Taskcafé';
}, []);
const [createProject] = useCreateProjectMutation({
update: (client, newProject) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, (cache) =>
produce(cache, (draftCache) => {
if (newProject.data) {
draftCache.projects.push({ ...newProject.data.createProject });
}
}),
);
},
@ -220,23 +226,22 @@ const Projects = () => {
const { user } = useCurrentUser();
const [createTeam] = useCreateTeamMutation({
update: (client, createData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
draftCache.teams.push({ ...createData.data.createTeam });
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, (cache) =>
produce(cache, (draftCache) => {
if (createData.data) {
draftCache.teams.push({ ...createData.data?.createTeam });
}
}),
);
},
});
if (loading) {
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
}
const colors = theme.colors.multiColors;
if (data && user) {
const { projects, teams, organizations } = data;
const organizationID = organizations[0].id ?? null;
const personalProjects = projects
.filter(p => p.team === null)
.filter((p) => p.team === null)
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
@ -248,12 +253,12 @@ const Projects = () => {
const textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
})
.map(team => {
.map((team) => {
return {
id: team.id,
name: team.name,
projects: projects
.filter(project => project.team && project.team.id === team.id)
.filter((project) => project.team && project.team.id === team.id)
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
@ -266,10 +271,10 @@ const Projects = () => {
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<Wrapper>
<ProjectsContainer>
{user.roles.org === 'admin' && (
{true && ( // TODO: add permision check
<AddTeamButton
variant="outline"
onClick={$target => {
onClick={($target) => {
showPopup(
$target,
<Popup
@ -280,7 +285,7 @@ const Projects = () => {
}}
>
<CreateTeamForm
onCreateTeam={teamName => {
onCreateTeam={(teamName) => {
if (organizationID) {
createTeam({ variables: { name: teamName, organizationID } });
hidePopup();
@ -323,12 +328,12 @@ const Projects = () => {
</ProjectListItem>
</ProjectList>
</div>
{projectTeams.map(team => {
{projectTeams.map((team) => {
return (
<div key={team.id}>
<ProjectSectionTitleWrapper>
<ProjectSectionTitle>{team.name}</ProjectSectionTitle>
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
{true && ( // TODO: add permision check
<SectionActions>
<SectionActionLink to={`/teams/${team.id}`}>
<SectionAction variant="outline">Projects</SectionAction>
@ -353,7 +358,7 @@ const Projects = () => {
</ProjectTile>
</ProjectListItem>
))}
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
{true && ( // TODO: add permision check
<ProjectListItem>
<ProjectAddTile
onClick={() => {
@ -391,7 +396,7 @@ const Projects = () => {
</>
);
}
return <div>Error!</div>;
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
};
export default Projects;

View File

@ -35,7 +35,7 @@ const UsersRegister = () => {
},
}),
})
.then(async x => {
.then(async (x) => {
const response = await x.json();
const { setup } = response;
console.log(response);

View File

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

View File

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

View File

@ -13,7 +13,7 @@ import { usePopup, Popup } from 'shared/components/PopupMenu';
import { History } from 'history';
import produce from 'immer';
import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { PermissionObjectType, PermissionLevel, useCurrentUser } from 'App/context';
import { useCurrentUser } from 'App/context';
import NOOP from 'shared/utils/noop';
import Members from './Members';
import Projects from './Projects';
@ -33,7 +33,7 @@ const Wrapper = styled.div`
`;
type TeamPopupProps = {
history: History<History.PoorMansUnknown>;
history: History<any>;
name: string;
teamID: string;
};
@ -44,9 +44,9 @@ export const TeamPopup: React.FC<TeamPopupProps> = ({ history, name, teamID }) =
update: (client, deleteData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data.deleteTeam.team.id);
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data?.deleteTeam.team.id);
draftCache.projects = cache.projects.filter(
(project: any) => project.team.id !== deleteData.data.deleteTeam.team.id,
(project: any) => project.team.id !== deleteData.data?.deleteTeam.team.id,
);
}),
);
@ -94,27 +94,13 @@ const Teams = () => {
const { user } = useCurrentUser();
const [currentTab, setCurrentTab] = useState(0);
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) {
/*
TODO: re-add permission check
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
return <Redirect to="/" />;
}
*/
return (
<>
<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;

View File

@ -1,34 +1,27 @@
import React from 'react';
import ReactDOM from 'react-dom';
import axios from 'axios';
import createAuthRefreshInterceptor from 'axios-auth-refresh';
import { ApolloProvider } from '@apollo/react-hooks';
import { ApolloClient } from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error';
import { ApolloClient } from '@apollo/client';
import { ApolloProvider } from '@apollo/client/react';
import { enableMapSet } from 'immer';
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import dayjs from 'dayjs';
import updateLocale from 'dayjs/plugin/updateLocale';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween';
import weekday from 'dayjs/plugin/weekday';
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
import cache from './App/cache';
import App from './App';
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
dayjs.extend(isSameOrAfter);
enableMapSet();
dayjs.extend(isSameOrAfter);
dayjs.extend(weekday);
dayjs.extend(isBetween);
dayjs.extend(customParseFormat);
enableMapSet();
dayjs.extend(updateLocale);
dayjs.updateLocale('en', {
week: {
dow: 1, // First day of week is Monday
@ -36,131 +29,8 @@ dayjs.updateLocale('en', {
},
});
let forward$;
let isRefreshing = false;
let pendingRequests: any = [];
const refreshAuthLogic = (failedRequest: any) =>
axios.post('/auth/refresh_token', {}, { withCredentials: true }).then(tokenRefreshResponse => {
setAccessToken(tokenRefreshResponse.data.accessToken);
failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`;
return Promise.resolve();
});
createAuthRefreshInterceptor(axios, refreshAuthLogic);
const resolvePendingRequests = () => {
pendingRequests.map((callback: any) => callback());
pendingRequests = [];
};
const resolvePromise = (resolve: () => void) => {
pendingRequests.push(() => resolve());
};
const resetPendingRequests = () => {
pendingRequests = [];
};
const setRefreshing = (newVal: boolean) => {
isRefreshing = newVal;
};
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
if (err.extensions && err.extensions.code) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
if (!isRefreshing) {
setRefreshing(true);
forward$ = fromPromise(
getNewToken()
.then((response: any) => {
setAccessToken(response.accessToken);
resolvePendingRequests();
return response.accessToken;
})
.catch(() => {
resetPendingRequests();
// TODO
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
return undefined;
})
.finally(() => {
setRefreshing(false);
}),
).filter(value => Boolean(value));
} else {
forward$ = fromPromise(new Promise(resolvePromise));
}
return forward$.flatMap(() => forward(operation));
default:
// pass
}
}
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`); // 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,
});
const client = new ApolloClient({ uri: '/graphql', cache });
console.log('cloient', client);
ReactDOM.render(
<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 TextareaAutosize from 'react-autosize-textarea/lib';
import TextareaAutosize from 'react-autosize-textarea';
import { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button';

View File

@ -49,6 +49,7 @@ export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCa
<ListNameEditorWrapper>
<ListNameEditor
ref={$editorRef}
height={40}
onKeyDown={onKeyDown}
value={listName}
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

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

View File

@ -1,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

@ -10,6 +10,7 @@ export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
z-index: ${props => props.zIndex};
position: relative;
`;
export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'normal' }>`
${props =>
props.color === 'success' &&
@ -18,6 +19,7 @@ export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'no
stroke: ${props.theme.colors.success};
`}
`;
export const ClockIcon = styled(Clock)<{ color: string }>`
fill: ${props => props.color};
`;
@ -26,7 +28,7 @@ export const EditorTextarea = styled(TextareaAutosize)`
overflow: hidden;
overflow-wrap: break-word;
resize: none;
height: 90px;
height: 54px;
width: 100%;
background: none;
@ -227,18 +229,19 @@ export const ListCardOperation = styled.span`
}
`;
export const CardTitle = styled.span`
export const CardTitle = styled.div`
clear: both;
margin: 0 0 4px;
overflow: hidden;
text-decoration: none;
color: ${props => props.theme.colors.text.primary};
display: block;
align-items: center;
`;
export const CardTitleText = styled.span`
word-wrap: break-word;
line-height: 18px;
font-size: 14px;
color: ${props => props.theme.colors.text.primary};
display: flex;
align-items: center;
`;
export const CardMembers = styled.div`
@ -251,6 +254,7 @@ export const CompleteIcon = styled(CheckCircle)`
fill: ${props => props.theme.colors.success};
margin-right: 4px;
flex-shrink: 0;
margin-bottom: -2px;
`;
export const EditorContent = styled.div`

View File

@ -22,6 +22,7 @@ import {
ListCardOperation,
CardTitle,
CardMembers,
CardTitleText,
} from './Styles';
type DueDate = {
@ -57,12 +58,14 @@ type Props = {
onCardTitleChange?: (name: string) => void;
labelVariant?: CardLabelVariant;
toggleLabels?: boolean;
isPublic?: boolean;
toggleDirection?: 'shrink' | 'expand';
};
const Card = React.forwardRef(
(
{
isPublic = false,
wrapperProps,
onContextMenu,
taskID,
@ -119,9 +122,11 @@ const Card = React.forwardRef(
}
};
const onTaskContext = (e: React.MouseEvent) => {
if (!isPublic) {
e.preventDefault();
e.stopPropagation();
onOpenComposer();
}
};
const onOperationClick = (e: React.MouseEvent<HTMLOrSVGElement>) => {
e.preventDefault();
@ -144,7 +149,7 @@ const Card = React.forwardRef(
{...wrapperProps}
>
<ListCardInnerContainer ref={$innerCardRef}>
{isActive && !editable && (
{!isPublic && isActive && !editable && (
<ListCardOperation
onClick={e => {
e.stopPropagation();
@ -210,7 +215,7 @@ const Card = React.forwardRef(
) : (
<CardTitle>
{complete && <CompleteIcon width={16} height={16} />}
{`${title}${position ? ` - ${position}` : ''}`}
<CardTitleText>{`${title}${position ? ` - ${position}` : ''}`}</CardTitleText>
</CardTitle>
)}
<ListCardBadges>

View File

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

View File

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

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

View File

@ -1,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

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

View File

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

View File

@ -1,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;
font-weight: 600;
margin: -4px 0;
&:disabled {
opacity: 1;
}
letter-spacing: normal;
word-spacing: normal;

View File

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

View File

@ -1,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';
export const Container = styled.div`
flex: 1;
user-select: none;
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
::-webkit-scrollbar {
height: 10px;

View File

@ -151,6 +151,7 @@ interface SimpleProps {
onCardMemberClick: OnCardMemberClick;
onCardLabelClick: () => void;
cardLabelVariant: CardLabelVariant;
isPublic?: boolean;
taskStatusFilter?: TaskStatusFilter;
taskMetaFilters?: TaskMetaFilters;
taskSorting?: TaskSorting;
@ -188,6 +189,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
onExtraMenuOpen,
onCardMemberClick,
taskStatusFilter = initTaskStatusFilter,
isPublic = false,
taskMetaFilters = initTaskMetaFilters,
taskSorting = initTaskSorting,
}) => {
@ -300,6 +302,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
onOpenComposer={id => setCurrentComposer(id)}
isComposerOpen={currentComposer === taskGroup.id}
onSaveName={name => onChangeTaskGroupName(taskGroup.id, name)}
isPublic={isPublic}
ref={columnDragProvided.innerRef}
wrapperProps={columnDragProvided.draggableProps}
headerProps={columnDragProvided.dragHandleProps}
@ -328,6 +331,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
<Card
toggleDirection={toggleDirection}
toggleLabels={toggleLabels}
isPublic={isPublic}
labelVariant={cardLabelVariant}
wrapperProps={{
...taskProvided.draggableProps,
@ -391,16 +395,18 @@ const SimpleLists: React.FC<SimpleProps> = ({
</Draggable>
);
})}
<AddList
onSave={listName => {
onCreateTaskGroup(listName);
}}
/>
{provided.placeholder}
</Container>
)}
</Droppable>
</DragDropContext>
{!isPublic && (
<AddList
onSave={listName => {
onCreateTaskGroup(listName);
}}
/>
)}
</BoardWrapper>
</BoardContainer>
);

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

@ -25,7 +25,12 @@ import {
const Login = ({ onSubmit }: LoginProps) => {
const [isComplete, setComplete] = useState(true);
const { register, handleSubmit, errors, setError, formState } = useForm<LoginFormData>();
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<LoginFormData>();
const loginSubmit = (data: LoginFormData) => {
setComplete(false);
onSubmit(data, setComplete, setError);
@ -47,12 +52,7 @@ const Login = ({ onSubmit }: LoginProps) => {
<Form onSubmit={handleSubmit(loginSubmit)}>
<FormLabel htmlFor="username">
Username
<FormTextInput
type="text"
id="username"
name="username"
ref={register({ required: 'Username is required' })}
/>
<FormTextInput type="text" {...register('username', { required: 'Username is required' })} />
<FormIcon>
<User width={20} height={20} />
</FormIcon>
@ -60,12 +60,7 @@ const Login = ({ onSubmit }: LoginProps) => {
{errors.username && <FormError>{errors.username.message}</FormError>}
<FormLabel htmlFor="password">
Password
<FormTextInput
type="password"
id="password"
name="password"
ref={register({ required: 'Password is required' })}
/>
<FormTextInput type="password" {...register('password', { required: 'Password is required' })} />
<FormIcon>
<Lock width={20} height={20} />
</FormIcon>

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

@ -263,7 +263,7 @@ const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose,
onChange={(e: any) => {
setTeam(e.value);
}}
value={options.filter(d => d.value === team)}
value={options.find(d => d.value === team)}
styles={colourStyles}
classNamePrefix="teamSelect"
options={options}

View File

@ -87,6 +87,12 @@ export const HeaderTitle = styled.span`
export const Content = styled.div`
max-height: 632px;
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar-track-piece {
background: ${props => props.theme.colors.bg.primary};
border-radius: 20px;
}
`;
export const LabelSearch = styled(ControlledInput)`

View File

@ -17,7 +17,7 @@ import {
} from './Styles';
function getPopupOptions(options?: PopupOptions) {
const popupOptions = {
const popupOptions: PopupOptionsInternal = {
borders: true,
diamondColor: theme.colors.bg.secondary,
targetPadding: '10px',
@ -40,6 +40,9 @@ function getPopupOptions(options?: PopupOptions) {
if (options.diamondColor) {
popupOptions.diamondColor = options.diamondColor;
}
if (options.onClose) {
popupOptions.onClose = options.onClose;
}
}
return popupOptions;
}
@ -136,6 +139,7 @@ type PopupOptionsInternal = {
targetPadding: string;
diamondColor: string;
showDiamond: boolean;
onClose?: () => void;
};
type PopupOptions = {
@ -144,6 +148,7 @@ type PopupOptions = {
width?: number | null;
borders?: boolean | null;
diamondColor?: string | null;
onClose?: () => void;
};
const defaultState = {
isOpen: false,
@ -239,7 +244,12 @@ export const PopupProvider: React.FC = ({ children }) => {
top={currentState.top}
targetPadding={currentState.options.targetPadding}
left={currentState.left}
onClose={() => setState(defaultState)}
onClose={() => {
if (currentState.options && currentState.options.onClose) {
currentState.options.onClose();
}
setState(defaultState);
}}
width={currentState.options.width}
>
{currentState.content}

View File

@ -1,47 +0,0 @@
import React from 'react';
import styled from 'styled-components';
import ProjectGridItem from '.';
export default {
component: ProjectGridItem,
title: 'ProjectGridItem',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const projectsData = [
{ taskGroups: [], teamTitle: 'Personal', projectID: 'aaaa', name: 'Taskcafé', color: '#aa62e3' },
{ taskGroups: [], teamTitle: 'Personal', projectID: 'bbbb', name: 'Editorial Calender', color: '#aa62e3' },
{ taskGroups: [], teamTitle: 'Personal', projectID: 'cccc', name: 'New Blog', color: '#aa62e3' },
];
const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
`;
const ProjectsWrapper = styled.div`
width: 60%;
display: flex;
align-items: center;
justify-content: center;
`;
export const Default = () => {
return (
<Container>
<ProjectsWrapper>
{projectsData.map(project => (
<ProjectGridItem project={project} />
))}
</ProjectsWrapper>
</Container>
);
};

View File

@ -38,12 +38,17 @@ export const ListSeparator = styled.hr`
`;
type Props = {
publicOn: null | string;
onDeleteProject: () => void;
onToggleProjectVisible: (visible: boolean) => void;
};
const ProjectSettings: React.FC<Props> = ({ onDeleteProject }) => {
const ProjectSettings: React.FC<Props> = ({ publicOn, onDeleteProject, onToggleProjectVisible }) => {
return (
<>
<ListActionsWrapper>
<ListActionItemWrapper onClick={() => onToggleProjectVisible(publicOn === null)}>
<ListActionItem>{`Make ${publicOn === null ? 'public' : 'private'}`}</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper onClick={() => onDeleteProject()}>
<ListActionItem>Delete Project</ListActionItem>
</ListActionItemWrapper>
@ -127,5 +132,18 @@ const DeleteConfirm: React.FC<DeleteConfirmProps> = ({ description, deletedItems
);
};
export { DeleteConfirm };
type PublicConfirmProps = {
onConfirm: () => void;
};
const PublicConfirm: React.FC<PublicConfirmProps> = ({ onConfirm }) => {
return (
<ConfirmWrapper>
<ConfirmDescription>Public projects can be accessed by anyone with a link to the project.</ConfirmDescription>
<ConfirmDeleteButton onClick={() => onConfirm()}>Make public</ConfirmDeleteButton>
</ConfirmWrapper>
);
};
export { DeleteConfirm, PublicConfirm };
export default ProjectSettings;

View File

@ -1,99 +0,0 @@
import React, { createRef, useState } from 'react';
import { action } from '@storybook/addon-actions';
import Card from 'shared/components/Card';
import CardComposer from 'shared/components/CardComposer';
import LabelColors from 'shared/constants/labelColors';
import List, { ListCards } from 'shared/components/List';
import QuickCardEditor from 'shared/components/QuickCardEditor';
import NOOP from 'shared/utils/noop';
export default {
component: QuickCardEditor,
title: 'QuickCardEditor',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const labelData: Array<TaskLabel> = [
{
id: 'development',
assignedDate: new Date().toString(),
projectLabel: {
id: 'development',
name: 'Development',
createdDate: 'date',
labelColor: {
id: 'label-color-blue',
colorHex: LabelColors.BLUE,
name: 'blue',
position: 1,
},
},
},
];
export const Default = () => {
const $cardRef: any = createRef();
const task: Task = {
id: 'task',
name: 'Hello, world!',
position: 1,
labels: labelData,
taskGroup: {
id: '1',
},
};
const [isEditorOpen, setEditorOpen] = useState(false);
const [target, setTarget] = useState<null | React.RefObject<HTMLElement>>(null);
const [top, setTop] = useState(0);
const [left, setLeft] = useState(0);
return (
<>
{isEditorOpen && target && (
<QuickCardEditor
task={task}
onCloseEditor={() => setEditorOpen(false)}
onEditCard={action('edit card')}
onOpenLabelsPopup={action('open popup')}
onOpenDueDatePopup={action('open popup')}
onOpenMembersPopup={action('open popup')}
onToggleComplete={action('complete')}
onArchiveCard={action('archive card')}
target={target}
/>
)}
<List
id="1"
name="General"
isComposerOpen={false}
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
onExtraMenuOpen={NOOP}
>
<ListCards>
<Card
taskID="1"
taskGroupID="1"
description="hello!"
ref={$cardRef}
title={task.name}
onClick={action('on click')}
onContextMenu={() => {
setTarget($cardRef);
setEditorOpen(true);
}}
watched
labels={labelData.map(l => l.projectLabel)}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
<CardComposer onClose={NOOP} onCreateCard={NOOP} isOpen={false} />
</ListCards>
</List>
</>
);
};

View File

@ -26,7 +26,12 @@ const INITIALS_PATTERN = /[a-zA-Z]{2,3}/i;
const Register = ({ onSubmit, registered = false }: RegisterProps) => {
const [isComplete, setComplete] = useState(true);
const { register, handleSubmit, errors, setError } = useForm<RegisterFormData>();
const {
register,
handleSubmit,
formState: { errors },
setError,
} = useForm<RegisterFormData>();
const loginSubmit = (data: RegisterFormData) => {
setComplete(false);
onSubmit(data, setComplete, setError);
@ -55,12 +60,7 @@ const Register = ({ onSubmit, registered = false }: RegisterProps) => {
<Form onSubmit={handleSubmit(loginSubmit)}>
<FormLabel htmlFor="fullname">
Full name
<FormTextInput
type="text"
id="fullname"
name="fullname"
ref={register({ required: 'Full name is required' })}
/>
<FormTextInput type="text" {...register('fullname', { required: 'Full name is required' })} />
<FormIcon>
<User width={20} height={20} />
</FormIcon>
@ -68,12 +68,7 @@ const Register = ({ onSubmit, registered = false }: RegisterProps) => {
{errors.username && <FormError>{errors.username.message}</FormError>}
<FormLabel htmlFor="username">
Username
<FormTextInput
type="text"
id="username"
name="username"
ref={register({ required: 'Username is required' })}
/>
<FormTextInput type="text" {...register('username', { required: 'Username is required' })} />
<FormIcon>
<User width={20} height={20} />
</FormIcon>
@ -83,9 +78,7 @@ const Register = ({ onSubmit, registered = false }: RegisterProps) => {
Email
<FormTextInput
type="text"
id="email"
name="email"
ref={register({
{...register('email', {
required: 'Email is required',
pattern: { value: EMAIL_PATTERN, message: 'Must be a valid email' },
})}
@ -99,9 +92,7 @@ const Register = ({ onSubmit, registered = false }: RegisterProps) => {
Initials
<FormTextInput
type="text"
id="initials"
name="initials"
ref={register({
{...register('initials', {
required: 'Initials is required',
pattern: {
value: INITIALS_PATTERN,
@ -116,12 +107,7 @@ const Register = ({ onSubmit, registered = false }: RegisterProps) => {
{errors.initials && <FormError>{errors.initials.message}</FormError>}
<FormLabel htmlFor="password">
Password
<FormTextInput
type="password"
id="password"
name="password"
ref={register({ required: 'Password is required' })}
/>
<FormTextInput type="password" {...register('password', { required: 'Password is required' })} />
<FormIcon>
<Lock width={20} height={20} />
</FormIcon>
@ -131,9 +117,7 @@ const Register = ({ onSubmit, registered = false }: RegisterProps) => {
Password (Confirm)
<FormTextInput
type="password"
id="password_confirm"
name="password_confirm"
ref={register({ required: 'Password (confirm) is required' })}
{...register('password_confirm', { required: 'Password (confirm) is required' })}
/>
<FormIcon>
<Lock width={20} height={20} />

View File

@ -84,6 +84,58 @@ export const colourStyles = {
},
};
export const editorColourStyles = {
...colourStyles,
input: (styles: any) => ({
...styles,
color: '#000',
}),
singleValue: (styles: any) => {
return {
...styles,
color: '#000',
};
},
menu: (styles: any) => {
return {
...styles,
backgroundColor: '#fff',
};
},
indicatorsContainer: (styles: any) => {
return {
...styles,
display: 'none',
};
},
container: (styles: any) => {
return {
...styles,
display: 'flex',
flex: '1 1',
};
},
control: (styles: any, data: any) => {
return {
...styles,
flex: '1 1',
backgroundColor: 'transparent',
boxShadow: 'none',
borderRadius: '0',
minHeight: '35px',
border: '0',
':hover': {
boxShadow: 'none',
borderRadius: '0',
},
':active': {
boxShadow: 'none',
borderRadius: '0',
},
};
},
};
const InputLabel = styled.span<{ width: string }>`
width: ${props => props.width};
padding-left: 0.7rem;

View File

@ -1,37 +0,0 @@
import React, { createRef, useState } from 'react';
import { action } from '@storybook/addon-actions';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import Settings from '.';
export default {
component: Settings,
title: 'Settings',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const profile = {
id: '1',
fullName: 'Jordan Knott',
username: 'jordanthedev',
profileIcon: { url: '/uploads/headshot.png', bgColor: '#000', initials: 'JK' },
};
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<Settings
profile={profile}
onChangeUserInfo={action('change user info')}
onResetPassword={action('reset password')}
onProfileAvatarRemove={action('remove')}
onProfileAvatarChange={action('profile avatar change')}
/>
</>
);
};

View File

@ -1,23 +1,23 @@
import React, { useState, useRef } from 'react';
import styled from 'styled-components';
import { User } from 'shared/icons';
import Input from 'shared/components/Input';
import Button from 'shared/components/Button';
import { useForm } from 'react-hook-form';
import ControlledInput from 'shared/components/ControlledInput';
const PasswordInput = styled(Input)`
const PasswordInput = styled(ControlledInput)`
margin-top: 30px;
margin-bottom: 0;
`;
const UserInfoInput = styled(Input)`
const UserInfoInput = styled(ControlledInput)`
margin-top: 30px;
margin-bottom: 0;
`;
const FormError = styled.span`
font-size: 12px;
color: ${props => props.theme.colors.warning};
color: ${(props) => props.theme.colors.warning};
`;
const ProfileContainer = styled.div`
@ -42,7 +42,7 @@ const AvatarMask = styled.div<{ background: string }>`
width: 100%;
height: 100%;
overflow: hidden;
background: ${props => props.background};
background: ${(props) => props.background};
border-radius: 50%;
display: flex;
align-items: center;
@ -152,12 +152,12 @@ const TabNavItemButton = styled.button<{ active: boolean }>`
width: 100%;
position: relative;
color: ${props => (props.active ? `${props.theme.colors.primary}` : '#c2c6dc')};
color: ${(props) => (props.active ? `${props.theme.colors.primary}` : '#c2c6dc')};
&:hover {
color: ${props => props.theme.colors.primary};
color: ${(props) => props.theme.colors.primary};
}
&:hover svg {
fill: ${props => props.theme.colors.primary};
fill: ${(props) => props.theme.colors.primary};
}
`;
@ -173,10 +173,14 @@ const TabNavLine = styled.span<{ top: number }>`
width: 2px;
height: 48px;
transform: scaleX(1);
top: ${props => props.top}px;
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};
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;
@ -267,36 +271,36 @@ type ResetPasswordTabProps = {
};
const ResetPasswordTab: React.FC<ResetPasswordTabProps> = ({ onResetPassword }) => {
const [active, setActive] = useState(true);
const { register, handleSubmit, errors, setError, reset } = useForm<{ password: string; password_confirm: string }>();
const {
register,
handleSubmit,
formState: { errors },
setError,
reset,
} = useForm<{ password: string; passwordConfirm: string }>();
const done = () => {
reset();
setActive(true);
};
return (
<form
onSubmit={handleSubmit(data => {
if (data.password !== data.password_confirm) {
onSubmit={handleSubmit((data) => {
if (data.password !== data.passwordConfirm) {
setError('password', { message: 'Passwords must match!', type: 'error' });
setError('password_confirm', { message: 'Passwords must match!', type: 'error' });
setError('passwordConfirm', { message: 'Passwords must match!', type: 'error' });
} else {
onResetPassword(data.password, done);
}
})}
>
<PasswordInput
width="100%"
ref={register({ required: 'Password is required' })}
label="Password"
name="password"
/>
<PasswordInput width="100%" {...register('password', { required: 'Password is required' })} label="Password" />
{errors.password && <FormError>{errors.password.message}</FormError>}
<PasswordInput
width="100%"
ref={register({ required: 'Password is required' })}
{...register('passwordConfirm', { required: 'Password is required' })}
label="Password (confirm)"
name="password_confirm"
/>
{errors.password_confirm && <FormError>{errors.password_confirm.message}</FormError>}
{errors.passwordConfirm && <FormError>{errors.passwordConfirm.message}</FormError>}
<SettingActions>
<SaveButton disabled={!active} type="submit">
Save Change
@ -329,7 +333,11 @@ const UserInfoTab: React.FC<UserInfoTabProps> = ({
onChangeUserInfo,
}) => {
const [active, setActive] = useState(true);
const { register, handleSubmit, errors } = useForm<UserInfoData>();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<UserInfoData>();
const done = () => {
setActive(true);
};
@ -341,14 +349,13 @@ const UserInfoTab: React.FC<UserInfoTabProps> = ({
profile={profile.profileIcon}
/>
<form
onSubmit={handleSubmit(data => {
onSubmit={handleSubmit((data) => {
setActive(false);
onChangeUserInfo(data, done);
})}
>
<UserInfoInput
ref={register({ required: 'Full name is required' })}
name="full_name"
{...register('full_name', { required: 'Full name is required' })}
defaultValue={profile.fullName}
width="100%"
label="Name"
@ -356,11 +363,10 @@ const UserInfoTab: React.FC<UserInfoTabProps> = ({
{errors.full_name && <FormError>{errors.full_name.message}</FormError>}
<UserInfoInput
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
ref={register({
{...register('initials', {
required: 'Initials is required',
pattern: { value: INITIALS_PATTERN, message: 'Intials must be between two to four characters' },
})}
name="initials"
width="100%"
label="Initials "
/>
@ -368,8 +374,7 @@ const UserInfoTab: React.FC<UserInfoTabProps> = ({
<UserInfoInput disabled defaultValue={profile.username ?? ''} width="100%" label="Username " />
<UserInfoInput
width="100%"
name="email"
ref={register({
{...register('email', {
required: 'Email is required',
pattern: { value: EMAIL_PATTERN, message: 'Must be a valid email' },
})}
@ -377,7 +382,7 @@ const UserInfoTab: React.FC<UserInfoTabProps> = ({
label="Email"
/>
{errors.email && <FormError>{errors.email.message}</FormError>}
<UserInfoInput width="100%" name="bio" ref={register()} defaultValue={profile.bio ?? ''} label="Bio" />
<UserInfoInput width="100%" {...register('bio')} defaultValue={profile.bio ?? ''} label="Bio" />
{errors.bio && <FormError>{errors.bio.message}</FormError>}
<SettingActions>
<SaveButton disabled={!active} type="submit">

View File

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

View File

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

View File

@ -0,0 +1,53 @@
import React from 'react';
import styled from 'styled-components';
import { TaskActivityData, ActivityType } from 'shared/generated/graphql';
import dayjs from 'dayjs';
type ActivityMessageProps = {
type: ActivityType;
data: Array<TaskActivityData>;
};
function getVariable(data: Array<TaskActivityData>, name: string) {
const target = data.find(d => d.name === name);
return target ? target.value : null;
}
function renderDate(timestamp: string | null) {
if (timestamp) {
return dayjs(timestamp).format('MMM D [at] h:mm A');
}
return null;
}
const ActivityMessage: React.FC<ActivityMessageProps> = ({ type, data }) => {
let message = '';
switch (type) {
case ActivityType.TaskAdded:
message = `added this task to ${getVariable(data, 'TaskGroup')}`;
break;
case ActivityType.TaskMoved:
message = `moved this task from ${getVariable(data, 'PrevTaskGroup')} to ${getVariable(data, 'CurTaskGroup')}`;
break;
case ActivityType.TaskDueDateAdded:
message = `set this task to be due ${renderDate(getVariable(data, 'DueDate'))}`;
break;
case ActivityType.TaskDueDateRemoved:
message = `removed the due date from this task`;
break;
case ActivityType.TaskDueDateChanged:
message = `changed the due date of this task to ${renderDate(getVariable(data, 'CurDueDate'))}`;
break;
case ActivityType.TaskMarkedComplete:
message = `marked this task complete`;
break;
case ActivityType.TaskMarkedIncomplete:
message = `marked this task incomplete`;
break;
default:
message = '<unknown type>';
}
return <>{message}</>;
};
export default ActivityMessage;

View File

@ -0,0 +1,135 @@
import React, { useRef, useState, useEffect } from 'react';
import { usePopup } from 'shared/components/PopupMenu';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { At, Paperclip, Smile } from 'shared/icons';
import { Picker, Emoji } from 'emoji-mart';
import Task from 'shared/icons/Task';
import {
CommentTextArea,
CommentEditorContainer,
CommentEditorActions,
CommentEditorActionIcon,
CommentEditorSaveButton,
CommentProfile,
CommentInnerWrapper,
} from './Styles';
type CommentCreatorProps = {
me?: TaskUser;
autoFocus?: boolean;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
message?: string | null;
onCreateComment: (message: string) => void;
onCancelEdit?: () => void;
disabled?: boolean;
};
const CommentCreator: React.FC<CommentCreatorProps> = ({
me,
disabled = false,
message,
onMemberProfile,
onCreateComment,
onCancelEdit,
autoFocus = false,
}) => {
const $commentWrapper = useRef<HTMLDivElement>(null);
const $comment = useRef<HTMLTextAreaElement>(null);
const $emoji = useRef<HTMLDivElement>(null);
const $emojiCart = useRef<HTMLDivElement>(null);
const [comment, setComment] = useState(message ?? '');
const [showCommentActions, setShowCommentActions] = useState(autoFocus);
const { showPopup, hidePopup } = usePopup();
useEffect(() => {
if (autoFocus && $comment && $comment.current) {
$comment.current.select();
}
}, []);
useOnOutsideClick(
[$commentWrapper, $emojiCart],
showCommentActions,
() => {
if (onCancelEdit) {
onCancelEdit();
}
setShowCommentActions(false);
},
null,
);
return (
<CommentInnerWrapper ref={$commentWrapper}>
{me && onMemberProfile && (
<CommentProfile
member={me}
size={32}
onMemberProfile={$target => {
onMemberProfile($target, me.id);
}}
/>
)}
<CommentEditorContainer>
<CommentTextArea
$showCommentActions={showCommentActions}
placeholder="Write a comment..."
ref={$comment}
disabled={disabled}
value={comment}
onChange={e => setComment(e.currentTarget.value)}
onFocus={() => {
setShowCommentActions(true);
}}
/>
<CommentEditorActions visible={showCommentActions}>
<CommentEditorActionIcon>
<Paperclip width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<At width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon
ref={$emoji}
onClick={() => {
showPopup(
$emoji,
<div ref={$emojiCart}>
<Picker
onClick={emoji => {
if ($comment && $comment.current) {
const textToInsert = `${emoji.colons} `;
const cursorPosition = $comment.current.selectionStart;
const textBeforeCursorPosition = $comment.current.value.substring(0, cursorPosition);
const textAfterCursorPosition = $comment.current.value.substring(
cursorPosition,
$comment.current.value.length,
);
setComment(textBeforeCursorPosition + textToInsert + textAfterCursorPosition);
}
hidePopup();
}}
set="google"
/>
</div>,
);
}}
>
<Smile width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<Task width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorSaveButton
onClick={() => {
setShowCommentActions(false);
onCreateComment(comment);
setComment('');
}}
>
Save
</CommentEditorSaveButton>
</CommentEditorActions>
</CommentEditorContainer>
</CommentInnerWrapper>
);
};
export default CommentCreator;

View File

@ -0,0 +1,170 @@
import React, { useState, useRef } from 'react';
import {
Plus,
User,
Trash,
Paperclip,
Clone,
Share,
Tags,
Checkmark,
CheckSquareOutline,
At,
Smile,
} from 'shared/icons';
import { toArray } from 'react-emoji-render';
import DOMPurify from 'dompurify';
import TaskAssignee from 'shared/components/TaskAssignee';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { usePopup } from 'shared/components/PopupMenu';
import CommentCreator from 'shared/components/TaskDetails/CommentCreator';
import { AngleDown } from 'shared/icons/AngleDown';
import Editor from 'rich-markdown-editor';
import dark from 'shared/utils/editorTheme';
import styled from 'styled-components';
import ReactMarkdown from 'react-markdown';
import { Picker, Emoji } from 'emoji-mart';
import 'emoji-mart/css/emoji-mart.css';
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
import dayjs from 'dayjs';
import Task from 'shared/icons/Task';
import {
ActivityItemHeader,
ActivityItemTimestamp,
ActivityItem,
ActivityItemCommentAction,
ActivityItemCommentActions,
TaskDetailLabel,
CommentContainer,
ActivityItemCommentContainer,
MetaDetailContent,
TaskDetailsAddLabelIcon,
ActionButton,
AssignUserIcon,
AssignUserLabel,
AssignUsersButton,
AssignedUsersSection,
ViewRawButton,
DueDateTitle,
Container,
LeftSidebar,
SidebarSkeleton,
ContentContainer,
LeftSidebarContent,
LeftSidebarSection,
SidebarTitle,
SidebarButton,
SidebarButtonText,
MarkCompleteButton,
HeaderContainer,
HeaderLeft,
HeaderInnerContainer,
TaskDetailsTitleWrapper,
TaskDetailsTitle,
ExtraActionsSection,
HeaderRight,
HeaderActionIcon,
EditorContainer,
InnerContentContainer,
DescriptionContainer,
Labels,
ChecklistSection,
MemberList,
TaskMember,
TabBarSection,
TabBarItem,
ActivitySection,
TaskDetailsEditor,
ActivityItemHeaderUser,
ActivityItemHeaderTitle,
ActivityItemHeaderTitleName,
ActivityItemComment,
} from './Styles';
import { useCurrentUser } from 'App/context';
type TaskDetailsProps = {};
const TaskDetailsLoading: React.FC<TaskDetailsProps> = () => {
const { user } = useCurrentUser();
return (
<Container>
<LeftSidebar>
<LeftSidebarContent>
<LeftSidebarSection>
<SidebarTitle>TASK GROUP</SidebarTitle>
<SidebarButton $loading>
<SidebarSkeleton />
</SidebarButton>
<DueDateTitle>DUE DATE</DueDateTitle>
<SidebarButton $loading>
<SidebarSkeleton />
</SidebarButton>
</LeftSidebarSection>
<AssignedUsersSection>
<DueDateTitle>MEMBERS</DueDateTitle>
<SidebarButton $loading>
<SidebarSkeleton />
</SidebarButton>
</AssignedUsersSection>
{user && (
<ExtraActionsSection>
<DueDateTitle>ACTIONS</DueDateTitle>
<ActionButton disabled icon={<Tags width={12} height={12} />}>
Labels
</ActionButton>
<ActionButton disabled icon={<CheckSquareOutline width={12} height={12} />}>
Checklist
</ActionButton>
<ActionButton disabled>Cover</ActionButton>
</ExtraActionsSection>
)}
</LeftSidebarContent>
</LeftSidebar>
<ContentContainer>
<HeaderContainer>
<HeaderInnerContainer>
<HeaderLeft>
<MarkCompleteButton disabled invert={false}>
<Checkmark width={8} height={8} />
<span>Mark complete</span>
</MarkCompleteButton>
</HeaderLeft>
{user && (
<HeaderRight>
<HeaderActionIcon>
<Paperclip width={16} height={16} />
</HeaderActionIcon>
<HeaderActionIcon>
<Clone width={16} height={16} />
</HeaderActionIcon>
<HeaderActionIcon>
<Share width={16} height={16} />
</HeaderActionIcon>
<HeaderActionIcon>
<Trash width={16} height={16} />
</HeaderActionIcon>
</HeaderRight>
)}
</HeaderInnerContainer>
<TaskDetailsTitleWrapper $loading>
<TaskDetailsTitle value="" disabled $loading />
</TaskDetailsTitleWrapper>
</HeaderContainer>
<InnerContentContainer>
<DescriptionContainer />
<TabBarSection>
<TabBarItem>Activity</TabBarItem>
</TabBarSection>
<ActivitySection />
</InnerContentContainer>
{user && (
<CommentContainer>
<CommentCreator disabled onCreateComment={() => null} onMemberProfile={() => null} />
</CommentContainer>
)}
</ContentContainer>
</Container>
);
};
export default TaskDetailsLoading;

View File

@ -1,8 +1,9 @@
import styled, { css } from 'styled-components';
import styled, { css, keyframes } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea';
import { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button';
import TaskAssignee from 'shared/components/TaskAssignee';
import theme from 'App/ThemeStyles';
export const Container = styled.div`
display: flex;
@ -16,7 +17,7 @@ export const LeftSidebar = styled.div`
background: #222740;
`;
export const MarkCompleteButton = styled.button<{ invert: boolean }>`
export const MarkCompleteButton = styled.button<{ invert: boolean; disabled?: boolean }>`
padding: 4px 8px;
position: relative;
border: none;
@ -62,6 +63,11 @@ export const MarkCompleteButton = styled.button<{ invert: boolean }>`
color: ${props.theme.colors.success};
}
`}
${props =>
props.invert &&
css`
opacity: 0.6;
`}
`;
export const LeftSidebarContent = styled.div`
@ -89,24 +95,55 @@ export const SidebarTitle = styled.div`
text-transform: uppercase;
`;
export const SidebarButton = styled.div`
export const defaultBaseColor = theme.colors.bg.primary;
export const defaultHighlightColor = mixin.lighten(theme.colors.bg.primary, 0.25);
export const skeletonKeyframes = keyframes`
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
`;
export const SidebarButton = styled.div<{ $loading?: boolean }>`
font-size: 14px;
color: ${props => props.theme.colors.text.primary};
min-height: 32px;
width: 100%;
padding: 9px 8px 7px 8px;
border-color: transparent;
border-radius: 6px;
${props =>
props.$loading
? css`
background: ${props.theme.colors.bg.primary};
`
: css`
padding: 9px 8px 7px 8px;
cursor: pointer;
border-color: transparent;
border-width: 1px;
border-style: solid;
display: inline-block;
outline: 0;
cursor: pointer;
&:hover {
border-color: #414561;
}
`};
display: inline-block;
outline: 0;
`;
export const SidebarSkeleton = styled.div`
background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor});
background-size: 200px 100%;
background-repeat: no-repeat;
border-radius: 6px;
padding: 1px;
animation: ${skeletonKeyframes} 1.2s ease-in-out infinite;
width: 100%;
height: 100%;
`;
export const SidebarButtonText = styled.span`
@ -141,18 +178,18 @@ export const HeaderLeft = styled.div`
justify-content: flex-start;
`;
export const TaskDetailsTitleWrapper = styled.div`
export const TaskDetailsTitleWrapper = styled.div<{ $loading?: boolean }>`
width: 100%;
margin: 8px 0 4px 0;
display: inline-block;
display: flex;
border-radius: 6px;
${props => props.$loading && `background: ${props.theme.colors.bg.primary};`}
`;
export const TaskDetailsTitle = styled(TextareaAutosize)`
export const TaskDetailsTitle = styled(TextareaAutosize)<{ $loading?: boolean }>`
padding: 9px 8px 7px 8px;
border-color: transparent;
border-radius: 6px;
border-width: 1px;
border-style: solid;
width: 100%;
color: #c2c6dc;
display: inline-block;
@ -161,13 +198,28 @@ export const TaskDetailsTitle = styled(TextareaAutosize)`
font-weight: 700;
background: none;
&:hover {
&:disabled {
opacity: 1;
}
${props =>
props.$loading
? css`
background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor});
background-size: 200px 100%;
background-repeat: no-repeat;
animation: ${skeletonKeyframes} 1.2s ease-in-out infinite;
`
: css`
&:not(:disabled):hover {
border-color: #414561;
border-width: 1px;
border-style: solid;
}
&:focus {
border-color: ${props => props.theme.colors.primary};
border-color: ${props.theme.colors.primary};
}
`}
`;
export const DueDateTitle = styled.div`
@ -475,6 +527,7 @@ export const CommentEditorContainer = styled.div`
border: 1px solid #414561;
display: flex;
flex-direction: column;
background: #1f243e;
`;
export const CommentProfile = styled(TaskAssignee)`
margin-right: 8px;
@ -484,7 +537,7 @@ export const CommentProfile = styled(TaskAssignee)`
align-items: normal;
`;
export const CommentTextArea = styled(TextareaAutosize)`
export const CommentTextArea = styled(TextareaAutosize)<{ $showCommentActions: boolean }>`
width: 100%;
line-height: 28px;
padding: 4px 6px;
@ -495,14 +548,16 @@ export const CommentTextArea = styled(TextareaAutosize)`
transition: max-height 200ms, height 200ms, min-height 200ms;
min-height: 36px;
max-height: 36px;
&:not(:focus) {
height: 36px;
}
&:focus {
${props =>
props.$showCommentActions
? css`
min-height: 80px;
max-height: none;
line-height: 20px;
}
`
: css`
height: 36px;
`}
`;
export const CommentEditorActions = styled.div<{ visible: boolean }>`
@ -529,6 +584,18 @@ export const ActivitySection = styled.div`
overflow-x: hidden;
padding: 8px 26px;
display: flex;
flex-direction: column-reverse;
`;
export const ActivityItemCommentAction = styled.div`
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
svg {
fill: ${props => props.theme.colors.text.primary} !important;
}
`;
export const ActivityItem = styled.div`
@ -537,25 +604,32 @@ export const ActivityItem = styled.div`
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
display: flex;
&:hover ${ActivityItemCommentAction} {
display: flex;
}
`;
export const ActivityItemHeader = styled.div`
export const ActivityItemHeader = styled.div<{ editable?: boolean }>`
display: flex;
flex-direction: column;
padding-left: 8px;
${props => props.editable && 'width: 100%;'}
`;
export const ActivityItemHeaderUser = styled(TaskAssignee)`
margin-right: 4px;
align-items: start;
`;
export const ActivityItemHeaderTitle = styled.div`
margin-left: 4px;
line-height: 18px;
display: flex;
align-items: center;
color: ${props => props.theme.colors.text.primary};
padding-bottom: 2px;
`;
export const ActivityItemHeaderTitleName = styled.span`
color: ${props => props.theme.colors.text.primary};
font-weight: 500;
padding-right: 3px;
`;
export const ActivityItemTimestamp = styled.span<{ margin: number }>`
@ -568,8 +642,10 @@ export const ActivityItemDetails = styled.div`
margin-left: 32px;
`;
export const ActivityItemComment = styled.div`
export const ActivityItemCommentContainer = styled.div``;
export const ActivityItemComment = styled.div<{ editable: boolean }>`
display: inline-flex;
flex-direction: column;
border-radius: 3px;
${mixin.boxShadowCard}
position: relative;
@ -577,6 +653,32 @@ export const ActivityItemComment = styled.div`
padding: 8px 12px;
margin: 4px 0;
background-color: ${props => mixin.darken(props.theme.colors.alternate, 0.1)};
${props => props.editable && 'width: 100%;'}
& span {
display: inline-flex;
align-items: center;
}
& ul {
list-style-type: disc;
margin: 8px 0;
}
& ul > li {
margin: 8px 8px 8px 24px;
margin-inline-start: 24px;
margin-inline-end: 8px;
}
& ul > li ul > li {
list-style: circle;
}
`;
export const ActivityItemCommentActions = styled.div`
display: flex;
align-items: center;
position: absolute;
top: 8px;
right: 0;
`;
export const ActivityItemLog = styled.span`

View File

@ -1,86 +0,0 @@
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import Modal from 'shared/components/Modal';
import TaskDetails from '.';
export default {
component: TaskDetails,
title: 'TaskDetails',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#cdd3e1', default: true },
],
},
};
export const Default = () => {
const [description, setDescription] = useState('');
return (
<>
<NormalizeStyles />
<BaseStyles />
<Modal
width={1040}
onClose={action('on close')}
renderContent={() => {
return (
<TaskDetails
onDeleteItem={action('delete item')}
onChangeItemName={action('change item name')}
task={{
id: '1',
taskGroup: { name: 'General', id: '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,
assigned: [
{
id: '1',
profileIcon: { bgColor: null, url: null, initials: null },
fullName: 'Jordan Knott',
},
],
}}
onTaskNameChange={action('task name change')}
onTaskDescriptionChange={(_task, desc) => setDescription(desc)}
onDeleteTask={action('delete task')}
onCloseModal={action('close modal')}
onMemberProfile={action('profile')}
onOpenAddMemberPopup={action('open add member popup')}
onAddItem={action('add item')}
onToggleTaskComplete={action('toggle task complete')}
onToggleChecklistItem={action('toggle checklist item')}
onOpenAddLabelPopup={action('open add label popup')}
onChangeChecklistName={action('change checklist name')}
onDeleteChecklist={action('delete checklist')}
onOpenAddChecklistPopup={action(' open checklist')}
onOpenDueDatePopop={action('open due date popup')}
onChecklistDrop={action('on checklist drop')}
onChecklistItemDrop={action('on checklist item drop')}
/>
);
}}
/>
</>
);
};

View File

@ -12,17 +12,31 @@ import {
At,
Smile,
} from 'shared/icons';
import { toArray } from 'react-emoji-render';
import DOMPurify from 'dompurify';
import TaskAssignee from 'shared/components/TaskAssignee';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { usePopup } from 'shared/components/PopupMenu';
import CommentCreator from 'shared/components/TaskDetails/CommentCreator';
import { AngleDown } from 'shared/icons/AngleDown';
import Editor from 'rich-markdown-editor';
import dark from 'shared/utils/editorTheme';
import styled from 'styled-components';
import ReactMarkdown from 'react-markdown';
import { Picker, Emoji } from 'emoji-mart';
import 'emoji-mart/css/emoji-mart.css';
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
import dayjs from 'dayjs';
import Task from 'shared/icons/Task';
import {
ActivityItemHeader,
ActivityItemTimestamp,
ActivityItem,
ActivityItemCommentAction,
ActivityItemCommentActions,
TaskDetailLabel,
CommentContainer,
ActivityItemCommentContainer,
MetaDetailContent,
TaskDetailsAddLabelIcon,
ActionButton,
@ -58,18 +72,128 @@ import {
TaskMember,
TabBarSection,
TabBarItem,
CommentTextArea,
CommentEditorContainer,
CommentEditorActions,
CommentEditorActionIcon,
CommentEditorSaveButton,
CommentProfile,
CommentInnerWrapper,
ActivitySection,
TaskDetailsEditor,
ActivityItemHeaderUser,
ActivityItemHeaderTitle,
ActivityItemHeaderTitleName,
ActivityItemComment,
} from './Styles';
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
import onDragEnd from './onDragEnd';
import { plugin as em } from './remark';
import ActivityMessage from './ActivityMessage';
import { useCurrentUser } from 'App/context';
const parseEmojis = (value: string) => {
const emojisArray = toArray(value);
// toArray outputs React elements for emojis and strings for other
const newValue = emojisArray.reduce((previous: any, current: any) => {
if (typeof current === 'string') {
return previous + current;
}
return previous + current.props.children;
}, '');
return newValue;
};
type StreamCommentProps = {
comment?: TaskComment | null;
onUpdateComment: (message: string) => void;
onExtraActions: (commentID: string, $target: React.RefObject<HTMLElement>) => void;
onCancelCommentEdit: () => void;
editable: boolean;
};
const StreamComment: React.FC<StreamCommentProps> = ({
comment,
onExtraActions,
editable,
onUpdateComment,
onCancelCommentEdit,
}) => {
const $actions = useRef<HTMLDivElement>(null);
if (comment) {
return (
<ActivityItem>
<ActivityItemHeaderUser size={32} member={comment.createdBy} />
<ActivityItemHeader editable={editable}>
<ActivityItemHeaderTitle>
<ActivityItemHeaderTitleName>{comment.createdBy.fullName}</ActivityItemHeaderTitleName>
<ActivityItemTimestamp margin={8}>
{dayjs(comment.createdAt).format('MMM D [at] h:mm A')}
{comment.updatedAt && ' (edited)'}
</ActivityItemTimestamp>
</ActivityItemHeaderTitle>
<ActivityItemCommentContainer>
<ActivityItemComment editable={editable}>
{editable ? (
<CommentCreator
message={comment.message}
autoFocus
onCancelEdit={onCancelCommentEdit}
onCreateComment={onUpdateComment}
/>
) : (
<ReactMarkdown skipHtml plugins={[em]}>
{DOMPurify.sanitize(comment.message, { FORBID_TAGS: ['style', 'img'] })}
</ReactMarkdown>
)}
</ActivityItemComment>
<ActivityItemCommentActions>
<ActivityItemCommentAction
ref={$actions}
onClick={() => {
onExtraActions(comment.id, $actions);
}}
>
<AngleDown width={18} height={18} />
</ActivityItemCommentAction>
</ActivityItemCommentActions>
</ActivityItemCommentContainer>
</ActivityItemHeader>
</ActivityItem>
);
}
return null;
};
type StreamActivityProps = {
activity?: TaskActivity | null;
};
const StreamActivity: React.FC<StreamActivityProps> = ({ activity }) => {
if (activity) {
return (
<ActivityItem>
<ActivityItemHeaderUser
size={32}
member={{
id: activity.causedBy.id,
fullName: activity.causedBy.fullName,
profileIcon: activity.causedBy.profileIcon
? activity.causedBy.profileIcon
: {
url: null,
initials: activity.causedBy.fullName.charAt(0),
bgColor: '#fff',
},
}}
/>
<ActivityItemHeader>
<ActivityItemHeaderTitle>
<ActivityItemHeaderTitleName>{activity.causedBy.fullName}</ActivityItemHeaderTitleName>
<ActivityMessage type={activity.type} data={activity.data} />
</ActivityItemHeaderTitle>
<ActivityItemTimestamp margin={0}>
{dayjs(activity.createdAt).format('MMM D [at] h:mm A')}
</ActivityItemTimestamp>
</ActivityItemHeader>
</ActivityItem>
);
}
return null;
};
const ChecklistContainer = styled.div``;
@ -114,8 +238,13 @@ type TaskDetailsProps = {
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onCreateComment: (task: Task, message: string) => void;
onCommentShowActions: (commentID: string, $targetRef: React.RefObject<HTMLElement>) => void;
onMemberProfile: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
onCancelCommentEdit: () => void;
onUpdateComment: (commentID: string, message: string) => void;
onChangeChecklistName: (checklistID: string, name: string) => void;
editableComment?: string | null;
onDeleteChecklist: ($target: React.RefObject<HTMLElement>, checklistID: string) => void;
onCloseModal: () => void;
onChecklistDrop: (checklist: TaskChecklist) => void;
@ -124,11 +253,15 @@ type TaskDetailsProps = {
const TaskDetails: React.FC<TaskDetailsProps> = ({
me,
onCancelCommentEdit,
task,
editableComment = null,
onDeleteChecklist,
onTaskNameChange,
onCommentShowActions,
onOpenAddChecklistPopup,
onChangeChecklistName,
onCreateComment,
onChecklistDrop,
onChecklistItemDrop,
onToggleTaskComplete,
@ -137,6 +270,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
onDeleteItem,
onDeleteTask,
onCloseModal,
onUpdateComment,
onOpenAddMemberPopup,
onOpenAddLabelPopup,
onOpenDueDatePopop,
@ -144,6 +278,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
onToggleChecklistItem,
onMemberProfile,
}) => {
const { user } = useCurrentUser();
const [taskName, setTaskName] = useState(task.name);
const [editTaskDescription, setEditTaskDescription] = useState(() => {
if (task.description) {
@ -156,11 +291,38 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
});
const [saveTimeout, setSaveTimeout] = useState<any>(null);
const [showRaw, setShowRaw] = useState(false);
const [showCommentActions, setShowCommentActions] = useState(false);
const taskDescriptionRef = useRef(task.description ?? '');
const $noMemberBtn = useRef<HTMLDivElement>(null);
const $addMemberBtn = useRef<HTMLDivElement>(null);
const $dueDateBtn = useRef<HTMLDivElement>(null);
const $detailsTitle = useRef<HTMLTextAreaElement>(null);
const activityStream: Array<{ id: string; data: { time: string; type: 'comment' | 'activity' } }> = [];
if (task.activity) {
task.activity.forEach((activity) => {
activityStream.push({
id: activity.id,
data: {
time: activity.createdAt,
type: 'activity',
},
});
});
}
if (task.comments) {
task.comments.forEach((comment) => {
activityStream.push({
id: comment.id,
data: {
time: comment.createdAt,
type: 'comment',
},
});
});
}
activityStream.sort((a, b) => (dayjs(a.data.time).isAfter(dayjs(b.data.time)) ? 1 : -1));
const saveDescription = () => {
onTaskDescriptionChange(task, taskDescriptionRef.current);
@ -178,11 +340,15 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<SidebarButton
ref={$dueDateBtn}
onClick={() => {
if (user) {
onOpenDueDatePopop(task, $dueDateBtn);
}
}}
>
{task.dueDate ? (
<SidebarButtonText>{dayjs(task.dueDate).format('MMM D [at] h:mm A')}</SidebarButtonText>
<SidebarButtonText>
{dayjs(task.dueDate).format(task.hasTime ? 'MMM D [at] h:mm A' : 'MMMM D')}
</SidebarButtonText>
) : (
<SidebarButtonText>No due date</SidebarButtonText>
)}
@ -192,20 +358,24 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<DueDateTitle>MEMBERS</DueDateTitle>
{task.assigned && task.assigned.length !== 0 ? (
<MemberList>
{task.assigned.map(m => (
{task.assigned.map((m) => (
<TaskMember
key={m.id}
member={m}
size={32}
onMemberProfile={$target => {
onMemberProfile={($target) => {
if (user) {
onMemberProfile($target, m.id);
}
}}
/>
))}
<AssignUserIcon
ref={$addMemberBtn}
onClick={() => {
if (user) {
onOpenAddMemberPopup(task, $addMemberBtn);
}
}}
>
<Plus width={16} height={16} />
@ -215,7 +385,9 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<AssignUsersButton
ref={$noMemberBtn}
onClick={() => {
if (user) {
onOpenAddMemberPopup(task, $noMemberBtn);
}
}}
>
<AssignUserIcon>
@ -225,10 +397,11 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
</AssignUsersButton>
)}
</AssignedUsersSection>
{user && (
<ExtraActionsSection>
<DueDateTitle>ACTIONS</DueDateTitle>
<ActionButton
onClick={$target => {
onClick={($target) => {
onOpenAddLabelPopup(task, $target);
}}
icon={<Tags width={12} height={12} />}
@ -236,7 +409,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
Labels
</ActionButton>
<ActionButton
onClick={$target => {
onClick={($target) => {
onOpenAddChecklistPopup(task, $target);
}}
icon={<CheckSquareOutline width={12} height={12} />}
@ -245,6 +418,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
</ActionButton>
<ActionButton>Cover</ActionButton>
</ExtraActionsSection>
)}
</LeftSidebarContent>
</LeftSidebar>
<ContentContainer>
@ -252,15 +426,19 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<HeaderInnerContainer>
<HeaderLeft>
<MarkCompleteButton
disabled={user === null}
invert={task.complete ?? false}
onClick={() => {
if (user) {
onToggleTaskComplete(task);
}
}}
>
<Checkmark width={8} height={8} />
<span>{task.complete ? 'Completed' : 'Mark complete'}</span>
</MarkCompleteButton>
</HeaderLeft>
{user && (
<HeaderRight>
<HeaderActionIcon>
<Paperclip width={16} height={16} />
@ -275,11 +453,22 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<Trash width={16} height={16} />
</HeaderActionIcon>
</HeaderRight>
)}
</HeaderInnerContainer>
<TaskDetailsTitleWrapper>
<TaskDetailsTitle
value={taskName}
onChange={e => {
ref={$detailsTitle}
disabled={user === null}
onKeyDown={(e) => {
if (e.keyCode === 13) {
e.preventDefault();
if ($detailsTitle && $detailsTitle.current) {
$detailsTitle.current.blur();
}
}
}}
onChange={(e) => {
setTaskName(e.currentTarget.value);
}}
onBlur={() => {
@ -292,12 +481,12 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<Labels>
{task.labels.length !== 0 && (
<MetaDetailContent>
{task.labels.map(label => {
{task.labels.map((label) => {
return (
<TaskLabelItem
key={label.projectLabel.id}
label={label}
onClick={$target => {
onClick={($target) => {
onOpenAddLabelPopup(task, $target);
}}
/>
@ -316,7 +505,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<TaskDetailsEditor value={taskDescriptionRef.current} />
) : (
<EditorContainer
onClick={e => {
onClick={(e) => {
if (!editTaskDescription) {
setEditTaskDescription(true);
}
@ -324,10 +513,9 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
>
<Editor
defaultValue={task.description ?? ''}
theme={dark}
readOnly={!editTaskDescription}
readOnly={user === null || !editTaskDescription}
autoFocus
onChange={value => {
onChange={(value) => {
setSaveTimeout(() => {
clearTimeout(saveTimeout);
return setTimeout(saveDescription, 2000);
@ -342,9 +530,9 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<ViewRawButton onClick={() => setShowRaw(!showRaw)}>{showRaw ? 'Show editor' : 'Show raw'}</ViewRawButton>
</DescriptionContainer>
<ChecklistSection>
<DragDropContext onDragEnd={result => onDragEnd(result, task, onChecklistDrop, onChecklistItemDrop)}>
<DragDropContext onDragEnd={(result) => onDragEnd(result, task, onChecklistDrop, onChecklistItemDrop)}>
<Droppable direction="vertical" type="checklist" droppableId="root">
{dropProvided => (
{(dropProvided) => (
<ChecklistContainer {...dropProvided.droppableProps} ref={dropProvided.innerRef}>
{task.checklists &&
task.checklists
@ -352,7 +540,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
.sort((a, b) => a.position - b.position)
.map((checklist, idx) => (
<Draggable key={checklist.id} draggableId={checklist.id} index={idx}>
{provided => (
{(provided) => (
<Checklist
ref={provided.innerRef}
wrapperProps={provided.draggableProps}
@ -362,10 +550,10 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
checklistID={checklist.id}
items={checklist.items}
onDeleteChecklist={onDeleteChecklist}
onChangeName={newName => onChangeChecklistName(checklist.id, newName)}
onChangeName={(newName) => onChangeChecklistName(checklist.id, newName)}
onToggleItem={onToggleChecklistItem}
onDeleteItem={onDeleteItem}
onAddItem={n => {
onAddItem={(n) => {
if (task.checklists) {
let position = 65535;
const [lastItem] = checklist.items
@ -380,7 +568,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
onChangeItemName={onChangeItemName}
>
<Droppable direction="vertical" type="checklistItem" droppableId={checklist.id}>
{checklistDrop => (
{(checklistDrop) => (
<>
<ChecklistItems ref={checklistDrop.innerRef} {...checklistDrop.droppableProps}>
{checklist.items
@ -388,7 +576,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
.sort((a, b) => a.position - b.position)
.map((item, itemIdx) => (
<Draggable key={item.id} draggableId={item.id} index={itemIdx}>
{itemDrop => (
{(itemDrop) => (
<ChecklistItem
key={item.id}
itemID={item.id}
@ -425,48 +613,33 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<TabBarSection>
<TabBarItem>Activity</TabBarItem>
</TabBarSection>
<ActivitySection />
</InnerContentContainer>
<CommentContainer>
{me && (
<CommentInnerWrapper>
<CommentProfile
member={me}
size={32}
onMemberProfile={$target => {
onMemberProfile($target, me.id);
}}
<ActivitySection>
{activityStream.map((stream) =>
stream.data.type === 'comment' ? (
<StreamComment
onExtraActions={onCommentShowActions}
onCancelCommentEdit={onCancelCommentEdit}
onUpdateComment={(message) => onUpdateComment(stream.id, message)}
editable={stream.id === editableComment}
comment={task.comments && task.comments.find((comment) => comment.id === stream.id)}
/>
<CommentEditorContainer>
<CommentTextArea
disabled
placeholder="Write a comment..."
onFocus={() => {
setShowCommentActions(true);
}}
onBlur={() => {
setShowCommentActions(false);
}}
) : (
<StreamActivity
activity={task.activity && task.activity.find((activity) => activity.id === stream.id)}
/>
<CommentEditorActions visible={showCommentActions}>
<CommentEditorActionIcon>
<Paperclip width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<At width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<Smile width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<Task width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorSaveButton>Save</CommentEditorSaveButton>
</CommentEditorActions>
</CommentEditorContainer>
</CommentInnerWrapper>
),
)}
</ActivitySection>
</InnerContentContainer>
{me && (
<CommentContainer>
<CommentCreator
me={me}
onCreateComment={(message) => onCreateComment(task, message)}
onMemberProfile={onMemberProfile}
/>
</CommentContainer>
)}
</ContentContainer>
</Container>
);

View File

@ -0,0 +1,68 @@
import visit from 'unist-util-visit';
import emoji from 'node-emoji';
import { emoticon } from 'emoticon';
import { Emoji } from 'emoji-mart';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
const RE_EMOJI = /:\+1:|:-1:|:[\w-]+:/g;
const RE_SHORT = /[$@|*'",;.=:\-)([\]\\/<>038BOopPsSdDxXzZ]{2,5}/g;
const DEFAULT_SETTINGS = {
padSpaceAfter: false,
emoticon: false,
};
function plugin(options) {
const settings = Object.assign({}, DEFAULT_SETTINGS, options);
const pad = !!settings.padSpaceAfter;
const emoticonEnable = !!settings.emoticon;
function getEmojiByShortCode(match) {
// find emoji by shortcode - full match or with-out last char as it could be from text e.g. :-),
const iconFull = emoticon.find(e => e.emoticons.includes(match)); // full match
const iconPart = emoticon.find(e => e.emoticons.includes(match.slice(0, -1))); // second search pattern
const trimmedChar = iconPart ? match.slice(-1) : '';
const addPad = pad ? ' ' : '';
let icon = iconFull ? iconFull.emoji + addPad : iconPart && iconPart.emoji + addPad + trimmedChar;
return icon || match;
}
function getEmoji(match) {
console.log(match);
const got = emoji.get(match);
if (pad && got !== match) {
return got + ' ';
}
console.log(got);
return ReactDOMServer.renderToStaticMarkup(<Emoji set="google" emoji={match} size={16} />);
}
function transformer(tree) {
visit(tree, 'paragraph', function(node) {
console.log(tree);
// node.value = node.value.replace(RE_EMOJI, getEmoji);
// jnode.type = 'html';
// jnode.tagName = 'div';
// jnode.value = '';
for (let nodeIdx = 0; nodeIdx < node.children.length; nodeIdx++) {
if (node.children[nodeIdx].type === 'text') {
node.children[nodeIdx].type = 'html';
node.children[nodeIdx].tagName = 'div';
node.children[nodeIdx].value = node.children[nodeIdx].value.replace(RE_EMOJI, getEmoji);
}
}
if (emoticonEnable) {
// node.value = node.value.replace(RE_SHORT, getEmojiByShortCode);
}
console.log(node);
});
}
return transformer;
}
export { plugin };

View File

@ -0,0 +1,69 @@
import React, { useRef, useState, useEffect } from 'react';
import { Home, Star, Bell, AngleDown, BarChart, CheckCircle, ListUnordered } from 'shared/icons';
import { RoleCode } from 'shared/generated/graphql';
import * as S from './Styles';
import { Link } from 'react-router-dom';
export type MenuItem = {
name: string;
link: string;
};
type NavBarProps = {
menuType?: Array<MenuItem> | null;
name: string | null;
match: string;
};
const NavBar: React.FC<NavBarProps> = ({ menuType, name, match }) => {
return (
<S.NavbarWrapper>
<S.NavbarHeader>
<S.ProjectActions>
<S.ProjectSwitch>
<S.ProjectSwitchInner>
<S.TaskcafeLogo innerColor="#9f46e4" outerColor="#000" width={32} height={32} />
</S.ProjectSwitchInner>
</S.ProjectSwitch>
<S.ProjectInfo>
<S.ProjectMeta>{name && <S.ProjectName>{name}</S.ProjectName>}</S.ProjectMeta>
{name && (
<S.ProjectTabs>
{menuType &&
menuType.map((menu, idx) => {
return (
<S.ProjectTab
key={menu.name}
to={menu.link}
exact
onClick={() => {
// TODO
}}
>
{menu.name}
</S.ProjectTab>
);
})}
</S.ProjectTabs>
)}
</S.ProjectInfo>
</S.ProjectActions>
<S.LogoContainer to="/">
<S.TaskcafeTitle>Taskcafé</S.TaskcafeTitle>
</S.LogoContainer>
<S.GlobalActions>
<Link
to={{
pathname: '/login',
state: { redirect: match },
}}
>
<S.SignIn>Sign In</S.SignIn>
</Link>
</S.GlobalActions>
</S.NavbarHeader>
</S.NavbarWrapper>
);
};
export default NavBar;

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