Compare commits

..

8 Commits

Author SHA1 Message Date
Jordan Knott
33f06c1035 feat: more changes 2020-12-16 20:18:02 -06:00
Jordan Knott
eff2044a6b feat: so many stuffs 2020-12-05 15:19:34 -06:00
Jordan Knott
451581e934 refactor: add logging info to search members 2020-10-20 18:59:21 -05:00
Jordan Knott
0a48b578fd chore: add air config 2020-10-20 18:59:21 -05:00
Jordan Knott
262f9cbdda feat: redesign project invite popup 2020-10-20 18:59:21 -05:00
Jordan Knott
737d2b640f feat: redesign project sharing 2020-10-20 18:59:21 -05:00
Jordan Knott
36f25391b4 feat: add public projects 2020-10-20 18:59:21 -05:00
Jordan Knott
696a9aeee7 refactor: move default viper config values to commands/commands.go 2020-10-20 18:59:21 -05:00
313 changed files with 24536 additions and 36656 deletions

View File

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

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

View File

@ -10,8 +10,6 @@ windows:
- yarn: - yarn:
- cd frontend - cd frontend
- yarn start - yarn start
- worker:
- go run cmd/taskcafe/main.go worker
- web/editor: - web/editor:
root: ./frontend root: ./frontend
panes: panes:
@ -23,4 +21,4 @@ windows:
- database: - database:
root: ./ root: ./
panes: panes:
- pgcli postgres://taskcafe:taskcafe_test@localhost:8855/taskcafe - pgcli postgres://taskcafe:taskcafe_test@localhost:5432/taskcafe

View File

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

View File

@ -6,9 +6,7 @@ Thanks for wanting to contribute to Taskcafe!
So you want to contribute to Taskcafe? Great! So you want to contribute to Taskcafe? Great!
If you have noticed a bug, 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 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 there is a [new feature you'd like added](https://github.com/JordanKnott/taskcafe/discussions/new?category=ideas) or [have a question](https://github.com/JordanKnott/taskcafe/discussions/new?category=q-a), please visit the [discussions section](https://github.com/JordanKnott/taskcafe/discussions)
Alternatively you can join the [Taskcafe discord](https://discord.gg/JkQDruh) and ask in the #questions channel. Alternatively you can join the [Taskcafe discord](https://discord.gg/JkQDruh) and ask in the #questions channel.

View File

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

71
Pipfile.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@ -0,0 +1,437 @@
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';
const TeamContainer = styled.div`
display: flex;
flex-direction: column;
margin: 0 8px;
`;
const TeamTitle = styled.h3`
font-size: 14px;
font-weight: 700;
`;
const TeamProjects = styled.div`
display: flex;
flex-direction: column;
margin-top: 8px;
margin-bottom: 4px;
`;
const TeamProjectLink = styled(Link)`
display: flex;
font-weight: 700;
height: 36px;
overflow: hidden;
padding: 0;
position: relative;
text-decoration: none;
user-select: none;
`;
const TeamProjectBackground = styled.div<{ color: string }>`
background-image: url(null);
background-color: ${props => props.color};
background-size: cover;
background-position: 50%;
position: absolute;
width: 100%;
height: 36px;
opacity: 1;
border-radius: 3px;
&:before {
background: rgba(${props => props.theme.colors.bg.secondary});
bottom: 0;
content: '';
left: 0;
opacity: 0.88;
position: absolute;
right: 0;
top: 0;
}
`;
const TeamProjectAvatar = styled.div<{ color: string }>`
background-image: url(null);
background-color: ${props => props.color};
display: inline-block;
flex: 0 0 auto;
background-size: cover;
border-radius: 3px 0 0 3px;
height: 36px;
width: 36px;
position: relative;
opacity: 0.7;
`;
const TeamProjectContent = styled.div`
display: flex;
position: relative;
flex: 1;
width: 100%;
padding: 9px 0 9px 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TeamProjectTitle = styled.div`
font-weight: 700;
display: block;
padding-right: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TeamProjectContainer = styled.div`
box-sizing: border-box;
border-radius: 3px;
position: relative;
margin: 0 4px 4px 0;
min-width: 0;
&:hover ${TeamProjectTitle} {
color: rgba(${props => props.theme.colors.text.secondary});
}
&:hover ${TeamProjectAvatar} {
opacity: 1;
}
&:hover ${TeamProjectBackground}:before {
opacity: 0.78;
}
`;
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
const ProjectFinder = () => {
const { loading, data } = useGetProjectsQuery();
if (loading) {
return <span>loading</span>;
}
if (data) {
const { projects, teams } = 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: '#7367f0' },
);
}
};
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

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

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

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

View File

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

View File

@ -1,20 +1,79 @@
import React, { useContext } from 'react'; import React, { useContext } from 'react';
type UserContextState = { export enum PermissionLevel {
user: string | null; ORG,
setUser: (user: string | null) => void; 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;
};
export const UserContext = React.createContext<UserContextState>({ export const UserContext = React.createContext<UserContextState>({
user: null, user: null,
setUser: _user => null, setUser: _user => null,
setUserRoles: roles => null,
}); });
export interface CurrentUser extends CurrentUserRaw {
isAdmin: (level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) => boolean;
isVisible: (level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) => boolean;
}
export const useCurrentUser = () => { export const useCurrentUser = () => {
const { user, setUser } = useContext(UserContext); 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;
}
},
};
}
return { return {
user, user: currentUser,
setUser, setUser,
setUserRoles,
}; };
}; };

View File

@ -1,6 +0,0 @@
@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,32 +1,112 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { BrowserRouter } from 'react-router-dom'; import jwtDecode from 'jwt-decode';
import { createBrowserHistory } from 'history';
import { Router } from 'react-router';
import { PopupProvider } from 'shared/components/PopupMenu'; import { PopupProvider } from 'shared/components/PopupMenu';
import { ToastContainer } from 'react-toastify';
import { setAccessToken } from 'shared/utils/accessToken';
import styled, { ThemeProvider } from 'styled-components'; import styled, { ThemeProvider } from 'styled-components';
import NormalizeStyles from './NormalizeStyles'; import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles'; import BaseStyles from './BaseStyles';
import theme from './ThemeStyles'; import theme from './ThemeStyles';
import Routes from './Routes'; import Routes from './Routes';
import ToastedContainer from './Toast'; import { UserContext, CurrentUserRaw, CurrentUserRoles, PermissionLevel, PermissionObjectType } from './context';
import { UserContext } from './context';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import './fonts.css';
const StyledContainer = styled(ToastContainer).attrs({
// custom props
})`
.Toastify__toast-container {
}
.Toastify__toast {
padding: 5px;
margin-left: 5px;
margin-right: 5px;
border-radius: 10px;
background: #7367f0;
color: #fff;
}
.Toastify__toast--error {
background: rgba(${props => props.theme.colors.danger});
}
.Toastify__toast--warning {
background: rgba(${props => props.theme.colors.warning});
}
.Toastify__toast--success {
background: rgba(${props => props.theme.colors.success});
}
.Toastify__toast-body {
}
.Toastify__progress-bar {
}
.Toastify__close-button {
display: none;
}
`;
const history = createBrowserHistory();
type RefreshTokenResponse = {
accessToken: string;
isInstalled: boolean;
};
const App = () => { const App = () => {
const [user, setUser] = useState<string | null>(null); const [loading, setLoading] = useState(true);
const [user, setUser] = useState<CurrentUserRaw | null>(null);
const setUserRoles = (roles: CurrentUserRoles) => {
if (user) {
setUser({
...user,
roles,
});
}
};
useEffect(() => {
fetch('/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
if (!isInstalled) {
history.replace('/install');
}
}
setLoading(false);
});
}, []);
return ( return (
<> <>
<UserContext.Provider value={{ user, setUser }}> <UserContext.Provider value={{ user, setUser, setUserRoles }}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<NormalizeStyles /> <NormalizeStyles />
<BaseStyles /> <BaseStyles />
<BrowserRouter> <Router history={history}>
<PopupProvider> <PopupProvider>
<Routes /> {loading ? (
<div>loading</div>
) : (
<>
<Routes history={history} />
</>
)}
</PopupProvider> </PopupProvider>
</BrowserRouter> </Router>
<ToastedContainer <StyledContainer
position="bottom-right" position="bottom-right"
autoClose={5000} autoClose={5000}
hideProgressBar hideProgressBar

View File

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

View File

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

View File

@ -1,45 +0,0 @@
import React, { useEffect, useState } from 'react';
import Confirm from 'shared/components/Confirm';
import { useHistory, useLocation } from 'react-router';
import * as QueryString from 'query-string';
import { useCurrentUser } from 'App/context';
import { Container, LoginWrapper } from './Styles';
const UsersConfirm = () => {
const history = useHistory();
const location = useLocation();
const params = QueryString.parse(location.search);
const [hasFailed, setFailed] = useState(false);
const { setUser } = useCurrentUser();
useEffect(() => {
fetch('/auth/confirm', {
method: 'POST',
body: JSON.stringify({
confirmToken: params.confirmToken,
}),
})
.then(async (x) => {
const { status } = x;
if (status === 200) {
const response = await x.json();
const { userID } = response;
setUser(userID);
history.push('/');
} else {
setFailed(true);
}
})
.catch(() => {
setFailed(false);
});
}, []);
return (
<Container>
<LoginWrapper>
<Confirm hasConfirmToken={params.confirmToken !== undefined} hasFailed={hasFailed} />
</LoginWrapper>
</Container>
);
};
export default UsersConfirm;

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

@ -0,0 +1,242 @@
import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react';
import { Dot } from 'shared/icons';
import styled from 'styled-components';
import {
findNextDraggable,
getDimensions,
getTargetDepth,
getNodeAbove,
getBelowParent,
findNodeAbove,
getNodeOver,
getLastChildInBranch,
findNodeDepth,
} from './utils';
import { useDrag } from './useDrag';
const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 9px;
background: rgba(${p => p.theme.colors.primary});
svg {
fill: rgba(${p => p.theme.colors.text.primary});
stroke: rgba(${p => p.theme.colors.text.primary});
}
`;
type DraggerProps = {
container: React.RefObject<HTMLDivElement>;
draggedNodes: { nodes: Array<string>; first?: OutlineNode | null };
isDragging: boolean;
onDragEnd: (zone: ImpactZone) => void;
initialPos: { x: number; y: number };
};
const Dragger: React.FC<DraggerProps> = ({ draggedNodes, container, onDragEnd, isDragging, initialPos }) => {
const [pos, setPos] = useState<{ x: number; y: number }>(initialPos);
const { outline, impact, setImpact } = useDrag();
const $handle = useRef<HTMLDivElement>(null);
const handleMouseUp = useCallback(() => {
onDragEnd(impact ? impact.zone : { below: null, above: null });
}, [impact]);
const handleMouseMove = useCallback(
e => {
e.preventDefault();
const { clientX, clientY, pageX, pageY } = e;
setPos({ x: clientX, y: clientY });
const { curDepth, curPosition, curDraggable } = getNodeOver({ x: clientX, y: clientY }, outline.current);
let depthTarget: number = 0;
let aboveNode: null | OutlineNode = null;
let belowNode: null | OutlineNode = null;
if (curPosition === 'before') {
belowNode = curDraggable;
} else {
aboveNode = curDraggable;
}
// if belowNode has the depth of 1, then the above element will be a part of a different branch
const { relationships, nodes } = outline.current;
if (!belowNode || !aboveNode) {
if (belowNode) {
aboveNode = findNodeAbove(outline.current, curDepth, belowNode);
} else if (aboveNode) {
let targetBelowNode: RelationshipChild | null = null;
const parent = relationships.get(aboveNode.parent);
if (aboveNode.children !== 0 && !aboveNode.collapsed) {
const abr = relationships.get(aboveNode.id);
if (abr) {
const newTarget = abr.children[0];
if (newTarget) {
targetBelowNode = newTarget;
}
}
} else if (parent) {
const aboveNodeIndex = parent.children.findIndex(c => aboveNode && c.id === aboveNode.id);
if (aboveNodeIndex !== -1) {
if (aboveNodeIndex === parent.children.length - 1) {
targetBelowNode = getBelowParent(aboveNode, outline.current);
} else {
const nextChild = parent.children[aboveNodeIndex + 1];
targetBelowNode = nextChild ?? null;
}
}
}
if (targetBelowNode) {
const depthNodes = nodes.get(targetBelowNode.depth);
if (depthNodes) {
belowNode = depthNodes.get(targetBelowNode.id) ?? null;
}
}
}
}
// if outside outline, get either first or last item in list based on mouse Y
if (!aboveNode && !belowNode) {
if (container && container.current) {
const bounds = container.current.getBoundingClientRect();
if (clientY < bounds.top + bounds.height / 2) {
const rootChildren = outline.current.relationships.get('root');
const rootDepth = outline.current.nodes.get(1);
if (rootChildren && rootDepth) {
const firstChild = rootChildren.children[0];
belowNode = rootDepth.get(firstChild.id) ?? null;
aboveNode = null;
}
} else {
// TODO: enhance to actually get last child item, not last top level branch
const rootChildren = outline.current.relationships.get('root');
const rootDepth = outline.current.nodes.get(1);
if (rootChildren && rootDepth) {
const lastChild = rootChildren.children[rootChildren.children.length - 1];
const lastParentNode = rootDepth.get(lastChild.id) ?? null;
if (lastParentNode) {
const lastBranchChild = getLastChildInBranch(outline.current, lastParentNode);
if (lastBranchChild) {
const lastChildDepth = outline.current.nodes.get(lastBranchChild.depth);
if (lastChildDepth) {
aboveNode = lastChildDepth.get(lastBranchChild.id) ?? null;
}
}
}
}
}
}
}
if (aboveNode) {
const { ancestors } = findNodeDepth(outline.current.published, aboveNode.id);
for (let i = 0; i < draggedNodes.nodes.length; i++) {
const nodeID = draggedNodes.nodes[i];
if (ancestors.find(c => c === nodeID)) {
if (draggedNodes.first) {
belowNode = draggedNodes.first;
aboveNode = findNodeAbove(outline.current, aboveNode ? aboveNode.depth : 1, draggedNodes.first);
} else {
const { depth } = findNodeDepth(outline.current.published, nodeID);
const nodeDepth = outline.current.nodes.get(depth);
const targetNode = nodeDepth ? nodeDepth.get(nodeID) : null;
if (targetNode) {
belowNode = targetNode;
aboveNode = findNodeAbove(outline.current, depth, targetNode);
}
}
}
}
}
// calculate available depths
let minDepth = 1;
let maxDepth = 2;
if (aboveNode) {
const aboveParent = relationships.get(aboveNode.parent);
if (aboveNode.children !== 0 && !aboveNode.collapsed) {
minDepth = aboveNode.depth + 1;
maxDepth = aboveNode.depth + 1;
} else if (aboveParent) {
minDepth = aboveNode.depth;
maxDepth = aboveNode.depth + 1;
const aboveNodeIndex = aboveParent.children.findIndex(c => aboveNode && c.id === aboveNode.id);
if (aboveNodeIndex === aboveParent.children.length - 1) {
minDepth = belowNode ? belowNode.depth : minDepth;
}
}
}
if (aboveNode) {
const dimensions = outline.current.dimensions.get(aboveNode.id);
const entry = getDimensions(dimensions?.entry);
if (entry) {
depthTarget = getTargetDepth(clientX, entry.left, { min: minDepth, max: maxDepth });
}
}
let aboveImpact: null | ImpactZoneData = null;
let belowImpact: null | ImpactZoneData = null;
if (aboveNode) {
const aboveDim = outline.current.dimensions.get(aboveNode.id);
if (aboveDim) {
aboveImpact = {
node: aboveNode,
dimensions: aboveDim,
};
}
}
if (belowNode) {
const belowDim = outline.current.dimensions.get(belowNode.id);
if (belowDim) {
belowImpact = {
node: belowNode,
dimensions: belowDim,
};
}
}
setImpact({
zone: {
above: aboveImpact,
below: belowImpact,
},
depth: depthTarget,
});
},
[outline.current.nodes],
);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, []);
const styles = useMemo(() => {
const position: 'absolute' | 'relative' = isDragging ? 'absolute' : 'relative';
return {
cursor: isDragging ? '-webkit-grabbing' : '-webkit-grab',
transform: `translate(${pos.x - 10}px, ${pos.y - 4}px)`,
transition: isDragging ? 'none' : 'transform 500ms',
zIndex: isDragging ? 2 : 1,
position,
};
}, [isDragging, pos]);
return (
<>
{pos && (
<Container ref={$handle} style={styles}>
<Dot width={18} height={18} />
</Container>
)}
</>
);
};
export default Dragger;

View File

@ -0,0 +1,155 @@
import React, { useRef, useEffect } from 'react';
import { Dot, CaretDown, CaretRight } from 'shared/icons';
import { EntryChildren, EntryWrapper, EntryContent, EntryInnerContent, EntryHandle, ExpandButton } from './Styles';
import { useDrag } from './useDrag';
function getCaretPosition(editableDiv: any) {
let caretPos = 0;
let sel: any = null;
let range: any = null;
if (window.getSelection) {
sel = window.getSelection();
if (sel && sel.rangeCount) {
range = sel.getRangeAt(0);
if (range.commonAncestorContainer.parentNode === editableDiv) {
caretPos = range.endOffset;
}
}
}
return caretPos;
}
type EntryProps = {
id: string;
collapsed?: boolean;
onToggleCollapse: (id: string, collapsed: boolean) => void;
parentID: string;
onStartDrag: (e: { id: string; clientX: number; clientY: number }) => void;
onStartSelect: (e: { id: string; depth: number }) => void;
isRoot?: boolean;
selection: null | Array<{ id: string }>;
draggedNodes: null | Array<string>;
entries: Array<ItemElement>;
onCancelDrag: () => void;
position: number;
chain?: Array<string>;
depth?: number;
};
const Entry: React.FC<EntryProps> = ({
id,
parentID,
isRoot = false,
selection,
onToggleCollapse,
onStartSelect,
position,
onCancelDrag,
onStartDrag,
collapsed = false,
draggedNodes,
entries,
chain = [],
depth = 0,
}) => {
const $entry = useRef<HTMLDivElement>(null);
const $children = useRef<HTMLDivElement>(null);
const { setNodeDimensions, clearNodeDimensions } = useDrag();
useEffect(() => {
if (isRoot) return;
if ($entry && $entry.current) {
setNodeDimensions(id, {
entry: $entry,
children: entries.length !== 0 ? $children : null,
});
}
return () => {
clearNodeDimensions(id);
};
}, [position, depth, entries]);
let showHandle = true;
if (draggedNodes && draggedNodes.length === 1 && draggedNodes.find(c => c === id)) {
showHandle = false;
}
let isSelected = false;
if (selection && selection.find(c => c.id === id)) {
isSelected = true;
}
let onSaveTimer: any = null;
const onSaveTimeout = 300;
return (
<EntryWrapper isSelected={isSelected} isDragging={!showHandle}>
{!isRoot && (
<EntryContent>
{entries.length !== 0 && (
<ExpandButton onClick={() => onToggleCollapse(id, !collapsed)}>
{collapsed ? <CaretRight width={20} height={20} /> : <CaretDown width={20} height={20} />}
</ExpandButton>
)}
{showHandle && (
<EntryHandle
onMouseUp={() => onCancelDrag()}
onMouseDown={e => {
onStartDrag({ id, clientX: e.clientX, clientY: e.clientY });
}}
>
<Dot width={18} height={18} />
</EntryHandle>
)}
<EntryInnerContent
onMouseDown={() => {
onStartSelect({ id, depth });
}}
onKeyDown={e => {
if (e.key === 'z' && e.ctrlKey) {
if ($entry && $entry.current) {
console.log(getCaretPosition($entry.current));
}
} else {
clearTimeout(onSaveTimer);
if ($entry && $entry.current) {
onSaveTimer = setTimeout(() => {
if ($entry && $entry.current) {
console.log($entry.current.textContent);
}
}, onSaveTimeout);
}
}
}}
contentEditable
ref={$entry}
>
{`${id.toString()} - ${position}`}
</EntryInnerContent>
</EntryContent>
)}
{entries.length !== 0 && !collapsed && (
<EntryChildren ref={$children} isRoot={isRoot}>
{entries
.sort((a, b) => a.position - b.position)
.map(entry => (
<Entry
parentID={id}
key={entry.id}
position={entry.position}
depth={depth + 1}
draggedNodes={draggedNodes}
collapsed={entry.collapsed}
id={entry.id}
onStartSelect={onStartSelect}
onStartDrag={onStartDrag}
onCancelDrag={onCancelDrag}
entries={entry.children ?? []}
chain={[...chain, id]}
selection={selection}
onToggleCollapse={onToggleCollapse}
/>
))}
</EntryChildren>
)}
</EntryWrapper>
);
};
export default Entry;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Modal from 'shared/components/Modal'; import Modal from 'shared/components/Modal';
import TaskDetails from 'shared/components/TaskDetails'; import TaskDetails from 'shared/components/TaskDetails';
import TaskDetailsLoading from 'shared/components/TaskDetails/Loading';
import { Popup, usePopup } from 'shared/components/PopupMenu'; import { Popup, usePopup } from 'shared/components/PopupMenu';
import MemberManager from 'shared/components/MemberManager'; import MemberManager from 'shared/components/MemberManager';
import { useRouteMatch, useHistory, useParams } from 'react-router'; import { useRouteMatch, useHistory } from 'react-router';
import { import {
useDeleteTaskChecklistMutation, useDeleteTaskChecklistMutation,
useToggleTaskWatchMutation,
useUpdateTaskChecklistNameMutation, useUpdateTaskChecklistNameMutation,
useUpdateTaskChecklistItemLocationMutation, useUpdateTaskChecklistItemLocationMutation,
useCreateTaskChecklistMutation, useCreateTaskChecklistMutation,
useFindTaskQuery, useFindTaskQuery,
DueDateNotificationDuration,
useUpdateTaskDueDateMutation, useUpdateTaskDueDateMutation,
useSetTaskCompleteMutation, useSetTaskCompleteMutation,
useAssignTaskMutation, useAssignTaskMutation,
@ -24,9 +21,6 @@ import {
useCreateTaskChecklistItemMutation, useCreateTaskChecklistItemMutation,
FindTaskDocument, FindTaskDocument,
FindTaskQuery, FindTaskQuery,
useCreateTaskCommentMutation,
useDeleteTaskCommentMutation,
useUpdateTaskCommentMutation,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import MiniProfile from 'shared/components/MiniProfile'; import MiniProfile from 'shared/components/MiniProfile';
@ -38,74 +32,6 @@ import Input from 'shared/components/Input';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import polling from 'shared/utils/polling';
export const ActionsList = styled.ul`
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
`;
export const ActionItem = styled.li`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: ${(props) => props.theme.colors.primary};
}
`;
export const ActionTitle = styled.span`
margin-left: 20px;
`;
const WarningLabel = styled.p`
font-size: 14px;
margin: 8px 12px;
`;
const DeleteConfirm = styled(Button)`
width: 100%;
padding: 8px 12px;
margin-bottom: 6px;
`;
type TaskCommentActionsProps = {
onDeleteComment: () => void;
onEditComment: () => void;
};
const TaskCommentActions: React.FC<TaskCommentActionsProps> = ({ onDeleteComment, onEditComment }) => {
const { setTab } = usePopup();
return (
<>
<Popup tab={0} title={null}>
<ActionsList>
<ActionItem>
<ActionTitle>Pin to top</ActionTitle>
</ActionItem>
<ActionItem onClick={() => onEditComment()}>
<ActionTitle>Edit comment</ActionTitle>
</ActionItem>
<ActionItem onClick={() => setTab(1)}>
<ActionTitle>Delete comment</ActionTitle>
</ActionItem>
</ActionsList>
</Popup>
<Popup tab={1} title="Delete comment?">
<WarningLabel>Deleting a comment can not be undone.</WarningLabel>
<DeleteConfirm onClick={() => onDeleteComment()} color="danger">
Delete comment
</DeleteConfirm>
</Popup>
</>
);
};
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => { const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
const total = checklists.reduce((prev: any, next: any) => { const total = checklists.reduce((prev: any, next: any) => {
@ -168,8 +94,10 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
defaultValue="Checklist" defaultValue="Checklist"
width="100%" width="100%"
label="Name" label="Name"
id="name"
name="name"
variant="alternate" variant="alternate"
{...register('name', { required: 'Checklist name is required' })} ref={register({ required: 'Checklist name is required' })}
/> />
<CreateChecklistButton type="submit">Create</CreateChecklistButton> <CreateChecklistButton type="submit">Create</CreateChecklistButton>
</CreateChecklistForm> </CreateChecklistForm>
@ -177,6 +105,7 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
}; };
type DetailsProps = { type DetailsProps = {
taskID: string;
projectURL: string; projectURL: string;
onTaskNameChange: (task: Task, newName: string) => void; onTaskNameChange: (task: Task, newName: string) => void;
onTaskDescriptionChange: (task: Task, newDescription: string) => void; onTaskDescriptionChange: (task: Task, newDescription: string) => void;
@ -190,6 +119,7 @@ const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 };
const Details: React.FC<DetailsProps> = ({ const Details: React.FC<DetailsProps> = ({
projectURL, projectURL,
taskID,
onTaskNameChange, onTaskNameChange,
onTaskDescriptionChange, onTaskDescriptionChange,
onDeleteTask, onDeleteTask,
@ -198,69 +128,31 @@ const Details: React.FC<DetailsProps> = ({
refreshCache, refreshCache,
}) => { }) => {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { taskID } = useParams<{ taskID: string }>();
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const history = useHistory(); const history = useHistory();
const [deleteTaskComment] = useDeleteTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
draftCache.findTask.comments = cache.findTask.comments.filter(
(c) => c.id !== response.data?.deleteTaskComment.commentID,
);
}
}),
{ taskID },
);
},
});
const [toggleTaskWatch] = useToggleTaskWatchMutation();
const [createTaskComment] = useCreateTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
draftCache.findTask.comments.push({
...response.data.createTaskComment.comment,
});
}
}),
{ taskID },
);
},
});
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation(); const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({ const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
client, client,
FindTaskDocument, FindTaskDocument,
(cache) => cache =>
produce(cache, (draftCache) => { produce(cache, draftCache => {
if (response.data) { const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
const { prevChecklistID, taskChecklistID, checklistItem } = response.data.updateTaskChecklistItemLocation; if (checklistID !== prevChecklistID) {
if (taskChecklistID !== prevChecklistID) { const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
const oldIdx = cache.findTask.checklists.findIndex((c) => c.id === prevChecklistID); const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID);
const newIdx = cache.findTask.checklists.findIndex((c) => c.id === taskChecklistID); if (oldIdx > -1 && newIdx > -1) {
if (oldIdx > -1 && newIdx > -1) { const item = cache.findTask.checklists[oldIdx].items.find(i => i.id === checklistItem.id);
const item = cache.findTask.checklists[oldIdx].items.find((i) => i.id === checklistItem.id); if (item) {
if (item) { draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter( i => i.id !== checklistItem.id,
(i) => i.id !== checklistItem.id, );
); draftCache.findTask.checklists[newIdx].items.push({
draftCache.findTask.checklists[newIdx].items.push({ ...item,
...item, position: checklistItem.position,
position: checklistItem.position, taskChecklistID: checklistID,
taskChecklistID, });
});
}
} }
} }
} }
@ -270,12 +162,12 @@ const Details: React.FC<DetailsProps> = ({
}, },
}); });
const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({ const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({
update: (client) => { update: client => {
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
client, client,
FindTaskDocument, FindTaskDocument,
(cache) => cache =>
produce(cache, (draftCache) => { produce(cache, draftCache => {
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = { draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge', __typename: 'ChecklistBadge',
@ -292,11 +184,11 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
client, client,
FindTaskDocument, FindTaskDocument,
(cache) => cache =>
produce(cache, (draftCache) => { produce(cache, draftCache => {
const { checklists } = cache.findTask; const { checklists } = cache.findTask;
draftCache.findTask.checklists = checklists.filter( draftCache.findTask.checklists = checklists.filter(
(c) => c.id !== deleteData.data?.deleteTaskChecklist.taskChecklist.id, c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id,
); );
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = { draftCache.findTask.badges.checklist = {
@ -318,12 +210,10 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
client, client,
FindTaskDocument, FindTaskDocument,
(cache) => cache =>
produce(cache, (draftCache) => { produce(cache, draftCache => {
if (createData.data) { const item = createData.data.createTaskChecklist;
const item = createData.data.createTaskChecklist; draftCache.findTask.checklists.push({ ...item });
draftCache.findTask.checklists.push({ ...item });
}
}), }),
{ taskID }, { taskID },
); );
@ -335,16 +225,38 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
client, client,
FindTaskDocument, FindTaskDocument,
(cache) => cache =>
produce(cache, (draftCache) => { produce(cache, draftCache => {
if (deleteData.data) { const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem; const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
const targetIdx = cache.findTask.checklists.findIndex((c) => c.id === item.taskChecklistID); if (targetIdx > -1) {
if (targetIdx > -1) { draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter( c => item.id !== c.id,
(c) => item.id !== c.id, );
); }
} const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}),
{ taskID },
);
},
});
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
update: (client, newTaskItem) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask;
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
if (idx !== -1) {
draftCache.findTask.checklists[idx].items.push({ ...item });
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists); const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = { draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge', __typename: 'ChecklistBadge',
@ -357,37 +269,7 @@ const Details: React.FC<DetailsProps> = ({
); );
}, },
}); });
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({ const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
update: (client, newTaskItem) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
(cache) =>
produce(cache, (draftCache) => {
if (newTaskItem.data) {
const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask;
const idx = checklists.findIndex((c) => c.id === item.taskChecklistID);
if (idx !== -1) {
draftCache.findTask.checklists[idx].items.push({ ...item });
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}
}
}),
{ taskID },
);
},
});
const { loading, data, refetch } = useFindTaskQuery({
variables: { taskID },
pollInterval: polling.TASK_DETAILS,
fetchPolicy: 'cache-and-network',
});
const [setTaskComplete] = useSetTaskCompleteMutation(); const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({ const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
onCompleted: () => { onCompleted: () => {
@ -407,59 +289,25 @@ const Details: React.FC<DetailsProps> = ({
refreshCache(); refreshCache();
}, },
}); });
const [updateTaskComment] = useUpdateTaskCommentMutation(); if (loading) {
const [editableComment, setEditableComment] = useState<null | string>(null); return null;
const isLoading = true; }
if (!data) {
return null;
}
return ( return (
<> <>
<Modal <Modal
width={1070} width={1070}
onClose={() => { onClose={() => {
history.push(projectURL); history.push(projectURL);
hidePopup();
}} }}
renderContent={() => { renderContent={() => {
return data ? ( return (
<TaskDetails <TaskDetails
onCancelCommentEdit={() => setEditableComment(null)} me={data.me.user}
onUpdateComment={(commentID, message) => {
updateTaskComment({ variables: { commentID, message } });
}}
editableComment={editableComment}
me={data.me ? data.me.user : null}
onCommentShowActions={(commentID, $targetRef) => {
showPopup(
$targetRef,
<TaskCommentActions
onDeleteComment={() => {
deleteTaskComment({ variables: { commentID } });
hidePopup();
}}
onEditComment={() => {
setEditableComment(commentID);
hidePopup();
}}
/>,
);
}}
task={data.findTask} task={data.findTask}
onToggleTaskWatch={(task, watched) => { onChecklistDrop={checklist => {
toggleTaskWatch({
variables: { taskID: task.id },
optimisticResponse: {
__typename: 'Mutation',
toggleTaskWatch: {
id: task.id,
__typename: 'Task',
watched,
},
},
});
}}
onCreateComment={(task, message) => {
createTaskComment({ variables: { taskID: task.id, message } });
}}
onChecklistDrop={(checklist) => {
updateTaskChecklistLocation({ updateTaskChecklistLocation({
variables: { taskChecklistID: checklist.id, position: checklist.position }, variables: { taskChecklistID: checklist.id, position: checklist.position },
@ -501,7 +349,7 @@ const Details: React.FC<DetailsProps> = ({
}} }}
onTaskNameChange={onTaskNameChange} onTaskNameChange={onTaskNameChange}
onTaskDescriptionChange={onTaskDescriptionChange} onTaskDescriptionChange={onTaskDescriptionChange}
onToggleTaskComplete={(task) => { onToggleTaskComplete={task => {
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } }); setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
}} }}
onDeleteTask={onDeleteTask} onDeleteTask={onDeleteTask}
@ -546,7 +394,7 @@ const Details: React.FC<DetailsProps> = ({
createTaskChecklistItem({ variables: { taskChecklistID, name, position } }); createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
}} }}
onMemberProfile={($targetRef, memberID) => { onMemberProfile={($targetRef, memberID) => {
const member = data.findTask.assigned.find((m) => m.id === memberID); const member = data.findTask.assigned.find(m => m.id === memberID);
if (member) { if (member) {
showPopup( showPopup(
$targetRef, $targetRef,
@ -556,8 +404,7 @@ const Details: React.FC<DetailsProps> = ({
bio="None" bio="None"
onRemoveFromTask={() => { onRemoveFromTask={() => {
if (user) { if (user) {
unassignTask({ variables: { taskID: data.findTask.id, userID: member.id ?? '' } }); unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
hidePopup();
} }
}} }}
/> />
@ -597,7 +444,7 @@ const Details: React.FC<DetailsProps> = ({
}} }}
> >
<CreateChecklistPopup <CreateChecklistPopup
onCreateChecklist={(checklistData) => { onCreateChecklist={checklistData => {
let position = 65535; let position = 65535;
if (data.findTask.checklists) { if (data.findTask.checklists) {
const [lastChecklist] = data.findTask.checklists.slice(-1); const [lastChecklist] = data.findTask.checklists.slice(-1);
@ -647,79 +494,12 @@ const Details: React.FC<DetailsProps> = ({
> >
<DueDateManager <DueDateManager
task={task} task={task}
onRemoveDueDate={(t) => { onRemoveDueDate={t => {
updateTaskDueDate({ updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
variables: {
taskID: t.id,
dueDate: null,
hasTime: false,
deleteNotifications: t.dueDate.notifications
? t.dueDate.notifications.map((n) => ({ id: n.id }))
: [],
updateNotifications: [],
createNotifications: [],
},
});
hidePopup(); hidePopup();
}} }}
onDueDateChange={(t, newDueDate, hasTime, notifications) => { onDueDateChange={(t, newDueDate) => {
const updatedNotifications = notifications.current updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
.filter((c) => c.externalId !== null)
.map((c) => {
let duration = DueDateNotificationDuration.Minute;
switch (c.duration.value) {
case 'hour':
duration = DueDateNotificationDuration.Hour;
break;
case 'day':
duration = DueDateNotificationDuration.Day;
break;
case 'week':
duration = DueDateNotificationDuration.Week;
break;
default:
break;
}
return {
id: c.externalId ?? '',
period: c.period,
duration,
};
});
const newNotifications = notifications.current
.filter((c) => c.externalId === null)
.map((c) => {
let duration = DueDateNotificationDuration.Minute;
switch (c.duration.value) {
case 'hour':
duration = DueDateNotificationDuration.Hour;
break;
case 'day':
duration = DueDateNotificationDuration.Day;
break;
case 'week':
duration = DueDateNotificationDuration.Week;
break;
default:
break;
}
return {
taskID: task.id,
period: c.period,
duration,
};
});
// const updatedNotifications = notifications.filter(c => c.externalId === null);
updateTaskDueDate({
variables: {
taskID: t.id,
dueDate: newDueDate,
hasTime,
createNotifications: newNotifications,
updateNotifications: updatedNotifications,
deleteNotifications: notifications.removed.map((n) => ({ id: n })),
},
});
hidePopup(); hidePopup();
}} }}
onCancel={NOOP} onCancel={NOOP}
@ -729,8 +509,6 @@ const Details: React.FC<DetailsProps> = ({
); );
}} }}
/> />
) : (
<TaskDetailsLoading />
); );
}} }}
/> />

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,45 +1,34 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { ApolloClient } from '@apollo/client'; import axios from 'axios';
import { ApolloProvider } from '@apollo/client/react'; 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 { enableMapSet } from 'immer'; import { enableMapSet } from 'immer';
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import updateLocale from 'dayjs/plugin/updateLocale'; import updateLocale from 'dayjs/plugin/updateLocale';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween'; import isBetween from 'dayjs/plugin/isBetween';
import weekday from 'dayjs/plugin/weekday'; import weekday from 'dayjs/plugin/weekday';
import duration from 'dayjs/plugin/duration'; import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
import relativeTime from 'dayjs/plugin/relativeTime';
import log from 'loglevel';
import remote from 'loglevel-plugin-remote';
import cache from './App/cache'; import cache from './App/cache';
import App from './App'; import App from './App';
if (process.env.REACT_APP_NODE_ENV === 'production') { // https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
remote.apply(log, { format: remote.json });
switch (process.env.REACT_APP_LOG_LEVEL) {
case 'info':
log.setLevel(log.levels.INFO);
break;
case 'debug':
log.setLevel(log.levels.DEBUG);
break;
default:
log.setLevel(log.levels.ERROR);
}
}
enableMapSet();
dayjs.extend(isSameOrAfter); dayjs.extend(isSameOrAfter);
dayjs.extend(weekday); dayjs.extend(weekday);
dayjs.extend(isBetween); dayjs.extend(isBetween);
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
enableMapSet();
dayjs.extend(updateLocale); dayjs.extend(updateLocale);
dayjs.extend(duration);
dayjs.extend(relativeTime);
dayjs.updateLocale('en', { dayjs.updateLocale('en', {
week: { week: {
dow: 1, // First day of week is Monday dow: 1, // First day of week is Monday
@ -47,7 +36,131 @@ dayjs.updateLocale('en', {
}, },
}); });
const client = new ApolloClient({ uri: '/graphql', cache }); 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,
});
ReactDOM.render( ReactDOM.render(
<ApolloProvider client={client}> <ApolloProvider client={client}>

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,155 @@
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 rgba(${props => props.theme.colors.bg.primary});
`;
const defaultItems = [
{
id: '1',
position: 1,
taskChecklistID: '1',
complete: false,
name: 'Tasks',
assigned: null,
dueDate: null,
},
{
id: '2',
taskChecklistID: '1',
position: 2,
complete: false,
name: 'Projects',
assigned: null,
dueDate: null,
},
{
id: '3',
position: 3,
taskChecklistID: '1',
complete: false,
name: 'Teams',
assigned: null,
dueDate: null,
},
{
id: '4',
position: 4,
complete: false,
taskChecklistID: '1',
name: 'Organizations',
assigned: null,
dueDate: null,
},
];
export const Default = () => {
const [checklistName, setChecklistName] = useState('Checklist');
const [items, setItems] = useState(defaultItems);
const onToggleItem = (itemID: string, complete: boolean) => {
setItems(
produce(items, draftState => {
const idx = items.findIndex(item => item.id === itemID);
if (idx !== -1) {
draftState[idx] = {
...draftState[idx],
complete,
};
}
}),
);
};
return (
<>
<BaseStyles />
<NormalizeStyles />
<ThemeProvider theme={theme}>
<Container>
<Checklist
wrapperProps={{}}
handleProps={{}}
name={checklistName}
checklistID="checklist-one"
items={items}
onDeleteChecklist={action('delete checklist')}
onChangeName={currentName => {
setChecklistName(currentName);
}}
onAddItem={itemName => {
let position = 1;
const lastItem = items[-1];
if (lastItem) {
position = lastItem.position * 2 + 1;
}
setItems([
...items,
{
id: `${Math.random()}`,
name: itemName,
complete: false,
assigned: null,
dueDate: null,
position,
taskChecklistID: '1',
},
]);
}}
onDeleteItem={itemID => {
setItems(items.filter(item => item.id !== itemID));
}}
onChangeItemName={(itemID, currentName) => {
setItems(
produce(items, draftState => {
const idx = items.findIndex(item => item.id === itemID);
if (idx !== -1) {
draftState[idx] = {
...draftState[idx],
name: currentName,
};
}
}),
);
}}
onToggleItem={onToggleItem}
>
{items.map(item => (
<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

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

View File

@ -7,7 +7,7 @@ const LabelText = styled.span`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: ${props => props.theme.colors.text.primary}; color: rgba(${props => props.theme.colors.text.primary});
`; `;
const Container = styled.div<{ color?: string }>` const Container = styled.div<{ color?: string }>`
@ -24,11 +24,11 @@ const Container = styled.div<{ color?: string }>`
? css` ? css`
background: ${props.color}; background: ${props.color};
& ${LabelText} { & ${LabelText} {
color: ${props.theme.colors.text.secondary}; color: rgba(${props.theme.colors.text.secondary});
} }
` `
: css` : css`
background: ${props.theme.colors.bg.primary}; background: rgba(${props.theme.colors.bg.primary});
`} `}
`; `;

View File

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

View File

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

View File

@ -0,0 +1,43 @@
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: rgba(${props => props.theme.colors.bg.primary});
padding: 45px;
margin: 25px;
display: flex;
flex-direction: column;
`;
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<ThemeProvider theme={theme}>
<Wrapper>
<Input label="Label placeholder" />
<Input width="100%" placeholder="Placeholder" />
<Input icon={<User width={20} height={20} />} width="100%" placeholder="Placeholder" />
</Wrapper>
</ThemeProvider>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,43 @@
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: rgba(${props => props.theme.colors.bg.primary});
padding: 45px;
margin: 25px;
display: flex;
flex-direction: column;
`;
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<ThemeProvider theme={theme}>
<Wrapper>
<Input label="Label placeholder" />
<Input width="100%" placeholder="Placeholder" />
<Input icon={<User width={20} height={20} />} width="100%" placeholder="Placeholder" />
</Wrapper>
</ThemeProvider>
</>
);
};

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import styled, { css } from 'styled-components/macro'; import styled, { css } from 'styled-components/macro';
import theme from '../../../App/ThemeStyles';
const InputWrapper = styled.div<{ width: string }>` const InputWrapper = styled.div<{ width: string }>`
position: relative; position: relative;
@ -54,18 +53,18 @@ const InputInput = styled.input<{
transition: all 0.3s ease; transition: all 0.3s ease;
&:focus { &:focus {
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15); box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
border: 1px solid ${props => props.theme.colors.primary}; border: 1px solid rgba(115, 103, 240);
background: ${props => props.focusBg}; background: ${props => props.focusBg};
} }
&:focus ~ ${InputLabel} { &:focus ~ ${InputLabel} {
color: ${props => props.theme.colors.primary}; color: rgba(115, 103, 240);
transform: translate(-3px, -90%); transform: translate(-3px, -90%);
} }
${props => ${props =>
props.hasValue && props.hasValue &&
css` css`
& ~ ${InputLabel} { & ~ ${InputLabel} {
color: ${props.theme.colors.primary}; color: rgba(115, 103, 240);
transform: translate(-3px, -90%); transform: translate(-3px, -90%);
} }
`} `}
@ -139,8 +138,8 @@ const Input = React.forwardRef(
$ref: any, $ref: any,
) => { ) => {
const [hasValue, setHasValue] = useState(defaultValue !== ''); const [hasValue, setHasValue] = useState(defaultValue !== '');
const borderColor = variant === 'normal' ? 'rgba(0,0,0,0.2)' : theme.colors.alternate; const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561';
const focusBg = variant === 'normal' ? theme.colors.bg.secondary : theme.colors.bg.primary; const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)';
// Merge forwarded ref and internal ref in order to be able to access the ref in the useEffect // Merge forwarded ref and internal ref in order to be able to access the ref in the useEffect
// The forwarded ref is not accessible by itself, which is what the innerRef & combined ref is for // The forwarded ref is not accessible by itself, which is what the innerRef & combined ref is for

View File

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

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,11 @@
import styled from 'styled-components'; import styled from 'styled-components';
export const Container = styled.div` export const Container = styled.div`
flex: 1;
user-select: none; user-select: none;
white-space: nowrap; white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
::-webkit-scrollbar { ::-webkit-scrollbar {
height: 10px; height: 10px;

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