Compare commits
3 Commits
0.3.2
...
feat/outli
Author | SHA1 | Date | |
---|---|---|---|
960f07cd11 | |||
19d302355f | |||
f4ef7fec83 |
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -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
|
|
30
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
|
||||||
|
|
||||||
|
-->
|
@ -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.
|
||||||
|
|
||||||
|
2
Pipfile
2
Pipfile
@ -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
71
Pipfile.lock
generated
@ -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": {}
|
||||||
|
29
README.md
29
README.md
@ -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**
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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)!
|
||||||
|
|
||||||
|
@ -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
|
|
||||||
|
@ -9,11 +9,10 @@
|
|||||||
"@types/axios": "^0.14.0",
|
"@types/axios": "^0.14.0",
|
||||||
"@types/color": "^3.0.1",
|
"@types/color": "^3.0.1",
|
||||||
"@types/date-fns": "^2.6.0",
|
"@types/date-fns": "^2.6.0",
|
||||||
"@types/dompurify": "^2.0.4",
|
|
||||||
"@types/emoji-mart": "^3.0.4",
|
|
||||||
"@types/jest": "^24.0.0",
|
"@types/jest": "^24.0.0",
|
||||||
"@types/jwt-decode": "^2.2.1",
|
"@types/jwt-decode": "^2.2.1",
|
||||||
"@types/lodash": "^4.14.149",
|
"@types/lodash": "^4.14.149",
|
||||||
|
"@types/marked": "^1.2.2",
|
||||||
"@types/node": "^12.0.0",
|
"@types/node": "^12.0.0",
|
||||||
"@types/query-string": "^6.3.0",
|
"@types/query-string": "^6.3.0",
|
||||||
"@types/react": "^16.9.21",
|
"@types/react": "^16.9.21",
|
||||||
@ -24,6 +23,7 @@
|
|||||||
"@types/react-router-dom": "^5.1.3",
|
"@types/react-router-dom": "^5.1.3",
|
||||||
"@types/react-select": "^3.0.13",
|
"@types/react-select": "^3.0.13",
|
||||||
"@types/react-timeago": "^4.1.1",
|
"@types/react-timeago": "^4.1.1",
|
||||||
|
"@types/react-window": "^1.8.2",
|
||||||
"@types/styled-components": "^5.0.0",
|
"@types/styled-components": "^5.0.0",
|
||||||
"apollo-cache-inmemory": "^1.6.5",
|
"apollo-cache-inmemory": "^1.6.5",
|
||||||
"apollo-client": "^2.6.8",
|
"apollo-client": "^2.6.8",
|
||||||
@ -32,21 +32,18 @@
|
|||||||
"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": "^2.2.7",
|
"axios-auth-refresh": "^2.2.7",
|
||||||
"color": "^3.1.2",
|
"color": "^3.1.2",
|
||||||
"date-fns": "^2.14.0",
|
"date-fns": "^2.14.0",
|
||||||
"dayjs": "^1.9.1",
|
"dayjs": "^1.9.1",
|
||||||
"dompurify": "^2.2.6",
|
|
||||||
"emoji-mart": "^3.0.0",
|
|
||||||
"emoticon": "^3.2.0",
|
|
||||||
"graphql": "^15.0.0",
|
"graphql": "^15.0.0",
|
||||||
"graphql-tag": "^2.10.3",
|
"graphql-tag": "^2.10.3",
|
||||||
"history": "^4.10.1",
|
"history": "^4.10.1",
|
||||||
"immer": "^8.0.1",
|
"immer": "^6.0.3",
|
||||||
"jwt-decode": "^2.2.0",
|
"jwt-decode": "^2.2.0",
|
||||||
"lodash": "^4.17.20",
|
"lodash": "^4.17.20",
|
||||||
"node-emoji": "^1.10.0",
|
"marked": "^2.0.0",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"query-string": "^6.13.7",
|
"query-string": "^6.13.7",
|
||||||
"react": "^16.12.0",
|
"react": "^16.12.0",
|
||||||
@ -54,7 +51,6 @@
|
|||||||
"react-beautiful-dnd": "^13.0.0",
|
"react-beautiful-dnd": "^13.0.0",
|
||||||
"react-datepicker": "^2.14.1",
|
"react-datepicker": "^2.14.1",
|
||||||
"react-dom": "^16.12.0",
|
"react-dom": "^16.12.0",
|
||||||
"react-emoji-render": "^1.2.4",
|
|
||||||
"react-hook-form": "^6.0.6",
|
"react-hook-form": "^6.0.6",
|
||||||
"react-markdown": "^4.3.1",
|
"react-markdown": "^4.3.1",
|
||||||
"react-router": "^5.1.2",
|
"react-router": "^5.1.2",
|
||||||
@ -63,6 +59,8 @@
|
|||||||
"react-select": "^3.1.0",
|
"react-select": "^3.1.0",
|
||||||
"react-timeago": "^4.4.0",
|
"react-timeago": "^4.4.0",
|
||||||
"react-toastify": "^6.0.8",
|
"react-toastify": "^6.0.8",
|
||||||
|
"react-visibility-sensor": "^5.1.1",
|
||||||
|
"react-window": "^1.8.6",
|
||||||
"rich-markdown-editor": "^10.6.5",
|
"rich-markdown-editor": "^10.6.5",
|
||||||
"styled-components": "^5.0.1",
|
"styled-components": "^5.0.1",
|
||||||
"typescript": "~3.7.2"
|
"typescript": "~3.7.2"
|
||||||
@ -73,6 +71,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"
|
||||||
@ -97,6 +97,16 @@
|
|||||||
"@graphql-codegen/typescript": "^1.13.2",
|
"@graphql-codegen/typescript": "^1.13.2",
|
||||||
"@graphql-codegen/typescript-operations": "^1.13.2",
|
"@graphql-codegen/typescript-operations": "^1.13.2",
|
||||||
"@graphql-codegen/typescript-react-apollo": "^1.13.2",
|
"@graphql-codegen/typescript-react-apollo": "^1.13.2",
|
||||||
|
"@storybook/addon-actions": "^5.3.13",
|
||||||
|
"@storybook/addon-backgrounds": "^5.3.17",
|
||||||
|
"@storybook/addon-docs": "^5.3.17",
|
||||||
|
"@storybook/addon-knobs": "^5.3.17",
|
||||||
|
"@storybook/addon-links": "^5.3.13",
|
||||||
|
"@storybook/addon-storysource": "^5.3.17",
|
||||||
|
"@storybook/addon-viewport": "^5.3.17",
|
||||||
|
"@storybook/addons": "^5.3.13",
|
||||||
|
"@storybook/preset-create-react-app": "^1.5.2",
|
||||||
|
"@storybook/react": "^5.3.13",
|
||||||
"@typescript-eslint/eslint-plugin": "^2.20.0",
|
"@typescript-eslint/eslint-plugin": "^2.20.0",
|
||||||
"@typescript-eslint/parser": "^2.20.0",
|
"@typescript-eslint/parser": "^2.20.0",
|
||||||
"eslint": "^6.8.0",
|
"eslint": "^6.8.0",
|
||||||
|
@ -174,7 +174,7 @@ 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({
|
||||||
@ -214,6 +214,9 @@ const AdminRoute = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (loading) {
|
||||||
|
return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />;
|
||||||
|
}
|
||||||
if (data && user) {
|
if (data && user) {
|
||||||
if (user.roles.org !== 'admin') {
|
if (user.roles.org !== 'admin') {
|
||||||
return <Redirect to="/" />;
|
return <Redirect to="/" />;
|
||||||
@ -256,7 +259,7 @@ const AdminRoute = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />;
|
return <span>error</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminRoute;
|
export default AdminRoute;
|
||||||
|
@ -126,8 +126,4 @@ export default createGlobalStyle`
|
|||||||
}
|
}
|
||||||
|
|
||||||
${mixin.placeholderColor(color.textLight)}
|
${mixin.placeholderColor(color.textLight)}
|
||||||
|
|
||||||
.picker-hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
@ -1,239 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } 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 theme from './ThemeStyles';
|
|
||||||
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 colors = [theme.colors.primary, theme.colors.secondary];
|
|
||||||
|
|
||||||
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;
|
|
@ -4,7 +4,6 @@ import * as H from 'history';
|
|||||||
|
|
||||||
import Dashboard from 'Dashboard';
|
import Dashboard from 'Dashboard';
|
||||||
import Admin from 'Admin';
|
import Admin from 'Admin';
|
||||||
import MyTasks from 'MyTasks';
|
|
||||||
import Confirm from 'Confirm';
|
import Confirm from 'Confirm';
|
||||||
import Projects from 'Projects';
|
import Projects from 'Projects';
|
||||||
import Project from 'Projects/Project';
|
import Project from 'Projects/Project';
|
||||||
@ -16,6 +15,7 @@ import styled from 'styled-components';
|
|||||||
import JwtDecode from 'jwt-decode';
|
import JwtDecode from 'jwt-decode';
|
||||||
import { setAccessToken } from 'shared/utils/accessToken';
|
import { setAccessToken } from 'shared/utils/accessToken';
|
||||||
import { useCurrentUser } from 'App/context';
|
import { useCurrentUser } from 'App/context';
|
||||||
|
import Outline from 'Outline';
|
||||||
|
|
||||||
const MainContent = styled.div`
|
const MainContent = styled.div`
|
||||||
padding: 0 0 0 0;
|
padding: 0 0 0 0;
|
||||||
@ -36,7 +36,10 @@ const AuthorizedRoutes = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { setUser } = useCurrentUser();
|
const { setUser } = useCurrentUser();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
fetch('/auth/refresh_token', {
|
fetch('/auth/refresh_token', {
|
||||||
|
signal: abortController.signal,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
}).then(async x => {
|
}).then(async x => {
|
||||||
@ -60,6 +63,9 @@ const AuthorizedRoutes = () => {
|
|||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
return loading ? null : (
|
return loading ? null : (
|
||||||
<Switch>
|
<Switch>
|
||||||
@ -68,9 +74,9 @@ const AuthorizedRoutes = () => {
|
|||||||
<Route exact path="/projects" component={Projects} />
|
<Route exact path="/projects" component={Projects} />
|
||||||
<Route path="/projects/:projectID" component={Project} />
|
<Route path="/projects/:projectID" component={Project} />
|
||||||
<Route path="/teams/:teamID" component={Teams} />
|
<Route path="/teams/:teamID" component={Teams} />
|
||||||
|
<Route path="/outline" component={Outline} />
|
||||||
<Route path="/profile" component={Profile} />
|
<Route path="/profile" component={Profile} />
|
||||||
<Route path="/admin" component={Admin} />
|
<Route path="/admin" component={Admin} />
|
||||||
<Route path="/tasks" component={MyTasks} />
|
|
||||||
</MainContent>
|
</MainContent>
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
|
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
|
||||||
|
import styled from 'styled-components/macro';
|
||||||
import { ProfileMenu } from 'shared/components/DropdownMenu';
|
import { ProfileMenu } from 'shared/components/DropdownMenu';
|
||||||
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
|
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
|
||||||
import { useHistory } from 'react-router';
|
import { useHistory } from 'react-router';
|
||||||
@ -8,16 +9,163 @@ import {
|
|||||||
RoleCode,
|
RoleCode,
|
||||||
useTopNavbarQuery,
|
useTopNavbarQuery,
|
||||||
useDeleteProjectMutation,
|
useDeleteProjectMutation,
|
||||||
|
useGetProjectsQuery,
|
||||||
GetProjectsDocument,
|
GetProjectsDocument,
|
||||||
} from 'shared/generated/graphql';
|
} from 'shared/generated/graphql';
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||||
|
import { History } from 'history';
|
||||||
import produce from 'immer';
|
import produce from 'immer';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import MiniProfile from 'shared/components/MiniProfile';
|
import MiniProfile from 'shared/components/MiniProfile';
|
||||||
import cache from 'App/cache';
|
import cache from 'App/cache';
|
||||||
|
import NOOP from 'shared/utils/noop';
|
||||||
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
|
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
|
||||||
import theme from './ThemeStyles';
|
import theme from './ThemeStyles';
|
||||||
import ProjectFinder from './ProjectFinder';
|
|
||||||
|
|
||||||
|
const TeamContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TeamTitle = styled.h3`
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TeamProjects = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TeamProjectLink = styled(Link)`
|
||||||
|
display: flex;
|
||||||
|
font-weight: 700;
|
||||||
|
height: 36px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
text-decoration: none;
|
||||||
|
user-select: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TeamProjectBackground = styled.div<{ color: string }>`
|
||||||
|
background-image: url(null);
|
||||||
|
background-color: ${props => props.color};
|
||||||
|
|
||||||
|
background-size: cover;
|
||||||
|
background-position: 50%;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
opacity: 1;
|
||||||
|
border-radius: 3px;
|
||||||
|
&:before {
|
||||||
|
background: ${props => props.theme.colors.bg.secondary};
|
||||||
|
bottom: 0;
|
||||||
|
content: '';
|
||||||
|
left: 0;
|
||||||
|
opacity: 0.88;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TeamProjectAvatar = styled.div<{ color: string }>`
|
||||||
|
background-image: url(null);
|
||||||
|
background-color: ${props => props.color};
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background-size: cover;
|
||||||
|
border-radius: 3px 0 0 3px;
|
||||||
|
height: 36px;
|
||||||
|
width: 36px;
|
||||||
|
position: relative;
|
||||||
|
opacity: 0.7;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TeamProjectContent = styled.div`
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 0 9px 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TeamProjectTitle = styled.div`
|
||||||
|
font-weight: 700;
|
||||||
|
display: block;
|
||||||
|
padding-right: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TeamProjectContainer = styled.div`
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 3px;
|
||||||
|
position: relative;
|
||||||
|
margin: 0 4px 4px 0;
|
||||||
|
min-width: 0;
|
||||||
|
&:hover ${TeamProjectTitle} {
|
||||||
|
color: ${props => props.theme.colors.text.secondary};
|
||||||
|
}
|
||||||
|
&:hover ${TeamProjectAvatar} {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
&:hover ${TeamProjectBackground}:before {
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const colors = [theme.colors.primary, theme.colors.secondary];
|
||||||
|
|
||||||
|
const ProjectFinder = () => {
|
||||||
|
const { loading, data } = useGetProjectsQuery();
|
||||||
|
if (loading) {
|
||||||
|
return <span>loading</span>;
|
||||||
|
}
|
||||||
|
if (data) {
|
||||||
|
const { projects, teams } = data;
|
||||||
|
const projectTeams = teams.map(team => {
|
||||||
|
return {
|
||||||
|
id: team.id,
|
||||||
|
name: team.name,
|
||||||
|
projects: projects.filter(project => project.team && project.team.id === team.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{projectTeams.map(team => (
|
||||||
|
<TeamContainer key={team.id}>
|
||||||
|
<TeamTitle>{team.name}</TeamTitle>
|
||||||
|
<TeamProjects>
|
||||||
|
{team.projects.map((project, idx) => (
|
||||||
|
<TeamProjectContainer key={project.id}>
|
||||||
|
<TeamProjectLink to={`/projects/${project.id}`}>
|
||||||
|
<TeamProjectBackground color={colors[idx % 5]} />
|
||||||
|
<TeamProjectAvatar color={colors[idx % 5]} />
|
||||||
|
<TeamProjectContent>
|
||||||
|
<TeamProjectTitle>{project.name}</TeamProjectTitle>
|
||||||
|
</TeamProjectContent>
|
||||||
|
</TeamProjectLink>
|
||||||
|
</TeamProjectContainer>
|
||||||
|
))}
|
||||||
|
</TeamProjects>
|
||||||
|
</TeamContainer>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>error</span>;
|
||||||
|
};
|
||||||
type ProjectPopupProps = {
|
type ProjectPopupProps = {
|
||||||
history: any;
|
history: any;
|
||||||
name: string;
|
name: string;
|
||||||
@ -277,9 +425,6 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
|||||||
onDashboardClick={() => {
|
onDashboardClick={() => {
|
||||||
history.push('/');
|
history.push('/');
|
||||||
}}
|
}}
|
||||||
onMyTasksClick={() => {
|
|
||||||
history.push('/tasks');
|
|
||||||
}}
|
|
||||||
projectMembers={projectMembers}
|
projectMembers={projectMembers}
|
||||||
projectInvitedMembers={projectInvitedMembers}
|
projectInvitedMembers={projectInvitedMembers}
|
||||||
onProfileClick={onProfileClick}
|
onProfileClick={onProfileClick}
|
||||||
|
@ -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;
|
|
@ -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;
|
|
@ -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 };
|
|
@ -1,913 +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 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) {
|
|
||||||
updateTaskDueDate({ variables: { taskID: dateEditor.task.id, dueDate, hasTime } });
|
|
||||||
setDateEditor(prev => ({ ...prev, task: { ...task, dueDate: dueDate.toISOString(), hasTime } }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onRemoveDueDate={task => {
|
|
||||||
if (dateEditor.task) {
|
|
||||||
updateTaskDueDate({ variables: { taskID: dateEditor.task.id, dueDate: null, hasTime: false } });
|
|
||||||
setDateEditor(prev => ({ ...prev, task: { ...task, hasTime: false } }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popup>,
|
|
||||||
{ onClose: () => setDateEditor({ open: false, task: null, pos: null }) },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [dateEditor]);
|
|
||||||
|
|
||||||
const [createTask] = useCreateTaskMutation({
|
|
||||||
update: (client, newTaskData) => {
|
|
||||||
updateApolloCache<MyTasksQuery>(
|
|
||||||
client,
|
|
||||||
MyTasksDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
if (newTaskData.data) {
|
|
||||||
draftCache.myTasks.tasks.unshift(newTaskData.data.createTask);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ status: MyTasksStatus.All, sort: MyTasksSort.None },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [setTaskComplete] = useSetTaskCompleteMutation();
|
|
||||||
const [updateTaskName] = useUpdateTaskNameMutation();
|
|
||||||
const [minified, setMinified] = useStickyState<Array<string>>([], 'my_tasks_minified');
|
|
||||||
useOnOutsideClick(
|
|
||||||
$editorContents,
|
|
||||||
projectEditor.open,
|
|
||||||
() =>
|
|
||||||
setProjectEditor({
|
|
||||||
open: false,
|
|
||||||
task: null,
|
|
||||||
pos: null,
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
if (data) {
|
|
||||||
const groups: Array<Group> = [];
|
|
||||||
if (filters.sort === MyTasksSort.None) {
|
|
||||||
groups.push({
|
|
||||||
id: 'recently-assigned',
|
|
||||||
name: 'Recently Assigned',
|
|
||||||
tasks: data.myTasks.tasks.map(task => ({
|
|
||||||
...task,
|
|
||||||
labels: [],
|
|
||||||
position: 0,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let { tasks } = data.myTasks;
|
|
||||||
if (filters.sort === MyTasksSort.DueDate) {
|
|
||||||
const group: Group = { id: 'due_date', name: null, tasks: [] };
|
|
||||||
data.myTasks.tasks.forEach(task => {
|
|
||||||
if (task.dueDate) {
|
|
||||||
group.tasks.push({ ...task, labels: [], position: 0 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
groups.push(group);
|
|
||||||
tasks = tasks.filter(t => t.dueDate === null);
|
|
||||||
}
|
|
||||||
const projects = new Map<string, Array<Task>>();
|
|
||||||
data.myTasks.projects.forEach(p => {
|
|
||||||
if (!projects.has(p.projectID)) {
|
|
||||||
projects.set(p.projectID, []);
|
|
||||||
}
|
|
||||||
const prev = projects.get(p.projectID);
|
|
||||||
const task = tasks.find(t => t.id === p.taskID);
|
|
||||||
if (prev && task) {
|
|
||||||
projects.set(p.projectID, [...prev, { ...task, labels: [], position: 0 }]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
for (const [id, pTasks] of projects) {
|
|
||||||
const project = data.projects.find(c => c.id === id);
|
|
||||||
if (pTasks.length === 0) continue;
|
|
||||||
if (project) {
|
|
||||||
groups.push({
|
|
||||||
id,
|
|
||||||
name: project.name,
|
|
||||||
tasks: pTasks.sort((a, b) => {
|
|
||||||
if (a.dueDate === null && b.dueDate === null) return 0;
|
|
||||||
if (a.dueDate === null && b.dueDate !== null) return 1;
|
|
||||||
if (a.dueDate !== null && b.dueDate === null) return -1;
|
|
||||||
const first = dayjs(a.dueDate);
|
|
||||||
const second = dayjs(b.dueDate);
|
|
||||||
if (first.isSame(second, 'minute')) return 0;
|
|
||||||
if (first.isAfter(second)) return -1;
|
|
||||||
return 1;
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
groups.sort((a, b) => {
|
|
||||||
if (a.name === null && b.name === null) return 0;
|
|
||||||
if (a.name === null) return -1;
|
|
||||||
if (b.name === null) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
|
|
||||||
<ProjectBar>
|
|
||||||
<ProjectActions />
|
|
||||||
<ProjectActions>
|
|
||||||
<ProjectAction
|
|
||||||
onClick={$target => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<MyTasksStatusPopup
|
|
||||||
status={filters.status}
|
|
||||||
onChangeStatus={status => {
|
|
||||||
setFilters(prev => ({ ...prev, status }));
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
{ width: 185 },
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckCircleOutline width={13} height={13} />
|
|
||||||
<ProjectActionText>{prettyStatus(filters.status)}</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
<ProjectAction
|
|
||||||
onClick={$target => {
|
|
||||||
showPopup(
|
|
||||||
$target,
|
|
||||||
<MyTasksSortPopup
|
|
||||||
sort={filters.sort}
|
|
||||||
onChangeSort={sort => {
|
|
||||||
setFilters(prev => ({ ...prev, sort }));
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
{ width: 185 },
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Sort width={13} height={13} />
|
|
||||||
<ProjectActionText>{prettySort(filters.sort)}</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
<ProjectAction disabled>
|
|
||||||
<Cogs width={13} height={13} />
|
|
||||||
<ProjectActionText>Customize</ProjectActionText>
|
|
||||||
</ProjectAction>
|
|
||||||
</ProjectActions>
|
|
||||||
</ProjectBar>
|
|
||||||
<ScrollContainer>
|
|
||||||
<Row>
|
|
||||||
<RowHeaderLeft width={leftRow}>
|
|
||||||
<RowHeaderLeftInner>
|
|
||||||
<RowHeaderLeftName>
|
|
||||||
<RowHeaderLeftNameText>Task name</RowHeaderLeftNameText>
|
|
||||||
</RowHeaderLeftName>
|
|
||||||
</RowHeaderLeftInner>
|
|
||||||
</RowHeaderLeft>
|
|
||||||
<RowHeaderRight left={leftRow}>
|
|
||||||
<RowScrollable>
|
|
||||||
<RowScrollContent>
|
|
||||||
<RowHeaderRightContainer>
|
|
||||||
<ItemsContainer>
|
|
||||||
<ItemWrapper width={120}>
|
|
||||||
<ItemName>Due date</ItemName>
|
|
||||||
</ItemWrapper>
|
|
||||||
<ItemWrapper width={120}>
|
|
||||||
<ItemName>Project</ItemName>
|
|
||||||
</ItemWrapper>
|
|
||||||
<ItemWrapper width={50} />
|
|
||||||
</ItemsContainer>
|
|
||||||
</RowHeaderRightContainer>
|
|
||||||
</RowScrollContent>
|
|
||||||
</RowScrollable>
|
|
||||||
</RowHeaderRight>
|
|
||||||
</Row>
|
|
||||||
<VerticalScoller>
|
|
||||||
<VerticalScollerInner>
|
|
||||||
<TableContents>
|
|
||||||
{groups.map(group => {
|
|
||||||
const isMinified = minified.find(m => m === group.id) ?? false;
|
|
||||||
return (
|
|
||||||
<TaskGroupContainer key={group.id}>
|
|
||||||
{group.name && (
|
|
||||||
<TaskGroupHeader>
|
|
||||||
<TaskGroupHeaderContents width={leftRow}>
|
|
||||||
<TaskGroupMinify
|
|
||||||
onClick={() => {
|
|
||||||
setMinified(prev => {
|
|
||||||
if (isMinified) {
|
|
||||||
return prev.filter(c => c !== group.id);
|
|
||||||
}
|
|
||||||
return [...prev, group.id];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isMinified ? (
|
|
||||||
<CaretRight width={16} height={16} />
|
|
||||||
) : (
|
|
||||||
<CaretDown width={16} height={16} />
|
|
||||||
)}
|
|
||||||
</TaskGroupMinify>
|
|
||||||
<TaskGroupName>{group.name}</TaskGroupName>
|
|
||||||
</TaskGroupHeaderContents>
|
|
||||||
</TaskGroupHeader>
|
|
||||||
)}
|
|
||||||
<TaskGroupItems>
|
|
||||||
{!isMinified &&
|
|
||||||
group.tasks.map(task => {
|
|
||||||
const projectID = data.myTasks.projects.find(t => t.taskID === task.id)?.projectID;
|
|
||||||
const projectName = data.projects.find(p => p.id === projectID)?.name;
|
|
||||||
return (
|
|
||||||
<TaskEntry
|
|
||||||
key={task.id}
|
|
||||||
complete={task.complete ?? false}
|
|
||||||
onToggleComplete={complete => {
|
|
||||||
setTaskComplete({ variables: { taskID: task.id, complete } });
|
|
||||||
}}
|
|
||||||
onTaskDetails={() => {
|
|
||||||
history.push(`${match.url}/c/${task.id}`);
|
|
||||||
}}
|
|
||||||
onRemoveDueDate={() => {
|
|
||||||
updateTaskDueDate({ variables: { taskID: task.id, dueDate: null, hasTime: false } });
|
|
||||||
}}
|
|
||||||
project={projectName ?? 'none'}
|
|
||||||
dueDate={task.dueDate}
|
|
||||||
hasTime={task.hasTime ?? false}
|
|
||||||
name={task.name}
|
|
||||||
onEditName={name => updateTaskName({ variables: { taskID: task.id, name } })}
|
|
||||||
onEditProject={onEditProject}
|
|
||||||
onEditDueDate={$target => onEditDueDate({ ...task, position: 0, labels: [] }, $target)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TaskGroupItems>
|
|
||||||
</TaskGroupContainer>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableContents>
|
|
||||||
</VerticalScollerInner>
|
|
||||||
</VerticalScoller>
|
|
||||||
</ScrollContainer>
|
|
||||||
{dateEditor.open && dateEditor.pos !== null && dateEditor.task && (
|
|
||||||
<EditorPositioner left={dateEditor.pos.left} top={dateEditor.pos.top}>
|
|
||||||
<EditorPositionerContents ref={$dateContents}>
|
|
||||||
<EditorContainer width={120}>
|
|
||||||
<EditorCell width={120}>
|
|
||||||
<DueDateEditorLabel>
|
|
||||||
{dateEditor.task.dueDate
|
|
||||||
? dayjs(dateEditor.task.dueDate).format(dateEditor.task.hasTime ? 'MMM D [at] h:mm A' : 'MMM D')
|
|
||||||
: ''}
|
|
||||||
</DueDateEditorLabel>
|
|
||||||
</EditorCell>
|
|
||||||
</EditorContainer>
|
|
||||||
</EditorPositionerContents>
|
|
||||||
</EditorPositioner>
|
|
||||||
)}
|
|
||||||
{projectEditor.open && projectEditor.pos !== null && (
|
|
||||||
<EditorPositioner left={projectEditor.pos.left} top={projectEditor.pos.top}>
|
|
||||||
<EditorPositionerContents ref={$editorContents}>
|
|
||||||
<EditorContainer width={300}>
|
|
||||||
<EditorCell width={300}>
|
|
||||||
<Select
|
|
||||||
components={{ SingleValue, Option }}
|
|
||||||
autoFocus
|
|
||||||
styles={editorColourStyles}
|
|
||||||
options={[{ label: 'hello', value: '1' }]}
|
|
||||||
onInputChange={(query, { action }) => {
|
|
||||||
if (action === 'input-change') {
|
|
||||||
setMenuOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={() => setMenuOpen(false)}
|
|
||||||
onBlur={() => setMenuOpen(false)}
|
|
||||||
menuIsOpen={menuOpen}
|
|
||||||
/>
|
|
||||||
</EditorCell>
|
|
||||||
</EditorContainer>
|
|
||||||
</EditorPositionerContents>
|
|
||||||
</EditorPositioner>
|
|
||||||
)}
|
|
||||||
<Route
|
|
||||||
path={`${match.path}/c/:taskID`}
|
|
||||||
render={(routeProps: RouteComponentProps<TaskRouteProps>) => (
|
|
||||||
<Details
|
|
||||||
refreshCache={NOOP}
|
|
||||||
availableMembers={[]}
|
|
||||||
projectURL={`${match.url}`}
|
|
||||||
taskID={routeProps.match.params.taskID}
|
|
||||||
onTaskNameChange={(updatedTask, newName) => {
|
|
||||||
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
|
|
||||||
}}
|
|
||||||
onTaskDescriptionChange={(updatedTask, newDescription) => {
|
|
||||||
/*
|
|
||||||
updateTaskDescription({
|
|
||||||
variables: { taskID: updatedTask.id, description: newDescription },
|
|
||||||
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;
|
|
24
frontend/src/Outline/DragDebug.tsx
Normal file
24
frontend/src/Outline/DragDebug.tsx
Normal 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;
|
41
frontend/src/Outline/DragIndicator.tsx
Normal file
41
frontend/src/Outline/DragIndicator.tsx
Normal 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;
|
385
frontend/src/Outline/Dragger.tsx
Normal file
385
frontend/src/Outline/Dragger.tsx
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react';
|
||||||
|
import { Dot } from 'shared/icons';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import {
|
||||||
|
findNextDraggable,
|
||||||
|
getDimensions,
|
||||||
|
getTargetDepth,
|
||||||
|
getNodeAbove,
|
||||||
|
getBelowParent,
|
||||||
|
findNodeAbove,
|
||||||
|
getNodeOver,
|
||||||
|
getLastChildInBranch,
|
||||||
|
findNodeDepth,
|
||||||
|
} from './utils';
|
||||||
|
import { useDrag } from './useDrag';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(${p => p.theme.colors.primary});
|
||||||
|
svg {
|
||||||
|
fill: rgba(${p => p.theme.colors.text.primary});
|
||||||
|
stroke: rgba(${p => p.theme.colors.text.primary});
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type DraggerProps = {
|
||||||
|
container: React.RefObject<HTMLDivElement>;
|
||||||
|
draggedNodes: { nodes: Array<string>; first?: OutlineNode | null };
|
||||||
|
isDragging: boolean;
|
||||||
|
onDragEnd: (zone: ImpactZone) => void;
|
||||||
|
initialPos: { x: number; y: number };
|
||||||
|
pageRef: React.RefObject<HTMLDivElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let timer: any = null;
|
||||||
|
|
||||||
|
type windowScrollOptions = {
|
||||||
|
maxScrollX: number;
|
||||||
|
maxScrollY: number;
|
||||||
|
isInTopEdge: boolean;
|
||||||
|
isInBottomEdge: boolean;
|
||||||
|
edgeTop: number;
|
||||||
|
edgeBottom: number;
|
||||||
|
edgeSize: number;
|
||||||
|
viewportY: number;
|
||||||
|
$page: React.RefObject<HTMLDivElement>;
|
||||||
|
};
|
||||||
|
function adjustWindowScroll({
|
||||||
|
maxScrollY,
|
||||||
|
maxScrollX,
|
||||||
|
$page,
|
||||||
|
isInTopEdge,
|
||||||
|
isInBottomEdge,
|
||||||
|
edgeTop,
|
||||||
|
edgeBottom,
|
||||||
|
edgeSize,
|
||||||
|
viewportY,
|
||||||
|
}: windowScrollOptions) {
|
||||||
|
// Get the current scroll position of the document.
|
||||||
|
if ($page.current) {
|
||||||
|
var currentScrollX = $page.current.scrollLeft;
|
||||||
|
var currentScrollY = $page.current.scrollTop;
|
||||||
|
|
||||||
|
// Determine if the window can be scrolled in any particular direction.
|
||||||
|
var canScrollUp = currentScrollY > 0;
|
||||||
|
var canScrollDown = currentScrollY < maxScrollY;
|
||||||
|
|
||||||
|
// Since we can potentially scroll in two directions at the same time,
|
||||||
|
// let's keep track of the next scroll, starting with the current scroll.
|
||||||
|
// Each of these values can then be adjusted independently in the logic
|
||||||
|
// below.
|
||||||
|
var nextScrollX = currentScrollX;
|
||||||
|
var nextScrollY = currentScrollY;
|
||||||
|
|
||||||
|
// As we examine the mouse position within the edge, we want to make the
|
||||||
|
// incremental scroll changes more "intense" the closer that the user
|
||||||
|
// gets the viewport edge. As such, we'll calculate the percentage that
|
||||||
|
// the user has made it "through the edge" when calculating the delta.
|
||||||
|
// Then, that use that percentage to back-off from the "max" step value.
|
||||||
|
var maxStep = 50;
|
||||||
|
|
||||||
|
// Should we scroll up?
|
||||||
|
if (isInTopEdge && canScrollUp) {
|
||||||
|
var intensity = (edgeTop - viewportY) / edgeSize;
|
||||||
|
|
||||||
|
nextScrollY = nextScrollY - maxStep * intensity;
|
||||||
|
|
||||||
|
// Should we scroll down?
|
||||||
|
} else if (isInBottomEdge && canScrollDown) {
|
||||||
|
var intensity = (viewportY - edgeBottom) / edgeSize;
|
||||||
|
|
||||||
|
nextScrollY = nextScrollY + maxStep * intensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize invalid maximums. An invalid scroll offset won't break the
|
||||||
|
// subsequent .scrollTo() call; however, it will make it harder to
|
||||||
|
// determine if the .scrollTo() method should have been called in the
|
||||||
|
// first place.
|
||||||
|
nextScrollX = Math.max(0, Math.min(maxScrollX, nextScrollX));
|
||||||
|
nextScrollY = Math.max(0, Math.min(maxScrollY, nextScrollY));
|
||||||
|
|
||||||
|
if (nextScrollX !== currentScrollX || nextScrollY !== currentScrollY) {
|
||||||
|
$page.current.scrollTo(nextScrollX, nextScrollY);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dragger: React.FC<DraggerProps> = ({
|
||||||
|
draggedNodes,
|
||||||
|
container,
|
||||||
|
onDragEnd,
|
||||||
|
isDragging,
|
||||||
|
initialPos,
|
||||||
|
pageRef: $page,
|
||||||
|
}) => {
|
||||||
|
const [pos, setPos] = useState<{ x: number; y: number }>(initialPos);
|
||||||
|
const { outline, impact, setImpact } = useDrag();
|
||||||
|
const $handle = useRef<HTMLDivElement>(null);
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
onDragEnd(impact ? impact.zone : { below: null, above: null });
|
||||||
|
}, [impact]);
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
e => {
|
||||||
|
var t0 = performance.now();
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const { clientX, clientY, pageX, pageY } = e;
|
||||||
|
setPos({ x: clientX, y: clientY });
|
||||||
|
const { curDepth, curPosition, curDraggable } = getNodeOver({ x: clientX, y: clientY }, outline.current);
|
||||||
|
let depthTarget: number = 0;
|
||||||
|
let aboveNode: null | OutlineNode = null;
|
||||||
|
let belowNode: null | OutlineNode = null;
|
||||||
|
|
||||||
|
const edgeSize = 50;
|
||||||
|
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
const viewportHeight = document.documentElement.clientHeight;
|
||||||
|
|
||||||
|
var edgeTop = edgeSize + 80;
|
||||||
|
var edgeBottom = viewportHeight - edgeSize;
|
||||||
|
|
||||||
|
var isInTopEdge = clientY < edgeTop;
|
||||||
|
var isInBottomEdge = clientY > edgeBottom;
|
||||||
|
|
||||||
|
if ((isInBottomEdge || isInTopEdge) && $page.current) {
|
||||||
|
var documentWidth = Math.max(
|
||||||
|
$page.current.scrollWidth,
|
||||||
|
$page.current.offsetWidth,
|
||||||
|
$page.current.clientWidth,
|
||||||
|
$page.current.scrollWidth,
|
||||||
|
$page.current.offsetWidth,
|
||||||
|
$page.current.clientWidth,
|
||||||
|
);
|
||||||
|
var documentHeight = Math.max(
|
||||||
|
$page.current.scrollHeight,
|
||||||
|
$page.current.offsetHeight,
|
||||||
|
$page.current.clientHeight,
|
||||||
|
$page.current.scrollHeight,
|
||||||
|
$page.current.offsetHeight,
|
||||||
|
$page.current.clientHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
var maxScrollX = documentWidth - viewportWidth;
|
||||||
|
var maxScrollY = documentHeight - viewportHeight;
|
||||||
|
|
||||||
|
(function checkForWindowScroll() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (
|
||||||
|
adjustWindowScroll({
|
||||||
|
maxScrollX,
|
||||||
|
maxScrollY,
|
||||||
|
edgeBottom,
|
||||||
|
$page,
|
||||||
|
edgeTop,
|
||||||
|
edgeSize,
|
||||||
|
isInBottomEdge,
|
||||||
|
isInTopEdge,
|
||||||
|
viewportY: clientY,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
timer = setTimeout(checkForWindowScroll, 30);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curPosition === 'before') {
|
||||||
|
belowNode = curDraggable;
|
||||||
|
} else {
|
||||||
|
aboveNode = curDraggable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if belowNode has the depth of 1, then the above element will be a part of a different branch
|
||||||
|
|
||||||
|
const { relationships, nodes } = outline.current;
|
||||||
|
if (!belowNode || !aboveNode) {
|
||||||
|
if (belowNode) {
|
||||||
|
aboveNode = findNodeAbove(outline.current, curDepth, belowNode);
|
||||||
|
} else if (aboveNode) {
|
||||||
|
let targetBelowNode: RelationshipChild | null = null;
|
||||||
|
const parent = relationships.get(aboveNode.parent);
|
||||||
|
if (aboveNode.children !== 0 && !aboveNode.collapsed) {
|
||||||
|
const abr = relationships.get(aboveNode.id);
|
||||||
|
if (abr) {
|
||||||
|
const newTarget = abr.children[0];
|
||||||
|
if (newTarget) {
|
||||||
|
targetBelowNode = newTarget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (parent) {
|
||||||
|
const aboveNodeIndex = parent.children.findIndex(c => aboveNode && c.id === aboveNode.id);
|
||||||
|
if (aboveNodeIndex !== -1) {
|
||||||
|
if (aboveNodeIndex === parent.children.length - 1) {
|
||||||
|
targetBelowNode = getBelowParent(aboveNode, outline.current);
|
||||||
|
} else {
|
||||||
|
const nextChild = parent.children[aboveNodeIndex + 1];
|
||||||
|
targetBelowNode = nextChild ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetBelowNode) {
|
||||||
|
const depthNodes = nodes.get(targetBelowNode.depth);
|
||||||
|
if (depthNodes) {
|
||||||
|
belowNode = depthNodes.get(targetBelowNode.id) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if outside outline, get either first or last item in list based on mouse Y
|
||||||
|
if (!aboveNode && !belowNode) {
|
||||||
|
if (container && container.current) {
|
||||||
|
const bounds = container.current.getBoundingClientRect();
|
||||||
|
if (clientY < bounds.top + bounds.height / 2) {
|
||||||
|
const rootChildren = outline.current.relationships.get('root');
|
||||||
|
const rootDepth = outline.current.nodes.get(1);
|
||||||
|
if (rootChildren && rootDepth) {
|
||||||
|
const firstChild = rootChildren.children[0];
|
||||||
|
belowNode = rootDepth.get(firstChild.id) ?? null;
|
||||||
|
aboveNode = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO: enhance to actually get last child item, not last top level branch
|
||||||
|
const rootChildren = outline.current.relationships.get('root');
|
||||||
|
const rootDepth = outline.current.nodes.get(1);
|
||||||
|
if (rootChildren && rootDepth) {
|
||||||
|
const lastChild = rootChildren.children[rootChildren.children.length - 1];
|
||||||
|
const lastParentNode = rootDepth.get(lastChild.id) ?? null;
|
||||||
|
|
||||||
|
if (lastParentNode) {
|
||||||
|
const lastBranchChild = getLastChildInBranch(outline.current, lastParentNode);
|
||||||
|
if (lastBranchChild) {
|
||||||
|
const lastChildDepth = outline.current.nodes.get(lastBranchChild.depth);
|
||||||
|
if (lastChildDepth) {
|
||||||
|
aboveNode = lastChildDepth.get(lastBranchChild.id) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aboveNode) {
|
||||||
|
const foundDepth = findNodeDepth(outline.current.published, aboveNode.id);
|
||||||
|
if (foundDepth === null) return;
|
||||||
|
for (let i = 0; i < draggedNodes.nodes.length; i++) {
|
||||||
|
const nodeID = draggedNodes.nodes[i];
|
||||||
|
if (foundDepth.ancestors.find(c => c === nodeID)) {
|
||||||
|
if (draggedNodes.first) {
|
||||||
|
belowNode = draggedNodes.first;
|
||||||
|
aboveNode = findNodeAbove(outline.current, aboveNode ? aboveNode.depth : 1, draggedNodes.first);
|
||||||
|
} else {
|
||||||
|
const foundDepth = findNodeDepth(outline.current.published, nodeID);
|
||||||
|
if (foundDepth === null) return;
|
||||||
|
const nodeDepth = outline.current.nodes.get(foundDepth.depth);
|
||||||
|
const targetNode = nodeDepth ? nodeDepth.get(nodeID) : null;
|
||||||
|
if (targetNode) {
|
||||||
|
belowNode = targetNode;
|
||||||
|
|
||||||
|
aboveNode = findNodeAbove(outline.current, foundDepth.depth, targetNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate available depths
|
||||||
|
|
||||||
|
let minDepth = 1;
|
||||||
|
let maxDepth = 2;
|
||||||
|
if (aboveNode) {
|
||||||
|
const aboveParent = relationships.get(aboveNode.parent);
|
||||||
|
if (aboveNode.children !== 0 && !aboveNode.collapsed) {
|
||||||
|
minDepth = aboveNode.depth + 1;
|
||||||
|
maxDepth = aboveNode.depth + 1;
|
||||||
|
} else if (aboveParent) {
|
||||||
|
minDepth = aboveNode.depth;
|
||||||
|
maxDepth = aboveNode.depth + 1;
|
||||||
|
const aboveNodeIndex = aboveParent.children.findIndex(c => aboveNode && c.id === aboveNode.id);
|
||||||
|
if (aboveNodeIndex === aboveParent.children.length - 1) {
|
||||||
|
minDepth = belowNode ? belowNode.depth : minDepth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aboveNode) {
|
||||||
|
const dimensions = outline.current.dimensions.get(aboveNode.id);
|
||||||
|
const entry = getDimensions(dimensions?.entry);
|
||||||
|
if (entry) {
|
||||||
|
depthTarget = getTargetDepth(clientX, entry.left, { min: minDepth, max: maxDepth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let aboveImpact: null | ImpactZoneData = null;
|
||||||
|
let belowImpact: null | ImpactZoneData = null;
|
||||||
|
if (aboveNode) {
|
||||||
|
const aboveDim = outline.current.dimensions.get(aboveNode.id);
|
||||||
|
if (aboveDim) {
|
||||||
|
aboveImpact = {
|
||||||
|
node: aboveNode,
|
||||||
|
dimensions: aboveDim,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (belowNode) {
|
||||||
|
const belowDim = outline.current.dimensions.get(belowNode.id);
|
||||||
|
if (belowDim) {
|
||||||
|
belowImpact = {
|
||||||
|
node: belowNode,
|
||||||
|
dimensions: belowDim,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setImpact({
|
||||||
|
zone: {
|
||||||
|
above: aboveImpact,
|
||||||
|
below: belowImpact,
|
||||||
|
},
|
||||||
|
depth: depthTarget,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[outline.current.nodes],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const styles = useMemo(() => {
|
||||||
|
const position: 'fixed' | 'relative' = isDragging ? 'fixed' : 'relative';
|
||||||
|
return {
|
||||||
|
cursor: isDragging ? '-webkit-grabbing' : '-webkit-grab',
|
||||||
|
transform: `translate(${pos.x - 10}px, ${pos.y - 4}px)`,
|
||||||
|
transition: isDragging ? 'none' : 'transform 500ms',
|
||||||
|
zIndex: isDragging ? 2 : 1,
|
||||||
|
position,
|
||||||
|
};
|
||||||
|
}, [isDragging, pos]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pos && (
|
||||||
|
<Container ref={$handle} style={styles}>
|
||||||
|
<Dot width={18} height={18} />
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dragger;
|
377
frontend/src/Outline/Entry.tsx
Normal file
377
frontend/src/Outline/Entry.tsx
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
|
import { Dot, CaretDown, CaretRight } from 'shared/icons';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import marked from 'marked';
|
||||||
|
import VisibilitySensor from 'react-visibility-sensor';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EntryChildren,
|
||||||
|
EntryWrapper,
|
||||||
|
EntryContent,
|
||||||
|
EntryInnerContent,
|
||||||
|
EntryHandle,
|
||||||
|
ExpandButton,
|
||||||
|
EntryContentEditor,
|
||||||
|
EntryContentDisplay,
|
||||||
|
} from './Styles';
|
||||||
|
import { useDrag } from './useDrag';
|
||||||
|
import { getCaretPosition, setCurrentCursorPosition } from './utils';
|
||||||
|
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||||
|
|
||||||
|
type EditorProps = {
|
||||||
|
text: string;
|
||||||
|
initFocus: null | { caret: null | number };
|
||||||
|
autoFocus: number | null;
|
||||||
|
onChangeCurrentText: (text: string) => void;
|
||||||
|
onDeleteEntry: (caret: number) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
handleChangeText: (caret: number) => void;
|
||||||
|
onDepthChange: (delta: number) => void;
|
||||||
|
onCreateEntry: () => void;
|
||||||
|
onNodeFocused: () => void;
|
||||||
|
};
|
||||||
|
const Editor: React.FC<EditorProps> = ({
|
||||||
|
text,
|
||||||
|
onCreateEntry,
|
||||||
|
initFocus,
|
||||||
|
autoFocus,
|
||||||
|
onChangeCurrentText,
|
||||||
|
onDepthChange,
|
||||||
|
onDeleteEntry,
|
||||||
|
onNodeFocused,
|
||||||
|
handleChangeText,
|
||||||
|
onBlur,
|
||||||
|
}) => {
|
||||||
|
const $editor = useRef<HTMLInputElement>(null);
|
||||||
|
useOnOutsideClick($editor, true, () => onBlur(), null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus && $editor.current) {
|
||||||
|
$editor.current.focus();
|
||||||
|
$editor.current.setSelectionRange(autoFocus, autoFocus);
|
||||||
|
onNodeFocused();
|
||||||
|
}
|
||||||
|
}, [autoFocus]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initFocus && $editor.current) {
|
||||||
|
$editor.current.focus();
|
||||||
|
if (initFocus.caret) {
|
||||||
|
$editor.current.setSelectionRange(initFocus.caret ?? 0, initFocus.caret ?? 0);
|
||||||
|
}
|
||||||
|
onNodeFocused();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<EntryContentEditor
|
||||||
|
value={text}
|
||||||
|
ref={$editor}
|
||||||
|
onChange={e => {
|
||||||
|
onChangeCurrentText(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.keyCode === 13) {
|
||||||
|
e.preventDefault();
|
||||||
|
// onCreateEntry(parentID, position * 2);
|
||||||
|
onCreateEntry();
|
||||||
|
return;
|
||||||
|
} else if (e.keyCode === 9) {
|
||||||
|
e.preventDefault();
|
||||||
|
onDepthChange(e.shiftKey ? -1 : 1);
|
||||||
|
} else if (e.keyCode === 8) {
|
||||||
|
const caretPos = e.currentTarget.selectionEnd;
|
||||||
|
if (caretPos === 0) {
|
||||||
|
// handleChangeText.flush();
|
||||||
|
// onDeleteEntry(depth, id, currentText, caretPos);
|
||||||
|
onDeleteEntry(caretPos);
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (e.key === 'z' && e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleChangeText(e.currentTarget.selectionEnd ?? 0);
|
||||||
|
// setCaretPos(e.currentTarget.selectionEnd ?? 0);
|
||||||
|
// handleChangeText();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntryProps = {
|
||||||
|
id: string;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse: (id: string, collapsed: boolean) => void;
|
||||||
|
parentID: string;
|
||||||
|
onStartDrag: (e: { id: string; clientX: number; clientY: number }) => void;
|
||||||
|
onStartSelect: (e: { id: string; depth: number }) => void;
|
||||||
|
isRoot?: boolean;
|
||||||
|
selection: null | Array<{ id: string }>;
|
||||||
|
draggedNodes: null | Array<string>;
|
||||||
|
onNodeFocused: (id: string) => void;
|
||||||
|
text: string;
|
||||||
|
entries: Array<ItemElement>;
|
||||||
|
onTextChange: (id: string, prex: string, next: string, caret: number) => void;
|
||||||
|
onCancelDrag: () => void;
|
||||||
|
autoFocus: null | { caret: null | number };
|
||||||
|
onCreateEntry: (parent: string, nextPositon: number) => void;
|
||||||
|
position: number;
|
||||||
|
chain?: Array<string>;
|
||||||
|
onHandleClick: (id: string) => void;
|
||||||
|
onDepthChange: (id: string, parent: string, position: number, depth: number, depthDelta: number) => void;
|
||||||
|
onDeleteEntry: (depth: number, id: string, text: string, caretPos: number) => void;
|
||||||
|
depth?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Entry: React.FC<EntryProps> = ({
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
parentID,
|
||||||
|
isRoot = false,
|
||||||
|
selection,
|
||||||
|
onToggleCollapse,
|
||||||
|
autoFocus,
|
||||||
|
onStartSelect,
|
||||||
|
onHandleClick,
|
||||||
|
onTextChange,
|
||||||
|
position,
|
||||||
|
onNodeFocused,
|
||||||
|
onDepthChange,
|
||||||
|
onCreateEntry,
|
||||||
|
onDeleteEntry,
|
||||||
|
onCancelDrag,
|
||||||
|
onStartDrag,
|
||||||
|
collapsed = false,
|
||||||
|
draggedNodes,
|
||||||
|
entries,
|
||||||
|
chain = [],
|
||||||
|
depth = 0,
|
||||||
|
}) => {
|
||||||
|
const $entry = useRef<HTMLDivElement>(null);
|
||||||
|
const $children = useRef<HTMLDivElement>(null);
|
||||||
|
const { setNodeDimensions, clearNodeDimensions } = useDrag();
|
||||||
|
if (autoFocus) {
|
||||||
|
}
|
||||||
|
|
||||||
|
const $snapshot = useRef<{ now: string; prev: string }>({ now: text, prev: text });
|
||||||
|
const [currentText, setCurrentText] = useState(text);
|
||||||
|
const [caretPos, setCaretPos] = useState(0);
|
||||||
|
const $firstRun = useRef<boolean>(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if ($firstRun.current) {
|
||||||
|
$firstRun.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('updating text');
|
||||||
|
setCurrentText(text);
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
const [editor, setEditor] = useState<{ open: boolean; caret: null | number }>({
|
||||||
|
open: false,
|
||||||
|
caret: null,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus) setEditor({ open: true, caret: null });
|
||||||
|
}, [autoFocus]);
|
||||||
|
useEffect(() => {
|
||||||
|
$snapshot.current.now = currentText;
|
||||||
|
}, [currentText]);
|
||||||
|
const handleChangeText = useCallback(
|
||||||
|
_.debounce(() => {
|
||||||
|
onTextChange(id, $snapshot.current.prev, $snapshot.current.now, caretPos);
|
||||||
|
$snapshot.current.prev = $snapshot.current.now;
|
||||||
|
}, 500),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRoot) return;
|
||||||
|
if (!visible) {
|
||||||
|
clearNodeDimensions(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($entry && $entry.current) {
|
||||||
|
setNodeDimensions(id, {
|
||||||
|
entry: $entry,
|
||||||
|
children: entries.length !== 0 ? $children : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
clearNodeDimensions(id);
|
||||||
|
};
|
||||||
|
}, [position, depth, entries, visible]);
|
||||||
|
let showHandle = true;
|
||||||
|
if (draggedNodes && draggedNodes.length === 1 && draggedNodes.find(c => c === id)) {
|
||||||
|
showHandle = false;
|
||||||
|
}
|
||||||
|
let isSelected = false;
|
||||||
|
if (selection && selection.find(c => c.id === id)) {
|
||||||
|
isSelected = true;
|
||||||
|
}
|
||||||
|
const renderMap: Array<number> = [];
|
||||||
|
const renderer = {
|
||||||
|
text(text: any) {
|
||||||
|
const localId = renderMap.length;
|
||||||
|
renderMap.push(text.length);
|
||||||
|
return `<span id="${id}_${localId}">${text}</span>`;
|
||||||
|
},
|
||||||
|
codespan(text: any) {
|
||||||
|
const localId = renderMap.length;
|
||||||
|
renderMap.push(text.length + 2);
|
||||||
|
return `<span class="markdown-code" id="${id}_${localId}">${text}</span>`;
|
||||||
|
},
|
||||||
|
strong(text: string) {
|
||||||
|
const idx = parseInt(text.split('"')[1].split('_')[1]);
|
||||||
|
renderMap[idx] += 4;
|
||||||
|
return text.replace('<span', '<span class="markdown-strong"');
|
||||||
|
},
|
||||||
|
em(text: string) {
|
||||||
|
const idx = parseInt(text.split('"')[1].split('_')[1]);
|
||||||
|
renderMap[idx] += 2;
|
||||||
|
return text.replace('<span', '<span class="markdown-em"');
|
||||||
|
},
|
||||||
|
del(text: string) {
|
||||||
|
const idx = parseInt(text.split('"')[1].split('_')[1]);
|
||||||
|
renderMap[idx] += 2;
|
||||||
|
return text.replace('<span', '<span class="markdown-del"');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
marked.use({ renderer });
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
_.debounce((e: any) => {
|
||||||
|
onStartDrag({ id, clientX: e.clientX, clientY: e.clientY });
|
||||||
|
}, 100),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<VisibilitySensor
|
||||||
|
onChange={v => {
|
||||||
|
if (v) {
|
||||||
|
setVisible(v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EntryWrapper isSelected={isSelected} isDragging={!showHandle}>
|
||||||
|
{!isRoot && (
|
||||||
|
<EntryContent>
|
||||||
|
{entries.length !== 0 && (
|
||||||
|
<ExpandButton onClick={() => onToggleCollapse(id, !collapsed)}>
|
||||||
|
{collapsed ? <CaretRight width={20} height={20} /> : <CaretDown width={20} height={20} />}
|
||||||
|
</ExpandButton>
|
||||||
|
)}
|
||||||
|
{showHandle && (
|
||||||
|
<EntryHandle
|
||||||
|
onMouseUp={() => {
|
||||||
|
handleMouseDown.cancel();
|
||||||
|
onHandleClick(id);
|
||||||
|
}}
|
||||||
|
onMouseDown={e => {
|
||||||
|
handleMouseDown(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dot width={18} height={18} />
|
||||||
|
</EntryHandle>
|
||||||
|
)}
|
||||||
|
<EntryInnerContent
|
||||||
|
onMouseDown={() => {
|
||||||
|
onStartSelect({ id, depth });
|
||||||
|
}}
|
||||||
|
ref={$entry}
|
||||||
|
>
|
||||||
|
{editor.open ? (
|
||||||
|
<Editor
|
||||||
|
onDepthChange={delta => onDepthChange(id, parentID, depth, position, delta)}
|
||||||
|
onBlur={() => setEditor({ open: false, caret: null })}
|
||||||
|
onNodeFocused={() => onNodeFocused(id)}
|
||||||
|
autoFocus={autoFocus ? (autoFocus.caret ? autoFocus.caret : 0) : null}
|
||||||
|
initFocus={editor.open ? { caret: editor.caret } : null}
|
||||||
|
text={currentText}
|
||||||
|
onDeleteEntry={caret => {
|
||||||
|
handleChangeText.flush();
|
||||||
|
onDeleteEntry(depth, id, currentText, caret);
|
||||||
|
}}
|
||||||
|
onCreateEntry={() => {
|
||||||
|
onCreateEntry(parentID, position * 2);
|
||||||
|
}}
|
||||||
|
onChangeCurrentText={text => setCurrentText(text)}
|
||||||
|
handleChangeText={caret => {
|
||||||
|
setCaretPos(caret);
|
||||||
|
handleChangeText();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EntryContentDisplay
|
||||||
|
onClick={e => {
|
||||||
|
let offset = 0;
|
||||||
|
let textNode: any;
|
||||||
|
if (document.caretPositionFromPoint) {
|
||||||
|
// standard
|
||||||
|
const range = document.caretPositionFromPoint(e.pageX, e.pageY);
|
||||||
|
console.dir(range);
|
||||||
|
if (range) {
|
||||||
|
textNode = range.offsetNode;
|
||||||
|
offset = range.offset;
|
||||||
|
}
|
||||||
|
} else if (document.caretRangeFromPoint) {
|
||||||
|
// WebKit
|
||||||
|
const range = document.caretRangeFromPoint(e.pageX, e.pageY);
|
||||||
|
if (range) {
|
||||||
|
textNode = range.startContainer;
|
||||||
|
offset = range.startOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = textNode.parentNode.id.split('_');
|
||||||
|
const index = parseInt(id[1]);
|
||||||
|
let caret = offset;
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
caret += renderMap[i];
|
||||||
|
}
|
||||||
|
setEditor({ open: true, caret });
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: marked.parseInline(text) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</EntryInnerContent>
|
||||||
|
</EntryContent>
|
||||||
|
)}
|
||||||
|
{entries.length !== 0 && !collapsed && (
|
||||||
|
<EntryChildren ref={$children} isRoot={isRoot}>
|
||||||
|
{entries
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map(entry => (
|
||||||
|
<Entry
|
||||||
|
onDeleteEntry={onDeleteEntry}
|
||||||
|
onHandleClick={onHandleClick}
|
||||||
|
onDepthChange={onDepthChange}
|
||||||
|
parentID={id}
|
||||||
|
key={entry.id}
|
||||||
|
onTextChange={onTextChange}
|
||||||
|
position={entry.position}
|
||||||
|
text={entry.text}
|
||||||
|
depth={depth + 1}
|
||||||
|
draggedNodes={draggedNodes}
|
||||||
|
collapsed={entry.collapsed}
|
||||||
|
id={entry.id}
|
||||||
|
autoFocus={entry.focus}
|
||||||
|
onNodeFocused={onNodeFocused}
|
||||||
|
onStartSelect={onStartSelect}
|
||||||
|
onStartDrag={onStartDrag}
|
||||||
|
onCancelDrag={onCancelDrag}
|
||||||
|
entries={entry.children ?? []}
|
||||||
|
chain={[...chain, id]}
|
||||||
|
selection={selection}
|
||||||
|
onToggleCollapse={onToggleCollapse}
|
||||||
|
onCreateEntry={onCreateEntry}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</EntryChildren>
|
||||||
|
)}
|
||||||
|
</EntryWrapper>
|
||||||
|
</VisibilitySensor>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Entry;
|
260
frontend/src/Outline/Styles.ts
Normal file
260
frontend/src/Outline/Styles.ts
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
import { mixin } from 'shared/utils/styles';
|
||||||
|
|
||||||
|
export const EntryWrapper = styled.div<{ isDragging: boolean; isSelected: boolean }>`
|
||||||
|
position: relative;
|
||||||
|
${props =>
|
||||||
|
props.isDragging &&
|
||||||
|
css`
|
||||||
|
&:before {
|
||||||
|
border-radius: 3px;
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: -5px;
|
||||||
|
left: -5px;
|
||||||
|
bottom: -2px;
|
||||||
|
background-color: #eceef0;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
${props =>
|
||||||
|
props.isSelected &&
|
||||||
|
css`
|
||||||
|
&:before {
|
||||||
|
border-radius: 3px;
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: -5px;
|
||||||
|
bottom: -2px;
|
||||||
|
left: -5px;
|
||||||
|
background-color: ${mixin.rgba(props.theme.colors.primary, 0.75)};
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const EntryChildren = styled.div<{ isRoot: boolean }>`
|
||||||
|
position: relative;
|
||||||
|
${props =>
|
||||||
|
!props.isRoot &&
|
||||||
|
css`
|
||||||
|
margin-left: 10px;
|
||||||
|
padding-left: 25px;
|
||||||
|
border-left: 1px solid ${mixin.rgba(props.theme.colors.text.primary, 0.6)};
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PageContent = styled.div`
|
||||||
|
min-height: calc(100vh - 146px);
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: none;
|
||||||
|
user-select: none;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
max-width: 700px;
|
||||||
|
padding-left: 56px;
|
||||||
|
padding-right: 56px;
|
||||||
|
padding-top: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
text-size-adjust: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DragHandle = styled.div<{ top: number; left: number }>`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
transform: translate3d(${props => props.left}px, ${props => props.top}px, 0);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: rgb(75, 81, 85);
|
||||||
|
border-radius: 9px;
|
||||||
|
`;
|
||||||
|
export const RootWrapper = styled.div``;
|
||||||
|
|
||||||
|
export const EntryHandle = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: absolute;
|
||||||
|
left: 501px;
|
||||||
|
top: 7px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: ${p => p.theme.colors.text.primary};
|
||||||
|
border-radius: 9px;
|
||||||
|
&:hover {
|
||||||
|
background: ${p => p.theme.colors.primary};
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
fill: ${p => p.theme.colors.text.primary};
|
||||||
|
stroke: ${p => p.theme.colors.text.primary};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export const EntryContentDisplay = styled.div`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 15px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
line-height: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
color: ${p => p.theme.colors.text.primary};
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
cursor: text;
|
||||||
|
.markdown-del {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.markdown-code {
|
||||||
|
margin-top: -4px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 19px;
|
||||||
|
color: ${props => props.theme.colors.primary};
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 4px 5px 0;
|
||||||
|
font-family: 'Consolas', Courier, monospace;
|
||||||
|
background: ${props => props.theme.colors.bg.primary};
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.markdown-em {
|
||||||
|
margin-top: -4px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.markdown-strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const EntryContentEditor = styled.input`
|
||||||
|
width: 100%;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
line-height: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
position: relative;
|
||||||
|
user-select: text;
|
||||||
|
color: ${p => p.theme.colors.text.primary};
|
||||||
|
&::selection {
|
||||||
|
background: #a49de8;
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const EntryInnerContent = styled.div`
|
||||||
|
padding-top: 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
line-height: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
position: relative;
|
||||||
|
user-select: text;
|
||||||
|
color: ${p => p.theme.colors.text.primary};
|
||||||
|
&::selection {
|
||||||
|
background: #a49de8;
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DragDebugWrapper = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
left: 42px;
|
||||||
|
bottom: 24px;
|
||||||
|
color: #fff;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DragIndicatorBar = styled.div<{ left: number; top: number; width: number }>`
|
||||||
|
position: fixed;
|
||||||
|
width: ${props => props.width}px;
|
||||||
|
top: ${props => props.top}px;
|
||||||
|
left: ${props => props.left}px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgb(204, 204, 204);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ExpandButton = styled.div`
|
||||||
|
top: 6px;
|
||||||
|
cursor: default;
|
||||||
|
color: transparent;
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
left: 478px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
svg {
|
||||||
|
fill: transparent;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export const EntryContent = styled.div`
|
||||||
|
position: relative;
|
||||||
|
margin-left: -500px;
|
||||||
|
padding-left: 524px;
|
||||||
|
|
||||||
|
&:hover ${ExpandButton} svg {
|
||||||
|
fill: ${props => props.theme.colors.text.primary};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PageContainer = styled.div`
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PageName = styled.div`
|
||||||
|
position: relative;
|
||||||
|
margin-left: -100px;
|
||||||
|
padding-left: 100px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-color: rgb(170, 170, 170);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PageNameContent = styled.div`
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
position: relative;
|
||||||
|
user-select: text;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PageNameText = styled.span``;
|
784
frontend/src/Outline/index.tsx
Normal file
784
frontend/src/Outline/index.tsx
Normal file
@ -0,0 +1,784 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo, useCallback, useContext, memo, createRef } from 'react';
|
||||||
|
import { DotCircle } from 'shared/icons';
|
||||||
|
import styled from 'styled-components/macro';
|
||||||
|
import GlobalTopNavbar from 'App/TopNavbar';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import produce from 'immer';
|
||||||
|
import Entry from './Entry';
|
||||||
|
import DragIndicator from './DragIndicator';
|
||||||
|
import Dragger from './Dragger';
|
||||||
|
import DragDebug from './DragDebug';
|
||||||
|
import { DragContext } from './useDrag';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PageContainer,
|
||||||
|
DragDebugWrapper,
|
||||||
|
DragIndicatorBar,
|
||||||
|
PageContent,
|
||||||
|
EntryChildren,
|
||||||
|
EntryInnerContent,
|
||||||
|
EntryWrapper,
|
||||||
|
EntryContent,
|
||||||
|
RootWrapper,
|
||||||
|
EntryHandle,
|
||||||
|
PageNameContent,
|
||||||
|
PageNameText,
|
||||||
|
PageName,
|
||||||
|
} from './Styles';
|
||||||
|
import {
|
||||||
|
transformToTree,
|
||||||
|
findNode,
|
||||||
|
findNodeDepth,
|
||||||
|
getNumberOfChildren,
|
||||||
|
validateDepth,
|
||||||
|
getDimensions,
|
||||||
|
findNextDraggable,
|
||||||
|
getNodeOver,
|
||||||
|
getCorrectNode,
|
||||||
|
findCommonParent,
|
||||||
|
getNodeAbove,
|
||||||
|
findNodeAbove,
|
||||||
|
} from './utils';
|
||||||
|
import NOOP from 'shared/utils/noop';
|
||||||
|
|
||||||
|
enum CommandType {
|
||||||
|
MOVE,
|
||||||
|
MERGE,
|
||||||
|
CHANGE_TEXT,
|
||||||
|
DELETE,
|
||||||
|
CREATE,
|
||||||
|
}
|
||||||
|
|
||||||
|
type MoveData = {
|
||||||
|
prev: { position: number; parent: string | null };
|
||||||
|
next: { position: number; parent: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChangeTextData = {
|
||||||
|
node: {
|
||||||
|
id: string;
|
||||||
|
parentID: string;
|
||||||
|
position: number;
|
||||||
|
};
|
||||||
|
caret: number;
|
||||||
|
prev: string;
|
||||||
|
next: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeleteData = {
|
||||||
|
node: {
|
||||||
|
id: string;
|
||||||
|
parentID: string;
|
||||||
|
position: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type OutlineCommand = {
|
||||||
|
nodes: Array<{
|
||||||
|
id: string;
|
||||||
|
type: CommandType;
|
||||||
|
data: MoveData | DeleteData | ChangeTextData;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ItemCollapsed = {
|
||||||
|
id: string;
|
||||||
|
collapsed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateItems(c: number) {
|
||||||
|
const items: Array<ItemElement> = [];
|
||||||
|
for (let i = 0; i < c; i++) {
|
||||||
|
items.push({
|
||||||
|
collapsed: false,
|
||||||
|
focus: null,
|
||||||
|
id: `entry-gen-${i}`,
|
||||||
|
text: `entry-gen-${i}`,
|
||||||
|
parent: 'root',
|
||||||
|
position: 4096 * (6 + i),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listItems: Array<ItemElement> = [
|
||||||
|
{ id: 'root', text: '', position: 4096, parent: null, collapsed: false, focus: null },
|
||||||
|
{ id: 'entry-1', text: 'entry-1', position: 4096, parent: 'root', collapsed: false, focus: null },
|
||||||
|
{ id: 'entry-1-3', text: 'entry-1-3', position: 4096 * 3, parent: 'entry-1', collapsed: false, focus: null },
|
||||||
|
{ id: 'entry-1-3-1', text: 'entry-1-3-1', position: 4096, parent: 'entry-1-3', collapsed: false, focus: null },
|
||||||
|
{ id: 'entry-1-3-2', text: 'entry-1-3-2', position: 4096 * 2, parent: 'entry-1-3', collapsed: false, focus: null },
|
||||||
|
{ id: 'entry-1-3-3', text: 'entry-1-3-3', position: 4096 * 3, parent: 'entry-1-3', collapsed: false, focus: null },
|
||||||
|
{
|
||||||
|
id: 'entry-1-3-3-1',
|
||||||
|
text: '*Hello!* I am `doing super` well ~how~ are **you**?',
|
||||||
|
position: 4096 * 1,
|
||||||
|
parent: 'entry-1-3-3',
|
||||||
|
collapsed: false,
|
||||||
|
focus: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entry-1-3-3-1-1',
|
||||||
|
text: 'entry-1-3-3-1-1',
|
||||||
|
position: 4096 * 1,
|
||||||
|
parent: 'entry-1-3-3-1',
|
||||||
|
collapsed: false,
|
||||||
|
focus: null,
|
||||||
|
},
|
||||||
|
{ id: 'entry-2', text: 'entry-2', position: 4096 * 2, parent: 'root', collapsed: false, focus: null },
|
||||||
|
{ id: 'entry-3', text: 'entry-3', position: 4096 * 3, parent: 'root', collapsed: false, focus: null },
|
||||||
|
{ id: 'entry-4', text: 'entry-4', position: 4096 * 4, parent: 'root', collapsed: false, focus: null },
|
||||||
|
{ id: 'entry-5', text: 'entry-5', position: 4096 * 5, parent: 'root', collapsed: false, focus: null },
|
||||||
|
...generateItems(100),
|
||||||
|
];
|
||||||
|
|
||||||
|
const Outline: React.FC = () => {
|
||||||
|
const [items, setItems] = useState(listItems);
|
||||||
|
const [selecting, setSelecting] = useState<{
|
||||||
|
isSelecting: boolean;
|
||||||
|
node: { id: string; depth: number } | null;
|
||||||
|
}>({ isSelecting: false, node: null });
|
||||||
|
const [selection, setSelection] = useState<null | { nodes: Array<{ id: string }>; first?: OutlineNode | null }>(null);
|
||||||
|
const [dragging, setDragging] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
draggedNodes: null | Array<string>;
|
||||||
|
initialPos: { x: number; y: number };
|
||||||
|
}>({ show: false, draggedNodes: null, initialPos: { x: 0, y: 0 } });
|
||||||
|
const [impact, setImpact] = useState<null | {
|
||||||
|
listPosition: number;
|
||||||
|
zone: ImpactZone;
|
||||||
|
depthTarget: number;
|
||||||
|
}>(null);
|
||||||
|
const selectRef = useRef<{ isSelecting: boolean; hasSelection: boolean; node: { id: string; depth: number } | null }>(
|
||||||
|
{
|
||||||
|
isSelecting: false,
|
||||||
|
node: null,
|
||||||
|
hasSelection: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const impactRef = useRef<null | { listPosition: number; depth: number; zone: ImpactZone }>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (impact) {
|
||||||
|
impactRef.current = { zone: impact.zone, depth: impact.depthTarget, listPosition: impact.listPosition };
|
||||||
|
}
|
||||||
|
}, [impact]);
|
||||||
|
useEffect(() => {
|
||||||
|
selectRef.current.isSelecting = selecting.isSelecting;
|
||||||
|
selectRef.current.node = selecting.node;
|
||||||
|
}, [selecting]);
|
||||||
|
|
||||||
|
const $content = useRef<HTMLDivElement>(null);
|
||||||
|
const outline = useRef<OutlineData>({
|
||||||
|
published: new Map<string, string>(),
|
||||||
|
dimensions: new Map<string, NodeDimensions>(),
|
||||||
|
nodes: new Map<number, Map<string, OutlineNode>>(),
|
||||||
|
relationships: new Map<string, NodeRelationships>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = transformToTree(_.cloneDeep(items));
|
||||||
|
let root: any = null;
|
||||||
|
if (tree.length === 1) {
|
||||||
|
root = tree[0];
|
||||||
|
}
|
||||||
|
const outlineHistory = useRef<{ commands: Array<OutlineCommand>; current: number }>({ current: -1, commands: [] });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
outline.current.relationships = new Map<string, NodeRelationships>();
|
||||||
|
outline.current.published = new Map<string, string>();
|
||||||
|
outline.current.nodes = new Map<number, Map<string, OutlineNode>>();
|
||||||
|
const collapsedMap = items.reduce((map, next) => {
|
||||||
|
if (next.collapsed) {
|
||||||
|
map.set(next.id, true);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, new Map<string, boolean>());
|
||||||
|
items.forEach(item => outline.current.published.set(item.id, item.parent ?? 'root'));
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const { collapsed, position, id, parent: curParent } = items[i];
|
||||||
|
if (id === 'root') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parent = curParent ?? 'root';
|
||||||
|
outline.current.published.set(id, parent ?? 'root');
|
||||||
|
const foundDepth = findNodeDepth(outline.current.published, id);
|
||||||
|
if (foundDepth === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { depth, ancestors } = foundDepth;
|
||||||
|
const collapsedParent = ancestors.slice(0, -1).find(a => collapsedMap.get(a));
|
||||||
|
if (collapsedParent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const children = getNumberOfChildren(root, ancestors);
|
||||||
|
if (!outline.current.nodes.has(depth)) {
|
||||||
|
outline.current.nodes.set(depth, new Map<string, OutlineNode>());
|
||||||
|
}
|
||||||
|
const targetDepthNodes = outline.current.nodes.get(depth);
|
||||||
|
if (targetDepthNodes) {
|
||||||
|
targetDepthNodes.set(id, {
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
position,
|
||||||
|
depth,
|
||||||
|
ancestors,
|
||||||
|
collapsed,
|
||||||
|
parent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!outline.current.relationships.has(parent)) {
|
||||||
|
outline.current.relationships.set(parent, {
|
||||||
|
self: {
|
||||||
|
depth: depth - 1,
|
||||||
|
id: parent,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
numberOfSubChildren: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const nodeRelations = outline.current.relationships.get(parent);
|
||||||
|
if (nodeRelations) {
|
||||||
|
outline.current.relationships.set(parent, {
|
||||||
|
self: nodeRelations.self,
|
||||||
|
numberOfSubChildren: nodeRelations.numberOfSubChildren + children,
|
||||||
|
children: [...nodeRelations.children, { id, position, depth, children }].sort(
|
||||||
|
(a, b) => a.position - b.position,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [items]);
|
||||||
|
const handleKeyDown = useCallback(e => {
|
||||||
|
if (e.code === 'KeyZ' && e.ctrlKey) {
|
||||||
|
const currentCommand = outlineHistory.current.commands[outlineHistory.current.current];
|
||||||
|
if (currentCommand) {
|
||||||
|
setItems(prevItems =>
|
||||||
|
produce(prevItems, draftItems => {
|
||||||
|
currentCommand.nodes.forEach(node => {
|
||||||
|
const idx = prevItems.findIndex(c => c.id === node.id);
|
||||||
|
if (node.type === CommandType.MOVE) {
|
||||||
|
if (idx === -1) return;
|
||||||
|
const data = node.data as MoveData;
|
||||||
|
draftItems[idx].parent = data.prev.parent;
|
||||||
|
draftItems[idx].position = data.prev.position;
|
||||||
|
} else if (node.type === CommandType.CHANGE_TEXT) {
|
||||||
|
if (idx === -1) return;
|
||||||
|
const data = node.data as ChangeTextData;
|
||||||
|
draftItems[idx] = produce(prevItems[idx], draftItem => {
|
||||||
|
draftItem.text = data.prev;
|
||||||
|
draftItem.focus = { caret: data.caret };
|
||||||
|
});
|
||||||
|
} else if (node.type === CommandType.DELETE) {
|
||||||
|
const data = node.data as DeleteData;
|
||||||
|
draftItems.push({
|
||||||
|
id: data.node.id,
|
||||||
|
position: data.node.position,
|
||||||
|
parent: data.node.parentID,
|
||||||
|
text: '',
|
||||||
|
focus: { caret: null },
|
||||||
|
children: [],
|
||||||
|
collapsed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
outlineHistory.current.current--;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (e.code === 'KeyY' && e.ctrlKey) {
|
||||||
|
const currentCommand = outlineHistory.current.commands[outlineHistory.current.current + 1];
|
||||||
|
if (currentCommand) {
|
||||||
|
setItems(prevItems =>
|
||||||
|
produce(prevItems, draftItems => {
|
||||||
|
currentCommand.nodes.forEach(node => {
|
||||||
|
const idx = prevItems.findIndex(c => c.id === node.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
if (node.type === CommandType.MOVE) {
|
||||||
|
const data = node.data as MoveData;
|
||||||
|
draftItems[idx].parent = data.next.parent;
|
||||||
|
draftItems[idx].position = data.next.position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
outlineHistory.current.current++;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(
|
||||||
|
e => {
|
||||||
|
if (selectRef.current.hasSelection && !selectRef.current.isSelecting) {
|
||||||
|
setSelection(null);
|
||||||
|
}
|
||||||
|
if (selectRef.current.isSelecting) {
|
||||||
|
setSelecting({ isSelecting: false, node: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dragging, selecting],
|
||||||
|
);
|
||||||
|
const handleMouseMove = useCallback(e => {
|
||||||
|
if (selectRef.current.isSelecting && selectRef.current.node) {
|
||||||
|
const { clientX, clientY } = e;
|
||||||
|
const dimensions = outline.current.dimensions.get(selectRef.current.node.id);
|
||||||
|
if (dimensions) {
|
||||||
|
const entry = getDimensions(dimensions.entry);
|
||||||
|
if (entry) {
|
||||||
|
const isAbove = clientY < entry.top;
|
||||||
|
const isBelow = clientY > entry.bottom;
|
||||||
|
if (!isAbove && !isBelow && selectRef.current.hasSelection) {
|
||||||
|
const nodeDepth = outline.current.nodes.get(selectRef.current.node.depth);
|
||||||
|
const aboveNode = nodeDepth ? nodeDepth.get(selectRef.current.node.id) : null;
|
||||||
|
if (aboveNode) {
|
||||||
|
setSelection({ nodes: [{ id: selectRef.current.node.id }], first: aboveNode });
|
||||||
|
selectRef.current.hasSelection = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isAbove || isBelow) {
|
||||||
|
e.preventDefault();
|
||||||
|
const { curDraggable } = getNodeOver({ x: clientX, y: clientY }, outline.current);
|
||||||
|
const nodeDepth = outline.current.nodes.get(selectRef.current.node.depth);
|
||||||
|
const selectedNode = nodeDepth ? nodeDepth.get(selectRef.current.node.id) : null;
|
||||||
|
let aboveNode: OutlineNode | undefined | null = null;
|
||||||
|
let belowNode: OutlineNode | undefined | null = null;
|
||||||
|
if (isBelow) {
|
||||||
|
aboveNode = selectedNode;
|
||||||
|
belowNode = curDraggable;
|
||||||
|
} else {
|
||||||
|
aboveNode = curDraggable;
|
||||||
|
belowNode = selectedNode;
|
||||||
|
}
|
||||||
|
if (aboveNode && belowNode) {
|
||||||
|
const aboveDim = outline.current.dimensions.get(aboveNode.id);
|
||||||
|
const belowDim = outline.current.dimensions.get(belowNode.id);
|
||||||
|
if (aboveDim && belowDim) {
|
||||||
|
const aboveDimBounds = getDimensions(aboveDim.entry);
|
||||||
|
const belowDimBounds = getDimensions(belowDim.children ? belowDim.children : belowDim.entry);
|
||||||
|
const aboveDimY = aboveDimBounds ? aboveDimBounds.bottom : 0;
|
||||||
|
const belowDimY = belowDimBounds ? belowDimBounds.top : 0;
|
||||||
|
const inbetweenNodes: Array<{ id: string }> = [];
|
||||||
|
for (const [id, dimension] of outline.current.dimensions.entries()) {
|
||||||
|
if (id === aboveNode.id || id === belowNode.id) {
|
||||||
|
inbetweenNodes.push({ id });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const targetNodeBounds = getDimensions(dimension.entry);
|
||||||
|
if (targetNodeBounds) {
|
||||||
|
if (
|
||||||
|
Math.round(aboveDimY) <= Math.round(targetNodeBounds.top) &&
|
||||||
|
Math.round(belowDimY) >= Math.round(targetNodeBounds.bottom)
|
||||||
|
) {
|
||||||
|
inbetweenNodes.push({ id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const filteredNodes = inbetweenNodes.filter(n => {
|
||||||
|
const parent = outline.current.published.get(n.id);
|
||||||
|
if (parent) {
|
||||||
|
const foundParent = inbetweenNodes.find(c => c.id === parent);
|
||||||
|
if (foundParent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
selectRef.current.hasSelection = true;
|
||||||
|
setSelection({ nodes: filteredNodes, first: aboveNode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const $page = useRef<HTMLDivElement>(null);
|
||||||
|
const $pageName = useRef<HTMLDivElement>(null);
|
||||||
|
if (!root) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
|
||||||
|
<DragContext.Provider
|
||||||
|
value={{
|
||||||
|
outline,
|
||||||
|
impact,
|
||||||
|
setImpact: data => {
|
||||||
|
if (data) {
|
||||||
|
const { zone, depth } = data;
|
||||||
|
let listPosition = 65535;
|
||||||
|
if (zone.above && zone.above.node.depth + 1 <= depth && zone.above.node.collapsed) {
|
||||||
|
const aboveChildren = items
|
||||||
|
.filter(i => (zone.above ? i.parent === zone.above.node.id : false))
|
||||||
|
.sort((a, b) => a.position - b.position);
|
||||||
|
const lastChild = aboveChildren[aboveChildren.length - 1];
|
||||||
|
if (lastChild) {
|
||||||
|
listPosition = lastChild.position * 2.0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const correctNode = getCorrectNode(outline.current, zone.above ? zone.above.node : null, depth);
|
||||||
|
const listAbove = validateDepth(correctNode, depth);
|
||||||
|
const listBelow = validateDepth(zone.below ? zone.below.node : null, depth);
|
||||||
|
if (listAbove && listBelow) {
|
||||||
|
listPosition = (listAbove.position + listBelow.position) / 2.0;
|
||||||
|
} else if (listAbove && !listBelow) {
|
||||||
|
listPosition = listAbove.position * 2.0;
|
||||||
|
} else if (!listAbove && listBelow) {
|
||||||
|
listPosition = listBelow.position / 2.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zone.above && zone.below) {
|
||||||
|
const newPosition = zone.below.node.position / 2.0;
|
||||||
|
setImpact(() => ({
|
||||||
|
zone,
|
||||||
|
listPosition: newPosition,
|
||||||
|
depthTarget: depth,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (zone.above) {
|
||||||
|
// console.log(`prev=${prev} next=${next} targetPosition=${targetPosition}`);
|
||||||
|
// let targetID = depthTarget === 1 ? 'root' : node.ancestors[depthTarget - 1];
|
||||||
|
// targetID = targetID ?? node.id;
|
||||||
|
setImpact(() => ({
|
||||||
|
zone,
|
||||||
|
listPosition,
|
||||||
|
depthTarget: depth,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setImpact(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setNodeDimensions: (nodeID, ref) => {
|
||||||
|
outline.current.dimensions.set(nodeID, ref);
|
||||||
|
},
|
||||||
|
clearNodeDimensions: nodeID => {
|
||||||
|
outline.current.dimensions.delete(nodeID);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<PageContainer ref={$page}>
|
||||||
|
<PageContent>
|
||||||
|
<RootWrapper ref={$content}>
|
||||||
|
<PageName>
|
||||||
|
<PageNameContent ref={$pageName}>
|
||||||
|
<PageNameText>entry-1-3-1</PageNameText>
|
||||||
|
</PageNameContent>
|
||||||
|
</PageName>
|
||||||
|
<Entry
|
||||||
|
onDepthChange={(id, parentID, position, depth, depthDelta) => {
|
||||||
|
if (depthDelta === -1) {
|
||||||
|
const parentRelation = outline.current.relationships.get(parentID);
|
||||||
|
if (parentRelation) {
|
||||||
|
const nodeIdx = parentRelation.children
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.findIndex(c => c.id === id);
|
||||||
|
if (parentRelation.children.length !== 0) {
|
||||||
|
const grandparent = outline.current.published.get(parentID);
|
||||||
|
if (grandparent) {
|
||||||
|
const grandparentNode = outline.current.relationships.get(grandparent);
|
||||||
|
if (grandparentNode) {
|
||||||
|
const parents = grandparentNode.children.sort((a, b) => a.position - b.position);
|
||||||
|
const parentIdx = parents.findIndex(c => c.id === parentID);
|
||||||
|
if (parentIdx === -1) return;
|
||||||
|
let position = parents[parentIdx].position * 2;
|
||||||
|
const nextParent = parents[parentIdx + 1];
|
||||||
|
if (nextParent) {
|
||||||
|
position = (parents[parentIdx].position + nextParent.position) / 2.0;
|
||||||
|
}
|
||||||
|
setItems(prevItems =>
|
||||||
|
produce(prevItems, draftItems => {
|
||||||
|
const idx = prevItems.findIndex(c => c.id === id);
|
||||||
|
draftItems[idx] = produce(prevItems[idx], draftItem => {
|
||||||
|
draftItem.parent = grandparent;
|
||||||
|
draftItem.position = position;
|
||||||
|
draftItem.focus = { caret: 0 };
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parent = outline.current.relationships.get(parentID);
|
||||||
|
if (parent) {
|
||||||
|
const nodeIdx = parent.children
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.findIndex(c => c.id === id);
|
||||||
|
const aboveNode = parent.children[nodeIdx - 1];
|
||||||
|
if (aboveNode) {
|
||||||
|
const aboveNodeRelations = outline.current.relationships.get(aboveNode.id);
|
||||||
|
let position = 65535;
|
||||||
|
if (aboveNodeRelations) {
|
||||||
|
const children = aboveNodeRelations.children.sort((a, b) => a.position - b.position);
|
||||||
|
if (children.length !== 0) {
|
||||||
|
position = children[children.length - 1].position * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setItems(prevItems =>
|
||||||
|
produce(prevItems, draftItems => {
|
||||||
|
const idx = prevItems.findIndex(c => c.id === id);
|
||||||
|
draftItems[idx] = produce(prevItems[idx], draftItem => {
|
||||||
|
draftItem.parent = aboveNode.id;
|
||||||
|
draftItem.position = position;
|
||||||
|
draftItem.focus = { caret: 0 };
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTextChange={(id, prev, next, caret) => {
|
||||||
|
outlineHistory.current.current += 1;
|
||||||
|
const data: ChangeTextData = {
|
||||||
|
node: {
|
||||||
|
id,
|
||||||
|
position: 0,
|
||||||
|
parentID: '',
|
||||||
|
},
|
||||||
|
caret,
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
};
|
||||||
|
const command: OutlineCommand = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type: CommandType.CHANGE_TEXT,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
outlineHistory.current.commands[outlineHistory.current.current] = command;
|
||||||
|
if (outlineHistory.current.commands[outlineHistory.current.current + 1]) {
|
||||||
|
outlineHistory.current.commands.splice(outlineHistory.current.current + 1);
|
||||||
|
}
|
||||||
|
setItems(prevItems =>
|
||||||
|
produce(prevItems, draftItems => {
|
||||||
|
const idx = prevItems.findIndex(c => c.id === id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
draftItems[idx] = produce(prevItems[idx], draftItem => {
|
||||||
|
draftItem.text = next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
text=""
|
||||||
|
autoFocus={null}
|
||||||
|
onDeleteEntry={(depth, id, text, caretPos) => {
|
||||||
|
const nodeDepth = outline.current.nodes.get(depth);
|
||||||
|
if (nodeDepth) {
|
||||||
|
const node = nodeDepth.get(id);
|
||||||
|
if (node) {
|
||||||
|
const nodeAbove = findNodeAbove(outline.current, depth, node);
|
||||||
|
setItems(prevItems => {
|
||||||
|
return produce(prevItems, draftItems => {
|
||||||
|
draftItems = prevItems.filter(c => c.id !== id);
|
||||||
|
const idx = prevItems.findIndex(c => c.id === nodeAbove?.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
draftItems[idx] = produce(prevItems[idx], draftItem => {
|
||||||
|
draftItem.focus = { caret: draftItem.text.length };
|
||||||
|
const cType = CommandType.DELETE;
|
||||||
|
const data: DeleteData = {
|
||||||
|
node: {
|
||||||
|
id,
|
||||||
|
position: node.position,
|
||||||
|
parentID: node.parent,
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (text !== '') {
|
||||||
|
draftItem.text += text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command: OutlineCommand = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
type: cType,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
outlineHistory.current.current += 1;
|
||||||
|
outlineHistory.current.commands[outlineHistory.current.current] = command;
|
||||||
|
if (outlineHistory.current.commands[outlineHistory.current.current + 1]) {
|
||||||
|
outlineHistory.current.commands.splice(outlineHistory.current.current + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return draftItems;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCreateEntry={(parent, position) => {
|
||||||
|
setItems(prevItems =>
|
||||||
|
produce(prevItems, draftItems => {
|
||||||
|
draftItems.push({
|
||||||
|
id: '' + Math.random(),
|
||||||
|
collapsed: false,
|
||||||
|
position,
|
||||||
|
text: '',
|
||||||
|
focus: {
|
||||||
|
caret: null,
|
||||||
|
},
|
||||||
|
parent,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onNodeFocused={id => {
|
||||||
|
setItems(prevItems =>
|
||||||
|
produce(prevItems, draftItems => {
|
||||||
|
const idx = draftItems.findIndex(c => c.id === id);
|
||||||
|
draftItems[idx] = produce(draftItems[idx], draftItem => {
|
||||||
|
draftItem.focus = null;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onStartSelect={({ id, depth }) => {
|
||||||
|
setSelection(null);
|
||||||
|
setSelecting({ isSelecting: true, node: { id, depth } });
|
||||||
|
}}
|
||||||
|
onToggleCollapse={(id, collapsed) => {
|
||||||
|
setItems(prevItems =>
|
||||||
|
produce(prevItems, draftItems => {
|
||||||
|
const idx = prevItems.findIndex(c => c.id === id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
draftItems[idx].collapsed = collapsed;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
id="root"
|
||||||
|
parentID="root"
|
||||||
|
isRoot
|
||||||
|
selection={selection ? selection.nodes : null}
|
||||||
|
draggedNodes={dragging.draggedNodes}
|
||||||
|
position={root.position}
|
||||||
|
entries={root.children}
|
||||||
|
onCancelDrag={() => {
|
||||||
|
setImpact(null);
|
||||||
|
setDragging({ show: false, draggedNodes: null, initialPos: { x: 0, y: 0 } });
|
||||||
|
}}
|
||||||
|
onHandleClick={id => {}}
|
||||||
|
onStartDrag={e => {
|
||||||
|
if (e.id !== 'root') {
|
||||||
|
if (selectRef.current.hasSelection && selection && selection.nodes.find(c => c.id === e.id)) {
|
||||||
|
setImpact(null);
|
||||||
|
setDragging({
|
||||||
|
show: true,
|
||||||
|
draggedNodes: [...selection.nodes.map(c => c.id)],
|
||||||
|
initialPos: { x: e.clientX, y: e.clientY },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setImpact(null);
|
||||||
|
setDragging({ show: true, draggedNodes: [e.id], initialPos: { x: e.clientX, y: e.clientY } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</RootWrapper>
|
||||||
|
</PageContent>
|
||||||
|
</PageContainer>
|
||||||
|
{dragging.show && dragging.draggedNodes && (
|
||||||
|
<Dragger
|
||||||
|
container={$content}
|
||||||
|
initialPos={dragging.initialPos}
|
||||||
|
pageRef={$page}
|
||||||
|
draggedNodes={{ nodes: dragging.draggedNodes, first: selection ? selection.first : null }}
|
||||||
|
isDragging={dragging.show}
|
||||||
|
onDragEnd={() => {
|
||||||
|
if (dragging.draggedNodes && impactRef.current) {
|
||||||
|
const { zone, depth, listPosition } = impactRef.current;
|
||||||
|
const noZone = !zone.above && !zone.below;
|
||||||
|
if (!noZone) {
|
||||||
|
let parentID = 'root';
|
||||||
|
if (zone.above) {
|
||||||
|
parentID = zone.above.node.ancestors[depth - 1];
|
||||||
|
}
|
||||||
|
let reparent = true;
|
||||||
|
for (let i = 0; i < dragging.draggedNodes.length; i++) {
|
||||||
|
const draggedID = dragging.draggedNodes[i];
|
||||||
|
const prevItem = items.find(i => i.id === draggedID);
|
||||||
|
if (prevItem && prevItem.position === listPosition && prevItem.parent === parentID) {
|
||||||
|
reparent = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: set reparent if list position changed but parent did not
|
||||||
|
//
|
||||||
|
|
||||||
|
if (reparent) {
|
||||||
|
// UPDATE OUTLINE DATA AFTER NODE MOVE
|
||||||
|
setItems(itemsPrev =>
|
||||||
|
produce(itemsPrev, draftItems => {
|
||||||
|
if (dragging.draggedNodes) {
|
||||||
|
const command: OutlineCommand = { nodes: [] };
|
||||||
|
outlineHistory.current.current += 1;
|
||||||
|
dragging.draggedNodes.forEach(n => {
|
||||||
|
const curDragging = itemsPrev.findIndex(i => i.id === n);
|
||||||
|
command.nodes.push({
|
||||||
|
id: n,
|
||||||
|
type: CommandType.MOVE,
|
||||||
|
data: {
|
||||||
|
prev: {
|
||||||
|
parent: draftItems[curDragging].parent,
|
||||||
|
position: draftItems[curDragging].position,
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
parent: parentID,
|
||||||
|
position: listPosition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
draftItems[curDragging].parent = parentID;
|
||||||
|
draftItems[curDragging].position = listPosition;
|
||||||
|
});
|
||||||
|
outlineHistory.current.commands[outlineHistory.current.current] = command;
|
||||||
|
if (outlineHistory.current.commands[outlineHistory.current.current + 1]) {
|
||||||
|
outlineHistory.current.commands.splice(outlineHistory.current.current + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setImpact(null);
|
||||||
|
setDragging({ show: false, draggedNodes: null, initialPos: { x: 0, y: 0 } });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</DragContext.Provider>
|
||||||
|
{impact && <DragIndicator depthTarget={impact.depthTarget} container={$content} zone={impact.zone} />}
|
||||||
|
{impact && (
|
||||||
|
<DragDebug zone={impact.zone ?? null} draggedNodes={dragging.draggedNodes} depthTarget={impact.depthTarget} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Outline;
|
22
frontend/src/Outline/useDrag.ts
Normal file
22
frontend/src/Outline/useDrag.ts
Normal 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');
|
||||||
|
};
|
409
frontend/src/Outline/utils.ts
Normal file
409
frontend/src/Outline/utils.ts
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
export function getCorrectNode(data: OutlineData, node: OutlineNode | null, depth: number) {
|
||||||
|
if (node) {
|
||||||
|
if (depth === node.depth) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
const parent = node.ancestors[depth];
|
||||||
|
if (parent) {
|
||||||
|
const parentNode = data.relationships.get(parent);
|
||||||
|
if (parentNode) {
|
||||||
|
const parentDepth = parentNode.self.depth;
|
||||||
|
const nodeDepth = data.nodes.get(parentDepth);
|
||||||
|
return nodeDepth ? nodeDepth.get(parent) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export function validateDepth(node: OutlineNode | null | undefined, depth: number) {
|
||||||
|
if (node) {
|
||||||
|
return node.depth === depth ? node : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeAbove(node: OutlineNode, startingParent: RelationshipChild, outline: OutlineData) {
|
||||||
|
let hasChildren = true;
|
||||||
|
let nodeAbove: null | RelationshipChild = null;
|
||||||
|
let aboveTargetID = startingParent.id;
|
||||||
|
while (hasChildren) {
|
||||||
|
const targetParent = outline.relationships.get(aboveTargetID);
|
||||||
|
if (targetParent) {
|
||||||
|
const parentNodes = outline.nodes.get(targetParent.self.depth);
|
||||||
|
const parentNode = parentNodes ? parentNodes.get(targetParent.self.id) : null;
|
||||||
|
if (targetParent.children.length === 0) {
|
||||||
|
if (parentNode) {
|
||||||
|
nodeAbove = {
|
||||||
|
id: parentNode.id,
|
||||||
|
depth: parentNode.depth,
|
||||||
|
position: parentNode.position,
|
||||||
|
children: parentNode.children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
hasChildren = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nodeAbove = targetParent.children[targetParent.children.length - 1];
|
||||||
|
if (targetParent.numberOfSubChildren === 0) {
|
||||||
|
hasChildren = false;
|
||||||
|
} else {
|
||||||
|
aboveTargetID = nodeAbove.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const target = outline.relationships.get(node.ancestors[0]);
|
||||||
|
if (target) {
|
||||||
|
const targetChild = target.children.find(i => i.id === aboveTargetID);
|
||||||
|
if (targetChild) {
|
||||||
|
nodeAbove = targetChild;
|
||||||
|
}
|
||||||
|
hasChildren = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodeAbove;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBelowParent(node: OutlineNode, outline: OutlineData) {
|
||||||
|
const { relationships, nodes } = outline;
|
||||||
|
const parentDepth = nodes.get(node.depth - 1);
|
||||||
|
const parent = parentDepth ? parentDepth.get(node.parent) : null;
|
||||||
|
if (parent) {
|
||||||
|
const grandfather = relationships.get(parent.parent);
|
||||||
|
if (grandfather) {
|
||||||
|
const parentIndex = grandfather.children.findIndex(c => c.id === parent.id);
|
||||||
|
if (parentIndex !== -1) {
|
||||||
|
if (parentIndex === grandfather.children.length - 1) {
|
||||||
|
const root = relationships.get(node.ancestors[0]);
|
||||||
|
if (root) {
|
||||||
|
const ancestorIndex = root.children.findIndex(c => c.id === node.ancestors[1]);
|
||||||
|
if (ancestorIndex !== -1) {
|
||||||
|
const nextAncestor = root.children[ancestorIndex + 1];
|
||||||
|
if (nextAncestor) {
|
||||||
|
return nextAncestor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const nextChild = grandfather.children[parentIndex + 1];
|
||||||
|
if (nextChild) {
|
||||||
|
return nextChild;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDimensions(ref: React.RefObject<HTMLElement> | null | undefined) {
|
||||||
|
if (ref && ref.current) {
|
||||||
|
return ref.current.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTargetDepth(mouseX: number, handleLeft: number, availableDepths: { min: number; max: number }) {
|
||||||
|
if (mouseX > handleLeft) {
|
||||||
|
return availableDepths.max;
|
||||||
|
}
|
||||||
|
let curDepth = availableDepths.max - 1;
|
||||||
|
for (let x = availableDepths.min; x < availableDepths.max; x++) {
|
||||||
|
const breakpoint = handleLeft - x * 35;
|
||||||
|
if (mouseX > breakpoint) {
|
||||||
|
return curDepth;
|
||||||
|
}
|
||||||
|
curDepth -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableDepths.min;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNextDraggable(pos: { x: number; y: number }, outline: OutlineData, curDepth: number) {
|
||||||
|
let index = 0;
|
||||||
|
const currentDepthNodes = outline.nodes.get(curDepth);
|
||||||
|
let nodeAbove: null | RelationshipChild = null;
|
||||||
|
if (!currentDepthNodes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const [id, node] of currentDepthNodes) {
|
||||||
|
const dimensions = outline.dimensions.get(id);
|
||||||
|
const target = dimensions ? getDimensions(dimensions.entry) : null;
|
||||||
|
const children = dimensions ? getDimensions(dimensions.children) : null;
|
||||||
|
if (target) {
|
||||||
|
if (pos.y <= target.bottom && pos.y >= target.top) {
|
||||||
|
const middlePoint = target.top + target.height / 2;
|
||||||
|
const position: ImpactPosition = pos.y > middlePoint ? 'after' : 'before';
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
node,
|
||||||
|
position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (children) {
|
||||||
|
if (pos.y <= children.bottom && pos.y >= children.top) {
|
||||||
|
const position: ImpactPosition = 'after';
|
||||||
|
return { found: false, node, position };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformToTree(arr: any) {
|
||||||
|
const nodes: any = {};
|
||||||
|
return arr.filter(function(obj: any) {
|
||||||
|
var id = obj['id'],
|
||||||
|
parentId = obj['parent'];
|
||||||
|
|
||||||
|
nodes[id] = _.defaults(obj, nodes[id], { children: [] });
|
||||||
|
parentId && (nodes[parentId] = nodes[parentId] || { children: [] })['children'].push(obj);
|
||||||
|
|
||||||
|
return !parentId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNode(parentID: string, nodeID: string, data: OutlineData) {
|
||||||
|
const nodeRelations = data.relationships.get(parentID);
|
||||||
|
if (nodeRelations) {
|
||||||
|
const nodeDepth = data.nodes.get(nodeRelations.self.depth + 1);
|
||||||
|
if (nodeDepth) {
|
||||||
|
const node = nodeDepth.get(nodeID);
|
||||||
|
return node ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNodeDepth(published: Map<string, string>, id: string) {
|
||||||
|
let currentID = id;
|
||||||
|
let breaker = 0;
|
||||||
|
let depth = 0;
|
||||||
|
let ancestors = [id];
|
||||||
|
while (currentID !== 'root') {
|
||||||
|
const nextID = published.get(currentID);
|
||||||
|
if (nextID) {
|
||||||
|
ancestors = [nextID, ...ancestors];
|
||||||
|
currentID = nextID;
|
||||||
|
depth += 1;
|
||||||
|
breaker += 1;
|
||||||
|
if (breaker > 100) {
|
||||||
|
throw new Error('node depth breaker was thrown');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { depth, ancestors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNumberOfChildren(root: ItemElement, ancestors: Array<string>) {
|
||||||
|
let currentBranch = root;
|
||||||
|
for (let i = 1; i < ancestors.length; i++) {
|
||||||
|
const nextBranch = currentBranch.children ? currentBranch.children.find(c => c.id === ancestors[i]) : null;
|
||||||
|
if (nextBranch) {
|
||||||
|
currentBranch = nextBranch;
|
||||||
|
} else {
|
||||||
|
throw new Error('unable to find next branch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return currentBranch.children ? currentBranch.children.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNodeAbove(outline: OutlineData, curDepth: number, belowNode: OutlineNode) {
|
||||||
|
let targetAboveNode: null | RelationshipChild = null;
|
||||||
|
if (curDepth === 1) {
|
||||||
|
const relations = outline.relationships.get(belowNode.ancestors[0]);
|
||||||
|
if (relations) {
|
||||||
|
const parentIndex = relations.children.findIndex(n => belowNode && n.id === belowNode.ancestors[1]);
|
||||||
|
if (parentIndex !== -1) {
|
||||||
|
const aboveParent = relations.children[parentIndex - 1];
|
||||||
|
if (parentIndex === 0) {
|
||||||
|
targetAboveNode = null;
|
||||||
|
} else {
|
||||||
|
targetAboveNode = getNodeAbove(belowNode, aboveParent, outline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const relations = outline.relationships.get(belowNode.parent);
|
||||||
|
if (relations) {
|
||||||
|
const currentIndex = relations.children.findIndex(n => belowNode && n.id === belowNode.id);
|
||||||
|
// is first child, so use parent
|
||||||
|
if (currentIndex === 0) {
|
||||||
|
const parentNodes = outline.nodes.get(belowNode.depth - 1);
|
||||||
|
const parentNode = parentNodes ? parentNodes.get(belowNode.parent) : null;
|
||||||
|
if (parentNode) {
|
||||||
|
targetAboveNode = {
|
||||||
|
id: belowNode.parent,
|
||||||
|
depth: belowNode.depth - 1,
|
||||||
|
position: parentNode.position,
|
||||||
|
children: parentNode.children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (currentIndex !== -1) {
|
||||||
|
// is not first child, so first prev sibling
|
||||||
|
const aboveParentNode = relations.children[currentIndex - 1];
|
||||||
|
if (aboveParentNode) {
|
||||||
|
targetAboveNode = getNodeAbove(belowNode, aboveParentNode, outline);
|
||||||
|
if (targetAboveNode === null) {
|
||||||
|
targetAboveNode = aboveParentNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetAboveNode) {
|
||||||
|
const depthNodes = outline.nodes.get(targetAboveNode.depth);
|
||||||
|
if (depthNodes) {
|
||||||
|
return depthNodes.get(targetAboveNode.id) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeOver(mouse: { x: number; y: number }, outline: OutlineData) {
|
||||||
|
let curDepth = 1;
|
||||||
|
let curDraggables: any;
|
||||||
|
let curDraggable: any;
|
||||||
|
let curPosition: ImpactPosition = 'after';
|
||||||
|
while (outline.nodes.size + 1 > curDepth) {
|
||||||
|
curDraggables = outline.nodes.get(curDepth);
|
||||||
|
if (curDraggables) {
|
||||||
|
const nextDraggable = findNextDraggable(mouse, outline, curDepth);
|
||||||
|
if (nextDraggable) {
|
||||||
|
curDraggable = nextDraggable.node;
|
||||||
|
curPosition = nextDraggable.position;
|
||||||
|
if (nextDraggable.found) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
curDepth += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
curDepth,
|
||||||
|
curDraggable,
|
||||||
|
curPosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCommonParent(outline: OutlineData, aboveNode: OutlineNode, belowNode: OutlineNode) {
|
||||||
|
let aboveParentID = null;
|
||||||
|
let depth = 0;
|
||||||
|
for (let aIdx = aboveNode.ancestors.length - 1; aIdx !== 0; aIdx--) {
|
||||||
|
depth = aIdx;
|
||||||
|
const aboveNodeParent = aboveNode.ancestors[aIdx];
|
||||||
|
for (let bIdx = belowNode.ancestors.length - 1; bIdx !== 0; bIdx--) {
|
||||||
|
if (belowNode.ancestors[bIdx] === aboveNodeParent) {
|
||||||
|
aboveParentID = aboveNodeParent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (aboveParentID) {
|
||||||
|
const parent = outline.relationships.get(aboveParentID) ?? null;
|
||||||
|
if (parent) {
|
||||||
|
return {
|
||||||
|
parent,
|
||||||
|
depth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLastChildInBranch(outline: OutlineData, lastParentNode: OutlineNode) {
|
||||||
|
let curParentRelation = outline.relationships.get(lastParentNode.id);
|
||||||
|
if (!curParentRelation) {
|
||||||
|
return { id: lastParentNode.id, depth: 1 };
|
||||||
|
}
|
||||||
|
let hasChildren = lastParentNode.children !== 0;
|
||||||
|
let depth = 1;
|
||||||
|
let finalID: null | string = null;
|
||||||
|
while (hasChildren) {
|
||||||
|
if (curParentRelation) {
|
||||||
|
const lastChild = curParentRelation.children.sort((a, b) => a.position - b.position)[
|
||||||
|
curParentRelation.children.length - 1
|
||||||
|
];
|
||||||
|
depth += 1;
|
||||||
|
if (lastChild.children === 0) {
|
||||||
|
finalID = lastChild.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
curParentRelation = outline.relationships.get(lastChild.id);
|
||||||
|
} else {
|
||||||
|
hasChildren = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (finalID !== null) {
|
||||||
|
return { id: finalID, depth };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCaretPosition(editableDiv: any) {
|
||||||
|
/*
|
||||||
|
let caretPos = 0;
|
||||||
|
let sel: any = null;
|
||||||
|
let range: any = null;
|
||||||
|
if (window.getSelection) {
|
||||||
|
sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount) {
|
||||||
|
range = sel.getRangeAt(0);
|
||||||
|
if (range.commonAncestorContainer.parentNode === editableDiv.current) {
|
||||||
|
caretPos = range.endOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
return editableDiv.selectionEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRange(node: any, chars: any, range: any) {
|
||||||
|
if (!range) {
|
||||||
|
range = document.createRange();
|
||||||
|
range.selectNode(node);
|
||||||
|
range.setStart(node, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chars.count === 0) {
|
||||||
|
range.setEnd(node, chars.count);
|
||||||
|
} else if (node && chars.count > 0) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
if (node.textContent.length < chars.count) {
|
||||||
|
chars.count -= node.textContent.length;
|
||||||
|
} else {
|
||||||
|
range.setEnd(node, chars.count);
|
||||||
|
chars.count = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (var lp = 0; lp < node.childNodes.length; lp++) {
|
||||||
|
range = createRange(node.childNodes[lp], chars, range);
|
||||||
|
|
||||||
|
if (chars.count === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentCursorPosition(element: any, chars: any) {
|
||||||
|
if (chars >= 0) {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = createRange(element, { count: chars }, false);
|
||||||
|
if (range && selection) {
|
||||||
|
range.collapse(false);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -159,7 +159,6 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
|
|||||||
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..."
|
||||||
/>
|
/>
|
||||||
|
@ -69,7 +69,7 @@ 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(${props => props.theme.colors.primary});
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const ActionExtraMenuSeparator = styled.li`
|
const ActionExtraMenuSeparator = styled.li`
|
||||||
|
@ -426,7 +426,6 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
|
|||||||
name,
|
name,
|
||||||
complete: false,
|
complete: false,
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
hasTime: false,
|
|
||||||
taskGroup: {
|
taskGroup: {
|
||||||
__typename: 'TaskGroup',
|
__typename: 'TaskGroup',
|
||||||
id: taskGroup.id,
|
id: taskGroup.id,
|
||||||
@ -460,6 +459,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';
|
||||||
@ -794,12 +796,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
|
|||||||
<DueDateManager
|
<DueDateManager
|
||||||
task={task}
|
task={task}
|
||||||
onRemoveDueDate={t => {
|
onRemoveDueDate={t => {
|
||||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } });
|
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
|
||||||
// hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
onDueDateChange={(t, newDueDate, hasTime) => {
|
onDueDateChange={(t, newDueDate) => {
|
||||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } });
|
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
||||||
// hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
onCancel={NOOP}
|
onCancel={NOOP}
|
||||||
/>
|
/>
|
||||||
@ -816,7 +818,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <BoardLoading />;
|
return <span>Error</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProjectBoard;
|
export default ProjectBoard;
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Modal from 'shared/components/Modal';
|
import Modal from 'shared/components/Modal';
|
||||||
import TaskDetails from 'shared/components/TaskDetails';
|
import TaskDetails from 'shared/components/TaskDetails';
|
||||||
import TaskDetailsLoading from 'shared/components/TaskDetails/Loading';
|
|
||||||
import { Popup, usePopup } from 'shared/components/PopupMenu';
|
import { Popup, usePopup } from 'shared/components/PopupMenu';
|
||||||
import MemberManager from 'shared/components/MemberManager';
|
import MemberManager from 'shared/components/MemberManager';
|
||||||
import { useRouteMatch, useHistory } from 'react-router';
|
import { useRouteMatch, useHistory } from 'react-router';
|
||||||
@ -22,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';
|
||||||
@ -37,73 +33,6 @@ 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';
|
||||||
|
|
||||||
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) => {
|
||||||
return (
|
return (
|
||||||
@ -201,40 +130,6 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { showPopup, hidePopup } = usePopup();
|
const { showPopup, hidePopup } = usePopup();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [deleteTaskComment] = useDeleteTaskCommentMutation({
|
|
||||||
update: (client, response) => {
|
|
||||||
updateApolloCache<FindTaskQuery>(
|
|
||||||
client,
|
|
||||||
FindTaskDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
if (response.data) {
|
|
||||||
draftCache.findTask.comments = cache.findTask.comments.filter(
|
|
||||||
c => c.id !== response.data?.deleteTaskComment.commentID,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ taskID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [createTaskComment] = useCreateTaskCommentMutation({
|
|
||||||
update: (client, response) => {
|
|
||||||
updateApolloCache<FindTaskQuery>(
|
|
||||||
client,
|
|
||||||
FindTaskDocument,
|
|
||||||
cache =>
|
|
||||||
produce(cache, draftCache => {
|
|
||||||
if (response.data) {
|
|
||||||
draftCache.findTask.comments.push({
|
|
||||||
...response.data.createTaskComment.comment,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ taskID },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
|
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
|
||||||
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
|
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
|
||||||
update: (client, response) => {
|
update: (client, response) => {
|
||||||
@ -257,7 +152,7 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
draftCache.findTask.checklists[newIdx].items.push({
|
draftCache.findTask.checklists[newIdx].items.push({
|
||||||
...item,
|
...item,
|
||||||
position: checklistItem.position,
|
position: checklistItem.position,
|
||||||
taskChecklistID,
|
taskChecklistID: taskChecklistID,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -382,11 +277,7 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { loading, data, refetch } = useFindTaskQuery({
|
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID }, fetchPolicy: 'cache-and-network' });
|
||||||
variables: { taskID },
|
|
||||||
pollInterval: 3000,
|
|
||||||
fetchPolicy: 'cache-and-network',
|
|
||||||
});
|
|
||||||
const [setTaskComplete] = useSetTaskCompleteMutation();
|
const [setTaskComplete] = useSetTaskCompleteMutation();
|
||||||
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
|
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
|
||||||
onCompleted: () => {
|
onCompleted: () => {
|
||||||
@ -406,9 +297,12 @@ 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
|
||||||
@ -417,33 +311,10 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
history.push(projectURL);
|
history.push(projectURL);
|
||||||
}}
|
}}
|
||||||
renderContent={() => {
|
renderContent={() => {
|
||||||
return data ? (
|
return (
|
||||||
<TaskDetails
|
<TaskDetails
|
||||||
onCancelCommentEdit={() => setEditableComment(null)}
|
|
||||||
onUpdateComment={(commentID, message) => {
|
|
||||||
updateTaskComment({ variables: { commentID, message } });
|
|
||||||
}}
|
|
||||||
editableComment={editableComment}
|
|
||||||
me={data.me.user}
|
me={data.me.user}
|
||||||
onCommentShowActions={(commentID, $targetRef) => {
|
|
||||||
showPopup(
|
|
||||||
$targetRef,
|
|
||||||
<TaskCommentActions
|
|
||||||
onDeleteComment={() => {
|
|
||||||
deleteTaskComment({ variables: { commentID } });
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
onEditComment={() => {
|
|
||||||
setEditableComment(commentID);
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
task={data.findTask}
|
task={data.findTask}
|
||||||
onCreateComment={(task, message) => {
|
|
||||||
createTaskComment({ variables: { taskID: task.id, message } });
|
|
||||||
}}
|
|
||||||
onChecklistDrop={checklist => {
|
onChecklistDrop={checklist => {
|
||||||
updateTaskChecklistLocation({
|
updateTaskChecklistLocation({
|
||||||
variables: { taskChecklistID: checklist.id, position: checklist.position },
|
variables: { taskChecklistID: checklist.id, position: checklist.position },
|
||||||
@ -632,12 +503,12 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
<DueDateManager
|
<DueDateManager
|
||||||
task={task}
|
task={task}
|
||||||
onRemoveDueDate={t => {
|
onRemoveDueDate={t => {
|
||||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } });
|
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
|
||||||
// hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
onDueDateChange={(t, newDueDate, hasTime) => {
|
onDueDateChange={(t, newDueDate) => {
|
||||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } });
|
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
||||||
// hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
onCancel={NOOP}
|
onCancel={NOOP}
|
||||||
/>
|
/>
|
||||||
@ -646,8 +517,6 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<TaskDetailsLoading />
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -134,6 +134,7 @@ type MemberFilterOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => {
|
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) {
|
if (input && input.trim().length < 3) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -161,10 +162,12 @@ const fetchMembers = async (client: any, projectID: string, options: MemberFilte
|
|||||||
|
|
||||||
let results: any = [];
|
let results: any = [];
|
||||||
const emails: Array<string> = [];
|
const emails: Array<string> = [];
|
||||||
|
console.log(res.data && res.data.searchMembers);
|
||||||
if (res.data && res.data.searchMembers) {
|
if (res.data && res.data.searchMembers) {
|
||||||
results = [
|
results = [
|
||||||
...res.data.searchMembers.map((m: any) => {
|
...res.data.searchMembers.map((m: any) => {
|
||||||
if (m.status === 'INVITED') {
|
if (m.status === 'INVITED') {
|
||||||
|
console.log(`${m.id} is added`);
|
||||||
return {
|
return {
|
||||||
label: m.id,
|
label: m.id,
|
||||||
value: {
|
value: {
|
||||||
@ -176,15 +179,17 @@ const fetchMembers = async (client: any, projectID: string, options: MemberFilte
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
} 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 },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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)) {
|
if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) {
|
||||||
@ -237,6 +242,7 @@ const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
|
const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
|
||||||
|
console.log(data);
|
||||||
return !isDisabled ? (
|
return !isDisabled ? (
|
||||||
<OptionWrapper {...innerProps} isFocused={isFocused}>
|
<OptionWrapper {...innerProps} isFocused={isFocused}>
|
||||||
<TaskAssignee
|
<TaskAssignee
|
||||||
@ -436,7 +442,6 @@ const Project = () => {
|
|||||||
|
|
||||||
const { loading, data, error } = useFindProjectQuery({
|
const { loading, data, error } = useFindProjectQuery({
|
||||||
variables: { projectID },
|
variables: { projectID },
|
||||||
pollInterval: 3000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [updateProjectName] = useUpdateProjectNameMutation({
|
const [updateProjectName] = useUpdateProjectNameMutation({
|
||||||
@ -517,6 +522,14 @@ const Project = () => {
|
|||||||
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) {
|
if (error) {
|
||||||
history.push('/projects');
|
history.push('/projects');
|
||||||
}
|
}
|
||||||
@ -629,12 +642,7 @@ const Project = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return <div>Error</div>;
|
||||||
<>
|
|
||||||
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
|
|
||||||
<BoardLoading />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Project;
|
export default Project;
|
||||||
|
@ -150,7 +150,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`
|
||||||
@ -203,7 +202,7 @@ type ShowNewProject = {
|
|||||||
|
|
||||||
const Projects = () => {
|
const Projects = () => {
|
||||||
const { showPopup, hidePopup } = usePopup();
|
const { showPopup, hidePopup } = usePopup();
|
||||||
const { loading, data } = useGetProjectsQuery({ pollInterval: 3000, fetchPolicy: 'cache-and-network' });
|
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = 'Taskcafé';
|
document.title = 'Taskcafé';
|
||||||
}, []);
|
}, []);
|
||||||
@ -232,6 +231,9 @@ const Projects = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (loading) {
|
||||||
|
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
|
||||||
|
}
|
||||||
|
|
||||||
const colors = theme.colors.multiColors;
|
const colors = theme.colors.multiColors;
|
||||||
if (data && user) {
|
if (data && user) {
|
||||||
@ -393,7 +395,7 @@ const Projects = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
|
return <div>Error!</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Projects;
|
export default Projects;
|
||||||
|
@ -419,11 +419,7 @@ 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 },
|
|
||||||
fetchPolicy: 'cache-and-network',
|
|
||||||
pollInterval: 3000,
|
|
||||||
});
|
|
||||||
const { user, setUserRoles } = useCurrentUser();
|
const { user, setUserRoles } = useCurrentUser();
|
||||||
const warning =
|
const warning =
|
||||||
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
||||||
@ -472,6 +468,9 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (loading) {
|
||||||
|
return <span>loading</span>;
|
||||||
|
}
|
||||||
|
|
||||||
if (data && user) {
|
if (data && user) {
|
||||||
return (
|
return (
|
||||||
@ -559,7 +558,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div>loading</div>;
|
return <div>error</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Members;
|
export default Members;
|
||||||
|
@ -155,11 +155,10 @@ type TeamProjectsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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: 3000,
|
}
|
||||||
});
|
|
||||||
if (data) {
|
if (data) {
|
||||||
return (
|
return (
|
||||||
<ProjectsContainer>
|
<ProjectsContainer>
|
||||||
@ -190,7 +189,7 @@ const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
|
|||||||
</ProjectsContainer>
|
</ProjectsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span>loading</span>;
|
return <span>error</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TeamProjects;
|
export default TeamProjects;
|
||||||
|
@ -94,6 +94,23 @@ 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) {
|
||||||
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
|
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
|
||||||
return <Redirect to="/" />;
|
return <Redirect to="/" />;
|
||||||
@ -129,21 +146,7 @@ const Teams = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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;
|
||||||
|
@ -20,13 +20,15 @@ import App from './App';
|
|||||||
|
|
||||||
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
|
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
|
||||||
|
|
||||||
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.updateLocale('en', {
|
dayjs.updateLocale('en', {
|
||||||
week: {
|
week: {
|
||||||
dow: 1, // First day of week is Monday
|
dow: 1, // First day of week is Monday
|
||||||
|
19
frontend/src/shared/components/AddList/AddList.stories.tsx
Normal file
19
frontend/src/shared/components/AddList/AddList.stories.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import theme from 'App/ThemeStyles';
|
||||||
|
import AddList from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: AddList,
|
||||||
|
title: 'AddList',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'gray', value: theme.colors.bg.secondary, default: true },
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return <AddList onSave={action('on save')} />;
|
||||||
|
};
|
61
frontend/src/shared/components/Admin/Admin.stories.tsx
Normal file
61
frontend/src/shared/components/Admin/Admin.stories.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
138
frontend/src/shared/components/Button/Button.stories.tsx
Normal file
138
frontend/src/shared/components/Button/Button.stories.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
174
frontend/src/shared/components/Card/Card.stories.tsx
Normal file
174
frontend/src/shared/components/Card/Card.stories.tsx
Normal 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')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -227,19 +227,18 @@ export const ListCardOperation = styled.span`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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: ${props => props.theme.colors.text.primary};
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const CardMembers = styled.div`
|
export const CardMembers = styled.div`
|
||||||
@ -252,7 +251,6 @@ export const CompleteIcon = styled(CheckCircle)`
|
|||||||
fill: ${props => props.theme.colors.success};
|
fill: ${props => props.theme.colors.success};
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-bottom: -2px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const EditorContent = styled.div`
|
export const EditorContent = styled.div`
|
||||||
|
@ -22,7 +22,6 @@ import {
|
|||||||
ListCardOperation,
|
ListCardOperation,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardMembers,
|
CardMembers,
|
||||||
CardTitleText,
|
|
||||||
} from './Styles';
|
} from './Styles';
|
||||||
|
|
||||||
type DueDate = {
|
type DueDate = {
|
||||||
@ -211,7 +210,7 @@ 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>
|
||||||
|
@ -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')} />;
|
||||||
|
};
|
@ -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
|
||||||
|
155
frontend/src/shared/components/Checklist/Checklist.stories.tsx
Normal file
155
frontend/src/shared/components/Checklist/Checklist.stories.tsx
Normal 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 ${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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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: ${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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,65 @@
|
|||||||
|
import React, { createRef, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import DropdownMenu from '.';
|
||||||
|
import theme from '../../../App/ThemeStyles';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: DropdownMenu,
|
||||||
|
title: 'DropdownMenu',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
{ name: 'gray', value: '#f8f8f8' },
|
||||||
|
{ name: 'darkBlue', value: theme.colors.bg.secondary, default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
const Button = styled.div`
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
color: #fff;
|
||||||
|
background: #000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
const [menu, setMenu] = useState({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
isOpen: false,
|
||||||
|
});
|
||||||
|
const $buttonRef: any = createRef();
|
||||||
|
const onClick = () => {
|
||||||
|
setMenu({
|
||||||
|
isOpen: !menu.isOpen,
|
||||||
|
left: $buttonRef.current.getBoundingClientRect().right,
|
||||||
|
top: $buttonRef.current.getBoundingClientRect().bottom,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container>
|
||||||
|
<Button onClick={onClick} ref={$buttonRef}>
|
||||||
|
Click me
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
{menu.isOpen && (
|
||||||
|
<DropdownMenu
|
||||||
|
onAdminConsole={action('admin')}
|
||||||
|
onCloseDropdown={() => {
|
||||||
|
setMenu({ top: 0, left: 0, isOpen: false });
|
||||||
|
}}
|
||||||
|
onLogout={action('on logout')}
|
||||||
|
left={menu.left}
|
||||||
|
top={menu.top}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -2,8 +2,6 @@ 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 Input from 'shared/components/Input';
|
import Input from 'shared/components/Input';
|
||||||
import ControlledInput from 'shared/components/ControlledInput';
|
|
||||||
import { Clock } from 'shared/icons';
|
|
||||||
|
|
||||||
export const Wrapper = styled.div`
|
export const Wrapper = styled.div`
|
||||||
display: flex
|
display: flex
|
||||||
@ -19,11 +17,6 @@ display: flex
|
|||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
& .react-datepicker__close-icon::after {
|
|
||||||
background: none;
|
|
||||||
font-size: 16px;
|
|
||||||
color: ${props => props.theme.colors.text.primary};
|
|
||||||
}
|
|
||||||
|
|
||||||
& .react-datepicker-time__header {
|
& .react-datepicker-time__header {
|
||||||
color: ${props => props.theme.colors.text.primary};
|
color: ${props => props.theme.colors.text.primary};
|
||||||
@ -98,24 +91,6 @@ display: flex
|
|||||||
border-bottom: 1px solid ${props => props.theme.colors.border};
|
border-bottom: 1px solid ${props => props.theme.colors.border};
|
||||||
}
|
}
|
||||||
|
|
||||||
& .react-datepicker__input-container input {
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
||||||
border-color: ${props => props.theme.colors.alternate};
|
|
||||||
background: #262c49;
|
|
||||||
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
|
|
||||||
padding: 0.7rem;
|
|
||||||
color: #c2c6dc;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 5px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 20px;
|
|
||||||
padding: 0 12px;
|
|
||||||
&:focus {
|
|
||||||
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
|
|
||||||
border: 1px solid rgba(115, 103, 240);
|
|
||||||
background: ${props => props.theme.colors.bg.primary};
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const DueDatePickerWrapper = styled.div`
|
export const DueDatePickerWrapper = styled.div`
|
||||||
@ -135,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;
|
||||||
@ -182,86 +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: 82px;
|
|
||||||
}
|
|
||||||
& .react-datepicker__input-container input {
|
|
||||||
padding-bottom: 4px;
|
|
||||||
padding-top: 4px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ActionClock = styled(Clock)`
|
|
||||||
align-self: center;
|
|
||||||
fill: ${props => props.theme.colors.primary};
|
|
||||||
margin: 0 8px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ActionLabel = styled.div`
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 14px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ActionIcon = styled.div`
|
|
||||||
height: 36px;
|
|
||||||
min-height: 36px;
|
|
||||||
min-width: 36px;
|
|
||||||
width: 36px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 8px;
|
|
||||||
svg {
|
|
||||||
fill: ${props => props.theme.colors.text.primary};
|
|
||||||
transition-duration: 0.2s;
|
|
||||||
transition-property: background, border, box-shadow, fill;
|
|
||||||
}
|
|
||||||
&:hover svg {
|
|
||||||
fill: ${props => props.theme.colors.text.secondary};
|
|
||||||
}
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ClearButton = styled.div`
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 13px;
|
|
||||||
height: 36px;
|
|
||||||
line-height: 36px;
|
|
||||||
padding: 0 12px;
|
|
||||||
margin-left: auto;
|
|
||||||
cursor: pointer;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
justify-content: center;
|
|
||||||
transition-duration: 0.2s;
|
|
||||||
transition-property: background, border, box-shadow, color, fill;
|
|
||||||
color: ${props => props.theme.colors.text.primary};
|
|
||||||
&:hover {
|
|
||||||
color: ${props => props.theme.colors.text.secondary};
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
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';
|
||||||
@ -8,27 +8,11 @@ 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 {
|
import { Wrapper, ActionWrapper, RemoveDueDate, DueDateInput, DueDatePickerWrapper, ConfirmAddDueDate } from './Styles';
|
||||||
Wrapper,
|
|
||||||
RemoveDueDate,
|
|
||||||
DueDateInput,
|
|
||||||
DueDatePickerWrapper,
|
|
||||||
ConfirmAddDueDate,
|
|
||||||
DateRangeInputs,
|
|
||||||
AddDateRange,
|
|
||||||
ActionIcon,
|
|
||||||
ActionsWrapper,
|
|
||||||
ClearButton,
|
|
||||||
ActionsSeparator,
|
|
||||||
ActionClock,
|
|
||||||
ActionLabel,
|
|
||||||
} from './Styles';
|
|
||||||
import { Clock, Cross } from 'shared/icons';
|
|
||||||
import Select from 'react-select/src/Select';
|
|
||||||
|
|
||||||
type DueDateManagerProps = {
|
type DueDateManagerProps = {
|
||||||
task: Task;
|
task: Task;
|
||||||
onDueDateChange: (task: Task, newDueDate: Date, hasTime: boolean) => void;
|
onDueDateChange: (task: Task, newDueDate: Date) => void;
|
||||||
onRemoveDueDate: (task: Task) => void;
|
onRemoveDueDate: (task: Task) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
@ -68,20 +52,14 @@ 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: ${props => props.theme.colors.bg.primary};
|
|
||||||
}
|
|
||||||
|
|
||||||
& option:hover {
|
|
||||||
background: ${props => props.theme.colors.bg.secondary};
|
background: ${props => props.theme.colors.bg.secondary};
|
||||||
border: 1px solid ${props => props.theme.colors.primary};
|
border: 1px solid ${props => props.theme.colors.primary};
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
@ -132,34 +110,15 @@ const HeaderActions = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
|
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
|
||||||
const currentDueDate = task.dueDate ? dayjs(task.dueDate).toDate() : null;
|
const now = dayjs();
|
||||||
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
|
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
|
||||||
|
const [startDate, setStartDate] = useState(new Date());
|
||||||
const [startDate, setStartDate] = useState<Date | null>(currentDueDate);
|
|
||||||
const [endDate, setEndDate] = useState<Date | null>(currentDueDate);
|
|
||||||
const [hasTime, enableTime] = useState(task.hasTime ?? false);
|
|
||||||
const firstRun = useRef<boolean>(true);
|
|
||||||
|
|
||||||
const debouncedFunctionRef = useRef((newDate: Date | null, nowHasTime: boolean) => {
|
|
||||||
if (!firstRun.current) {
|
|
||||||
if (newDate) {
|
|
||||||
onDueDateChange(task, newDate, nowHasTime);
|
|
||||||
} else {
|
|
||||||
onRemoveDueDate(task);
|
|
||||||
enableTime(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
firstRun.current = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const debouncedChange = useCallback(
|
|
||||||
_.debounce((newDate, nowHasTime) => debouncedFunctionRef.current(newDate, nowHasTime), 500),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
debouncedChange(startDate, hasTime);
|
const newDate = dayjs(startDate).format('YYYY-MM-DD');
|
||||||
}, [startDate, hasTime]);
|
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 = [
|
||||||
'January',
|
'January',
|
||||||
@ -175,21 +134,19 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
|||||||
'November',
|
'November',
|
||||||
'December',
|
'December',
|
||||||
];
|
];
|
||||||
|
const saveDueDate = (data: any) => {
|
||||||
const onChange = (dates: any) => {
|
const newDate = dayjs(`${data.endDate} ${dayjs(data.endTime).format('h:mm A')}`, 'YYYY-MM-DD h:mm A');
|
||||||
const [start, end] = dates;
|
if (newDate.isValid()) {
|
||||||
setStartDate(start);
|
onDueDateChange(task, newDate.toDate());
|
||||||
setEndDate(end);
|
}
|
||||||
};
|
};
|
||||||
const [isRange, setIsRange] = useState(false);
|
const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => {
|
||||||
|
|
||||||
const CustomTimeInput = forwardRef(({ value, onClick, onChange, onBlur, onFocus }: any, $ref: any) => {
|
|
||||||
return (
|
return (
|
||||||
<DueDateInput
|
<DueDateInput
|
||||||
id="endTime"
|
id="endTime"
|
||||||
value={value}
|
value={value}
|
||||||
name="endTime"
|
name="endTime"
|
||||||
onChange={onChange}
|
ref={$ref}
|
||||||
width="100%"
|
width="100%"
|
||||||
variant="alternate"
|
variant="alternate"
|
||||||
label="Time"
|
label="Time"
|
||||||
@ -197,119 +154,114 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<DateRangeInputs>
|
<Form onSubmit={handleSubmit(saveDueDate)}>
|
||||||
<DatePicker
|
<FormField>
|
||||||
selected={startDate}
|
<DueDateInput
|
||||||
onChange={date => setStartDate(date)}
|
id="endDate"
|
||||||
popperClassName="picker-hidden"
|
name="endDate"
|
||||||
dateFormat="yyyy-MM-dd"
|
width="100%"
|
||||||
disabledKeyboardNavigation
|
variant="alternate"
|
||||||
isClearable
|
label="Date"
|
||||||
placeholderText="Select due date"
|
defaultValue={now.format('YYYY-MM-DD')}
|
||||||
/>
|
ref={register({
|
||||||
{isRange ? (
|
required: 'End date is required.',
|
||||||
<DatePicker
|
})}
|
||||||
selected={startDate}
|
|
||||||
isClearable
|
|
||||||
onChange={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 => setStartDate(date)}
|
render={({ onChange, onBlur, value }) => (
|
||||||
startDate={startDate}
|
<DatePicker
|
||||||
useWeekdaysShort
|
onChange={onChange}
|
||||||
renderCustomHeader={({
|
selected={value}
|
||||||
date,
|
onBlur={onBlur}
|
||||||
changeYear,
|
showTimeSelect
|
||||||
changeMonth,
|
showTimeSelectOnly
|
||||||
decreaseMonth,
|
timeIntervals={15}
|
||||||
increaseMonth,
|
timeCaption="Time"
|
||||||
prevMonthButtonDisabled,
|
dateFormat="h:mm aa"
|
||||||
nextMonthButtonDisabled,
|
customInput={<CustomTimeInput />}
|
||||||
}) => (
|
/>
|
||||||
<HeaderActions>
|
)}
|
||||||
<HeaderButton onClick={decreaseMonth} disabled={prevMonthButtonDisabled}>
|
/>
|
||||||
Prev
|
</FormField>
|
||||||
</HeaderButton>
|
<DueDatePickerWrapper>
|
||||||
<HeaderSelectLabel>
|
<DatePicker
|
||||||
{months[date.getMonth()]}
|
useWeekdaysShort
|
||||||
<HeaderSelect
|
renderCustomHeader={({
|
||||||
value={months[getMonth(date)]}
|
date,
|
||||||
onChange={({ target: { value } }) => changeMonth(months.indexOf(value))}
|
changeYear,
|
||||||
>
|
changeMonth,
|
||||||
{months.map(option => (
|
decreaseMonth,
|
||||||
<option key={option} value={option}>
|
increaseMonth,
|
||||||
{option}
|
prevMonthButtonDisabled,
|
||||||
</option>
|
nextMonthButtonDisabled,
|
||||||
))}
|
}) => (
|
||||||
</HeaderSelect>
|
<HeaderActions>
|
||||||
</HeaderSelectLabel>
|
<HeaderButton onClick={decreaseMonth} disabled={prevMonthButtonDisabled}>
|
||||||
<HeaderSelectLabel>
|
Prev
|
||||||
{date.getFullYear()}
|
</HeaderButton>
|
||||||
<HeaderSelect value={getYear(date)} onChange={({ target: { value } }) => changeYear(parseInt(value, 10))}>
|
<HeaderSelectLabel>
|
||||||
{years.map(option => (
|
{months[date.getMonth()]}
|
||||||
<option key={option} value={option}>
|
<HeaderSelect
|
||||||
{option}
|
value={getYear(date)}
|
||||||
</option>
|
onChange={({ target: { value } }) => changeYear(parseInt(value, 10))}
|
||||||
))}
|
>
|
||||||
</HeaderSelect>
|
{years.map(option => (
|
||||||
</HeaderSelectLabel>
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</HeaderSelect>
|
||||||
|
</HeaderSelectLabel>
|
||||||
|
<HeaderSelectLabel>
|
||||||
|
{date.getFullYear()}
|
||||||
|
<HeaderSelect
|
||||||
|
value={months[getMonth(date)]}
|
||||||
|
onChange={({ target: { value } }) => changeMonth(months.indexOf(value))}
|
||||||
|
>
|
||||||
|
{months.map(option => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</HeaderSelect>
|
||||||
|
</HeaderSelectLabel>
|
||||||
|
|
||||||
<HeaderButton onClick={increaseMonth} disabled={nextMonthButtonDisabled}>
|
<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}
|
||||||
|
inline
|
||||||
onChange={date => {
|
onChange={date => {
|
||||||
setStartDate(date);
|
if (date) {
|
||||||
}}
|
setStartDate(date);
|
||||||
showTimeSelect
|
|
||||||
showTimeSelectOnly
|
|
||||||
timeIntervals={15}
|
|
||||||
timeCaption="Time"
|
|
||||||
dateFormat="h:mm aa"
|
|
||||||
/>
|
|
||||||
<ActionIcon onClick={() => enableTime(false)}>
|
|
||||||
<Cross width={16} height={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</ActionsWrapper>
|
|
||||||
)}
|
|
||||||
<ActionsWrapper>
|
|
||||||
{!hasTime && (
|
|
||||||
<ActionIcon
|
|
||||||
onClick={() => {
|
|
||||||
if (startDate === null) {
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(12, 30, 0);
|
|
||||||
setStartDate(today);
|
|
||||||
}
|
}
|
||||||
enableTime(true);
|
}}
|
||||||
|
/>
|
||||||
|
</DueDatePickerWrapper>
|
||||||
|
<ActionWrapper>
|
||||||
|
<ConfirmAddDueDate type="submit" onClick={NOOP}>
|
||||||
|
Save
|
||||||
|
</ConfirmAddDueDate>
|
||||||
|
<RemoveDueDate
|
||||||
|
variant="outline"
|
||||||
|
color="danger"
|
||||||
|
onClick={() => {
|
||||||
|
onRemoveDueDate(task);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Clock width={16} height={16} />
|
Remove
|
||||||
</ActionIcon>
|
</RemoveDueDate>
|
||||||
)}
|
</ActionWrapper>
|
||||||
<ClearButton onClick={() => setStartDate(null)}>{hasTime ? 'Clear all' : 'Clear'}</ClearButton>
|
</Form>
|
||||||
</ActionsWrapper>
|
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
43
frontend/src/shared/components/Input/Input.stories.tsx
Normal file
43
frontend/src/shared/components/Input/Input.stories.tsx
Normal 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: ${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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
146
frontend/src/shared/components/List/List.stories.tsx
Normal file
146
frontend/src/shared/components/List/List.stories.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
104
frontend/src/shared/components/Lists/Lists.stories.tsx
Normal file
104
frontend/src/shared/components/Lists/Lists.stories.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import theme from 'App/ThemeStyles';
|
||||||
|
import Lists from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Lists,
|
||||||
|
title: 'Lists',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'gray', value: theme.colors.bg.secondary, default: true },
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialListsData = {
|
||||||
|
columns: {
|
||||||
|
'column-1': {
|
||||||
|
taskGroupID: 'column-1',
|
||||||
|
name: 'General',
|
||||||
|
taskIds: ['task-3', 'task-4', 'task-1', 'task-2'],
|
||||||
|
position: 1,
|
||||||
|
tasks: [],
|
||||||
|
},
|
||||||
|
'column-2': {
|
||||||
|
taskGroupID: 'column-2',
|
||||||
|
name: 'Development',
|
||||||
|
taskIds: [],
|
||||||
|
position: 2,
|
||||||
|
tasks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
'task-1': {
|
||||||
|
taskID: 'task-1',
|
||||||
|
taskGroup: { taskGroupID: 'column-1' },
|
||||||
|
name: 'Create roadmap',
|
||||||
|
position: 2,
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
'task-2': {
|
||||||
|
taskID: 'task-2',
|
||||||
|
taskGroup: { taskGroupID: 'column-1' },
|
||||||
|
position: 1,
|
||||||
|
name: 'Create authentication',
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
'task-3': {
|
||||||
|
taskID: 'task-3',
|
||||||
|
taskGroup: { taskGroupID: 'column-1' },
|
||||||
|
position: 3,
|
||||||
|
name: 'Create login',
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
'task-4': {
|
||||||
|
taskID: 'task-4',
|
||||||
|
taskGroup: { taskGroupID: 'column-1' },
|
||||||
|
position: 4,
|
||||||
|
name: 'Create plugins',
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
const [listsData, setListsData] = useState(initialListsData);
|
||||||
|
const onCardDrop = (droppedTask: Task) => {
|
||||||
|
const newState = {
|
||||||
|
...listsData,
|
||||||
|
tasks: {
|
||||||
|
...listsData.tasks,
|
||||||
|
[droppedTask.id]: droppedTask,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setListsData(newState);
|
||||||
|
};
|
||||||
|
const onListDrop = (droppedColumn: any) => {
|
||||||
|
const newState = {
|
||||||
|
...listsData,
|
||||||
|
columns: {
|
||||||
|
...listsData.columns,
|
||||||
|
[droppedColumn.taskGroupID]: droppedColumn,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setListsData(newState);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Lists
|
||||||
|
taskGroups={[]}
|
||||||
|
onTaskClick={action('card click')}
|
||||||
|
onQuickEditorOpen={action('card composer open')}
|
||||||
|
onCreateTask={action('card create')}
|
||||||
|
onTaskDrop={onCardDrop}
|
||||||
|
onTaskGroupDrop={onListDrop}
|
||||||
|
onChangeTaskGroupName={action('change group name')}
|
||||||
|
cardLabelVariant="large"
|
||||||
|
onCardLabelClick={action('label click')}
|
||||||
|
onCreateTaskGroup={action('create list')}
|
||||||
|
onExtraMenuOpen={action('extra menu open')}
|
||||||
|
onCardMemberClick={action('card member click')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -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;
|
||||||
|
@ -391,16 +391,16 @@ const SimpleLists: React.FC<SimpleProps> = ({
|
|||||||
</Draggable>
|
</Draggable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<AddList
|
||||||
|
onSave={listName => {
|
||||||
|
onCreateTaskGroup(listName);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{provided.placeholder}
|
{provided.placeholder}
|
||||||
</Container>
|
</Container>
|
||||||
)}
|
)}
|
||||||
</Droppable>
|
</Droppable>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
<AddList
|
|
||||||
onSave={listName => {
|
|
||||||
onCreateTaskGroup(listName);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</BoardWrapper>
|
</BoardWrapper>
|
||||||
</BoardContainer>
|
</BoardContainer>
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import LoadingSpinner from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: LoadingSpinner,
|
||||||
|
title: 'Login',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
{ name: 'gray', value: '#cdd3e1', default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<LoadingSpinner />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
67
frontend/src/shared/components/Login/Login.stories.tsx
Normal file
67
frontend/src/shared/components/Login/Login.stories.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Login from '.';
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Login,
|
||||||
|
title: 'Login',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
{ name: 'gray', value: '#cdd3e1', default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LoginWrapper = styled.div`
|
||||||
|
width: 60%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Container>
|
||||||
|
<LoginWrapper>
|
||||||
|
<Login onSubmit={action('on submit')} />
|
||||||
|
</LoginWrapper>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSubmission = () => {
|
||||||
|
const onSubmit = async (data: LoginFormData, setComplete: (val: boolean) => void, setError: any) => {
|
||||||
|
await sleep(2000);
|
||||||
|
if (data.username !== 'test' || data.password !== 'test') {
|
||||||
|
setError('username', 'invalid', 'Invalid username');
|
||||||
|
setError('password', 'invalid', 'Invalid password');
|
||||||
|
}
|
||||||
|
setComplete(true);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Container>
|
||||||
|
<LoginWrapper>
|
||||||
|
<Login onSubmit={onSubmit} />
|
||||||
|
</LoginWrapper>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import MemberManager from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: MemberManager,
|
||||||
|
title: 'MemberManager',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff', default: true },
|
||||||
|
{ name: 'gray', value: '#f8f8f8' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return <MemberManager availableMembers={[]} activeMembers={[]} onMemberChange={action('member change')} />;
|
||||||
|
};
|
32
frontend/src/shared/components/Modal/Modal.stories.tsx
Normal file
32
frontend/src/shared/components/Modal/Modal.stories.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import Modal from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Modal,
|
||||||
|
title: 'Modal',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
{ name: 'gray', value: '#cdd3e1', default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Modal
|
||||||
|
width={1040}
|
||||||
|
onClose={action('on close')}
|
||||||
|
renderContent={() => {
|
||||||
|
return <h1>Hello!</h1>;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
40
frontend/src/shared/components/Navbar/Navbar.stories.tsx
Normal file
40
frontend/src/shared/components/Navbar/Navbar.stories.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import { Home } from 'shared/icons';
|
||||||
|
import Navbar, { ActionButton, ButtonContainer, PrimaryLogo } from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Navbar,
|
||||||
|
title: 'Navbar',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff', default: true },
|
||||||
|
{ name: 'gray', value: '#cdd3e1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const MainContent = styled.div`
|
||||||
|
padding: 0 0 50px 80px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Navbar>
|
||||||
|
<PrimaryLogo />
|
||||||
|
<ButtonContainer>
|
||||||
|
<ActionButton name="Home">
|
||||||
|
<Home width={28} height={28} />
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton name="Home">
|
||||||
|
<Home width={28} height={28} />
|
||||||
|
</ActionButton>
|
||||||
|
</ButtonContainer>
|
||||||
|
</Navbar>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import NOOP from 'shared/utils/noop';
|
||||||
|
import NewProject from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: NewProject,
|
||||||
|
title: 'NewProject',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff', default: true },
|
||||||
|
{ name: 'gray', value: '#f8f8f8' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<NewProject
|
||||||
|
initialTeamID={null}
|
||||||
|
onCreateProject={action('create project')}
|
||||||
|
teams={[{ name: 'General', id: 'general', createdAt: '' }]}
|
||||||
|
onClose={NOOP}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -87,12 +87,6 @@ export const HeaderTitle = styled.span`
|
|||||||
|
|
||||||
export const Content = styled.div`
|
export const Content = styled.div`
|
||||||
max-height: 632px;
|
max-height: 632px;
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
&::-webkit-scrollbar-track-piece {
|
|
||||||
background: ${props => props.theme.colors.bg.primary};
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const LabelSearch = styled(ControlledInput)`
|
export const LabelSearch = styled(ControlledInput)`
|
||||||
|
@ -17,7 +17,7 @@ import {
|
|||||||
} from './Styles';
|
} from './Styles';
|
||||||
|
|
||||||
function getPopupOptions(options?: PopupOptions) {
|
function getPopupOptions(options?: PopupOptions) {
|
||||||
const popupOptions: PopupOptionsInternal = {
|
const popupOptions = {
|
||||||
borders: true,
|
borders: true,
|
||||||
diamondColor: theme.colors.bg.secondary,
|
diamondColor: theme.colors.bg.secondary,
|
||||||
targetPadding: '10px',
|
targetPadding: '10px',
|
||||||
@ -40,9 +40,6 @@ function getPopupOptions(options?: PopupOptions) {
|
|||||||
if (options.diamondColor) {
|
if (options.diamondColor) {
|
||||||
popupOptions.diamondColor = options.diamondColor;
|
popupOptions.diamondColor = options.diamondColor;
|
||||||
}
|
}
|
||||||
if (options.onClose) {
|
|
||||||
popupOptions.onClose = options.onClose;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return popupOptions;
|
return popupOptions;
|
||||||
}
|
}
|
||||||
@ -139,7 +136,6 @@ type PopupOptionsInternal = {
|
|||||||
targetPadding: string;
|
targetPadding: string;
|
||||||
diamondColor: string;
|
diamondColor: string;
|
||||||
showDiamond: boolean;
|
showDiamond: boolean;
|
||||||
onClose?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PopupOptions = {
|
type PopupOptions = {
|
||||||
@ -148,7 +144,6 @@ type PopupOptions = {
|
|||||||
width?: number | null;
|
width?: number | null;
|
||||||
borders?: boolean | null;
|
borders?: boolean | null;
|
||||||
diamondColor?: string | null;
|
diamondColor?: string | null;
|
||||||
onClose?: () => void;
|
|
||||||
};
|
};
|
||||||
const defaultState = {
|
const defaultState = {
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
@ -244,12 +239,7 @@ export const PopupProvider: React.FC = ({ children }) => {
|
|||||||
top={currentState.top}
|
top={currentState.top}
|
||||||
targetPadding={currentState.options.targetPadding}
|
targetPadding={currentState.options.targetPadding}
|
||||||
left={currentState.left}
|
left={currentState.left}
|
||||||
onClose={() => {
|
onClose={() => setState(defaultState)}
|
||||||
if (currentState.options && currentState.options.onClose) {
|
|
||||||
currentState.options.onClose();
|
|
||||||
}
|
|
||||||
setState(defaultState);
|
|
||||||
}}
|
|
||||||
width={currentState.options.width}
|
width={currentState.options.width}
|
||||||
>
|
>
|
||||||
{currentState.content}
|
{currentState.content}
|
||||||
|
@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import ProjectGridItem from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: ProjectGridItem,
|
||||||
|
title: 'ProjectGridItem',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff', default: true },
|
||||||
|
{ name: 'gray', value: '#f8f8f8' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectsData = [
|
||||||
|
{ taskGroups: [], teamTitle: 'Personal', projectID: 'aaaa', name: 'Taskcafé', color: '#aa62e3' },
|
||||||
|
{ taskGroups: [], teamTitle: 'Personal', projectID: 'bbbb', name: 'Editorial Calender', color: '#aa62e3' },
|
||||||
|
{ taskGroups: [], teamTitle: 'Personal', projectID: 'cccc', name: 'New Blog', color: '#aa62e3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectsWrapper = styled.div`
|
||||||
|
width: 60%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ProjectsWrapper>
|
||||||
|
{projectsData.map(project => (
|
||||||
|
<ProjectGridItem project={project} />
|
||||||
|
))}
|
||||||
|
</ProjectsWrapper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,99 @@
|
|||||||
|
import React, { createRef, useState } from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import Card from 'shared/components/Card';
|
||||||
|
import CardComposer from 'shared/components/CardComposer';
|
||||||
|
import LabelColors from 'shared/constants/labelColors';
|
||||||
|
import List, { ListCards } from 'shared/components/List';
|
||||||
|
import QuickCardEditor from 'shared/components/QuickCardEditor';
|
||||||
|
import NOOP from 'shared/utils/noop';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: QuickCardEditor,
|
||||||
|
title: 'QuickCardEditor',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff', default: true },
|
||||||
|
{ name: 'gray', value: '#f8f8f8' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelData: Array<TaskLabel> = [
|
||||||
|
{
|
||||||
|
id: 'development',
|
||||||
|
assignedDate: new Date().toString(),
|
||||||
|
projectLabel: {
|
||||||
|
id: 'development',
|
||||||
|
name: 'Development',
|
||||||
|
createdDate: 'date',
|
||||||
|
labelColor: {
|
||||||
|
id: 'label-color-blue',
|
||||||
|
colorHex: LabelColors.BLUE,
|
||||||
|
name: 'blue',
|
||||||
|
position: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
const $cardRef: any = createRef();
|
||||||
|
const task: Task = {
|
||||||
|
id: 'task',
|
||||||
|
name: 'Hello, world!',
|
||||||
|
position: 1,
|
||||||
|
labels: labelData,
|
||||||
|
taskGroup: {
|
||||||
|
id: '1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const [isEditorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [target, setTarget] = useState<null | React.RefObject<HTMLElement>>(null);
|
||||||
|
const [top, setTop] = useState(0);
|
||||||
|
const [left, setLeft] = useState(0);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isEditorOpen && target && (
|
||||||
|
<QuickCardEditor
|
||||||
|
task={task}
|
||||||
|
onCloseEditor={() => setEditorOpen(false)}
|
||||||
|
onEditCard={action('edit card')}
|
||||||
|
onOpenLabelsPopup={action('open popup')}
|
||||||
|
onOpenDueDatePopup={action('open popup')}
|
||||||
|
onOpenMembersPopup={action('open popup')}
|
||||||
|
onToggleComplete={action('complete')}
|
||||||
|
onArchiveCard={action('archive card')}
|
||||||
|
target={target}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<List
|
||||||
|
id="1"
|
||||||
|
name="General"
|
||||||
|
isComposerOpen={false}
|
||||||
|
onSaveName={action('on save name')}
|
||||||
|
onOpenComposer={action('on open composer')}
|
||||||
|
onExtraMenuOpen={NOOP}
|
||||||
|
>
|
||||||
|
<ListCards>
|
||||||
|
<Card
|
||||||
|
taskID="1"
|
||||||
|
taskGroupID="1"
|
||||||
|
description="hello!"
|
||||||
|
ref={$cardRef}
|
||||||
|
title={task.name}
|
||||||
|
onClick={action('on click')}
|
||||||
|
onContextMenu={() => {
|
||||||
|
setTarget($cardRef);
|
||||||
|
setEditorOpen(true);
|
||||||
|
}}
|
||||||
|
watched
|
||||||
|
labels={labelData.map(l => l.projectLabel)}
|
||||||
|
checklists={{ complete: 1, total: 4 }}
|
||||||
|
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
|
||||||
|
/>
|
||||||
|
<CardComposer onClose={NOOP} onCreateCard={NOOP} isOpen={false} />
|
||||||
|
</ListCards>
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -84,58 +84,6 @@ export const colourStyles = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const editorColourStyles = {
|
|
||||||
...colourStyles,
|
|
||||||
input: (styles: any) => ({
|
|
||||||
...styles,
|
|
||||||
color: '#000',
|
|
||||||
}),
|
|
||||||
singleValue: (styles: any) => {
|
|
||||||
return {
|
|
||||||
...styles,
|
|
||||||
color: '#000',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
menu: (styles: any) => {
|
|
||||||
return {
|
|
||||||
...styles,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
indicatorsContainer: (styles: any) => {
|
|
||||||
return {
|
|
||||||
...styles,
|
|
||||||
display: 'none',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
container: (styles: any) => {
|
|
||||||
return {
|
|
||||||
...styles,
|
|
||||||
display: 'flex',
|
|
||||||
flex: '1 1',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
control: (styles: any, data: any) => {
|
|
||||||
return {
|
|
||||||
...styles,
|
|
||||||
flex: '1 1',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
boxShadow: 'none',
|
|
||||||
borderRadius: '0',
|
|
||||||
minHeight: '35px',
|
|
||||||
border: '0',
|
|
||||||
':hover': {
|
|
||||||
boxShadow: 'none',
|
|
||||||
borderRadius: '0',
|
|
||||||
},
|
|
||||||
':active': {
|
|
||||||
boxShadow: 'none',
|
|
||||||
borderRadius: '0',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const InputLabel = styled.span<{ width: string }>`
|
const InputLabel = styled.span<{ width: string }>`
|
||||||
width: ${props => props.width};
|
width: ${props => props.width};
|
||||||
padding-left: 0.7rem;
|
padding-left: 0.7rem;
|
||||||
|
37
frontend/src/shared/components/Settings/Settings.stories.tsx
Normal file
37
frontend/src/shared/components/Settings/Settings.stories.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React, { createRef, useState } from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import Settings from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Settings,
|
||||||
|
title: 'Settings',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff', default: true },
|
||||||
|
{ name: 'gray', value: '#f8f8f8' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const profile = {
|
||||||
|
id: '1',
|
||||||
|
fullName: 'Jordan Knott',
|
||||||
|
username: 'jordanthedev',
|
||||||
|
profileIcon: { url: '/uploads/headshot.png', bgColor: '#000', initials: 'JK' },
|
||||||
|
};
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Settings
|
||||||
|
profile={profile}
|
||||||
|
onChangeUserInfo={action('change user info')}
|
||||||
|
onResetPassword={action('reset password')}
|
||||||
|
onProfileAvatarRemove={action('remove')}
|
||||||
|
onProfileAvatarChange={action('profile avatar change')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
27
frontend/src/shared/components/Sidebar/Sidebar.stories.tsx
Normal file
27
frontend/src/shared/components/Sidebar/Sidebar.stories.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import Navbar from 'shared/components/Navbar';
|
||||||
|
import Sidebar from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Sidebar,
|
||||||
|
title: 'Sidebar',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
{ name: 'gray', value: '#cdd3e1', default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Navbar />
|
||||||
|
<Sidebar />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
25
frontend/src/shared/components/Tabs/Tabs.stories.tsx
Normal file
25
frontend/src/shared/components/Tabs/Tabs.stories.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import Tabs from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: Tabs,
|
||||||
|
title: 'Tabs',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Tabs />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { TaskActivityData, ActivityType } from 'shared/generated/graphql';
|
import { TaskActivityData, ActivityType } from 'shared/generated/graphql';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
type ActivityMessageProps = {
|
type ActivityMessageProps = {
|
||||||
type: ActivityType;
|
type: ActivityType;
|
||||||
@ -13,41 +12,12 @@ function getVariable(data: Array<TaskActivityData>, name: string) {
|
|||||||
return target ? target.value : null;
|
return target ? target.value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDate(timestamp: string | null) {
|
|
||||||
if (timestamp) {
|
|
||||||
return dayjs(timestamp).format('MMM D [at] h:mm A');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ActivityMessage: React.FC<ActivityMessageProps> = ({ type, data }) => {
|
const ActivityMessage: React.FC<ActivityMessageProps> = ({ type, data }) => {
|
||||||
let message = '';
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ActivityType.TaskAdded:
|
case ActivityType.TaskAdded:
|
||||||
message = `added this task to ${getVariable(data, 'TaskGroup')}`;
|
return <>`added this task to ${getVariable(data, 'TaskGroup')}`</>;
|
||||||
break;
|
|
||||||
case ActivityType.TaskMoved:
|
|
||||||
message = `moved this task from ${getVariable(data, 'PrevTaskGroup')} to ${getVariable(data, 'CurTaskGroup')}`;
|
|
||||||
break;
|
|
||||||
case ActivityType.TaskDueDateAdded:
|
|
||||||
message = `set this task to be due ${renderDate(getVariable(data, 'DueDate'))}`;
|
|
||||||
break;
|
|
||||||
case ActivityType.TaskDueDateRemoved:
|
|
||||||
message = `removed the due date from this task`;
|
|
||||||
break;
|
|
||||||
case ActivityType.TaskDueDateChanged:
|
|
||||||
message = `changed the due date of this task to ${renderDate(getVariable(data, 'CurDueDate'))}`;
|
|
||||||
break;
|
|
||||||
case ActivityType.TaskMarkedComplete:
|
|
||||||
message = `marked this task complete`;
|
|
||||||
break;
|
|
||||||
case ActivityType.TaskMarkedIncomplete:
|
|
||||||
message = `marked this task incomplete`;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
message = '<unknown type>';
|
|
||||||
}
|
}
|
||||||
return <>{message}</>;
|
return <h1>hello</h1>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ActivityMessage;
|
export default ActivityMessage;
|
||||||
|
@ -1,135 +0,0 @@
|
|||||||
import React, { useRef, useState, useEffect } from 'react';
|
|
||||||
import { usePopup } from 'shared/components/PopupMenu';
|
|
||||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
|
||||||
import { At, Paperclip, Smile } from 'shared/icons';
|
|
||||||
import { Picker, Emoji } from 'emoji-mart';
|
|
||||||
import Task from 'shared/icons/Task';
|
|
||||||
import {
|
|
||||||
CommentTextArea,
|
|
||||||
CommentEditorContainer,
|
|
||||||
CommentEditorActions,
|
|
||||||
CommentEditorActionIcon,
|
|
||||||
CommentEditorSaveButton,
|
|
||||||
CommentProfile,
|
|
||||||
CommentInnerWrapper,
|
|
||||||
} from './Styles';
|
|
||||||
|
|
||||||
type CommentCreatorProps = {
|
|
||||||
me?: TaskUser;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
|
|
||||||
message?: string | null;
|
|
||||||
onCreateComment: (message: string) => void;
|
|
||||||
onCancelEdit?: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CommentCreator: React.FC<CommentCreatorProps> = ({
|
|
||||||
me,
|
|
||||||
disabled = false,
|
|
||||||
message,
|
|
||||||
onMemberProfile,
|
|
||||||
onCreateComment,
|
|
||||||
onCancelEdit,
|
|
||||||
autoFocus = false,
|
|
||||||
}) => {
|
|
||||||
const $commentWrapper = useRef<HTMLDivElement>(null);
|
|
||||||
const $comment = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const $emoji = useRef<HTMLDivElement>(null);
|
|
||||||
const $emojiCart = useRef<HTMLDivElement>(null);
|
|
||||||
const [comment, setComment] = useState(message ?? '');
|
|
||||||
const [showCommentActions, setShowCommentActions] = useState(autoFocus);
|
|
||||||
const { showPopup, hidePopup } = usePopup();
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoFocus && $comment && $comment.current) {
|
|
||||||
$comment.current.select();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
useOnOutsideClick(
|
|
||||||
[$commentWrapper, $emojiCart],
|
|
||||||
showCommentActions,
|
|
||||||
() => {
|
|
||||||
if (onCancelEdit) {
|
|
||||||
onCancelEdit();
|
|
||||||
}
|
|
||||||
setShowCommentActions(false);
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<CommentInnerWrapper ref={$commentWrapper}>
|
|
||||||
{me && onMemberProfile && (
|
|
||||||
<CommentProfile
|
|
||||||
member={me}
|
|
||||||
size={32}
|
|
||||||
onMemberProfile={$target => {
|
|
||||||
onMemberProfile($target, me.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<CommentEditorContainer>
|
|
||||||
<CommentTextArea
|
|
||||||
showCommentActions={showCommentActions}
|
|
||||||
placeholder="Write a comment..."
|
|
||||||
ref={$comment}
|
|
||||||
disabled={disabled}
|
|
||||||
value={comment}
|
|
||||||
onChange={e => setComment(e.currentTarget.value)}
|
|
||||||
onFocus={() => {
|
|
||||||
setShowCommentActions(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CommentEditorActions visible={showCommentActions}>
|
|
||||||
<CommentEditorActionIcon>
|
|
||||||
<Paperclip width={12} height={12} />
|
|
||||||
</CommentEditorActionIcon>
|
|
||||||
<CommentEditorActionIcon>
|
|
||||||
<At width={12} height={12} />
|
|
||||||
</CommentEditorActionIcon>
|
|
||||||
<CommentEditorActionIcon
|
|
||||||
ref={$emoji}
|
|
||||||
onClick={() => {
|
|
||||||
showPopup(
|
|
||||||
$emoji,
|
|
||||||
<div ref={$emojiCart}>
|
|
||||||
<Picker
|
|
||||||
onClick={emoji => {
|
|
||||||
if ($comment && $comment.current) {
|
|
||||||
const textToInsert = `${emoji.colons} `;
|
|
||||||
const cursorPosition = $comment.current.selectionStart;
|
|
||||||
const textBeforeCursorPosition = $comment.current.value.substring(0, cursorPosition);
|
|
||||||
const textAfterCursorPosition = $comment.current.value.substring(
|
|
||||||
cursorPosition,
|
|
||||||
$comment.current.value.length,
|
|
||||||
);
|
|
||||||
setComment(textBeforeCursorPosition + textToInsert + textAfterCursorPosition);
|
|
||||||
}
|
|
||||||
hidePopup();
|
|
||||||
}}
|
|
||||||
set="google"
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Smile width={12} height={12} />
|
|
||||||
</CommentEditorActionIcon>
|
|
||||||
<CommentEditorActionIcon>
|
|
||||||
<Task width={12} height={12} />
|
|
||||||
</CommentEditorActionIcon>
|
|
||||||
<CommentEditorSaveButton
|
|
||||||
onClick={() => {
|
|
||||||
setShowCommentActions(false);
|
|
||||||
onCreateComment(comment);
|
|
||||||
setComment('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</CommentEditorSaveButton>
|
|
||||||
</CommentEditorActions>
|
|
||||||
</CommentEditorContainer>
|
|
||||||
</CommentInnerWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommentCreator;
|
|
@ -1,162 +0,0 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
User,
|
|
||||||
Trash,
|
|
||||||
Paperclip,
|
|
||||||
Clone,
|
|
||||||
Share,
|
|
||||||
Tags,
|
|
||||||
Checkmark,
|
|
||||||
CheckSquareOutline,
|
|
||||||
At,
|
|
||||||
Smile,
|
|
||||||
} from 'shared/icons';
|
|
||||||
import { toArray } from 'react-emoji-render';
|
|
||||||
import DOMPurify from 'dompurify';
|
|
||||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
|
||||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
|
||||||
import { usePopup } from 'shared/components/PopupMenu';
|
|
||||||
import CommentCreator from 'shared/components/TaskDetails/CommentCreator';
|
|
||||||
import { AngleDown } from 'shared/icons/AngleDown';
|
|
||||||
import Editor from 'rich-markdown-editor';
|
|
||||||
import dark from 'shared/utils/editorTheme';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import { Picker, Emoji } from 'emoji-mart';
|
|
||||||
import 'emoji-mart/css/emoji-mart.css';
|
|
||||||
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import Task from 'shared/icons/Task';
|
|
||||||
import {
|
|
||||||
ActivityItemHeader,
|
|
||||||
ActivityItemTimestamp,
|
|
||||||
ActivityItem,
|
|
||||||
ActivityItemCommentAction,
|
|
||||||
ActivityItemCommentActions,
|
|
||||||
TaskDetailLabel,
|
|
||||||
CommentContainer,
|
|
||||||
ActivityItemCommentContainer,
|
|
||||||
MetaDetailContent,
|
|
||||||
TaskDetailsAddLabelIcon,
|
|
||||||
ActionButton,
|
|
||||||
AssignUserIcon,
|
|
||||||
AssignUserLabel,
|
|
||||||
AssignUsersButton,
|
|
||||||
AssignedUsersSection,
|
|
||||||
ViewRawButton,
|
|
||||||
DueDateTitle,
|
|
||||||
Container,
|
|
||||||
LeftSidebar,
|
|
||||||
SidebarSkeleton,
|
|
||||||
ContentContainer,
|
|
||||||
LeftSidebarContent,
|
|
||||||
LeftSidebarSection,
|
|
||||||
SidebarTitle,
|
|
||||||
SidebarButton,
|
|
||||||
SidebarButtonText,
|
|
||||||
MarkCompleteButton,
|
|
||||||
HeaderContainer,
|
|
||||||
HeaderLeft,
|
|
||||||
HeaderInnerContainer,
|
|
||||||
TaskDetailsTitleWrapper,
|
|
||||||
TaskDetailsTitle,
|
|
||||||
ExtraActionsSection,
|
|
||||||
HeaderRight,
|
|
||||||
HeaderActionIcon,
|
|
||||||
EditorContainer,
|
|
||||||
InnerContentContainer,
|
|
||||||
DescriptionContainer,
|
|
||||||
Labels,
|
|
||||||
ChecklistSection,
|
|
||||||
MemberList,
|
|
||||||
TaskMember,
|
|
||||||
TabBarSection,
|
|
||||||
TabBarItem,
|
|
||||||
ActivitySection,
|
|
||||||
TaskDetailsEditor,
|
|
||||||
ActivityItemHeaderUser,
|
|
||||||
ActivityItemHeaderTitle,
|
|
||||||
ActivityItemHeaderTitleName,
|
|
||||||
ActivityItemComment,
|
|
||||||
} from './Styles';
|
|
||||||
|
|
||||||
type TaskDetailsProps = {};
|
|
||||||
|
|
||||||
const TaskDetailsLoading: React.FC<TaskDetailsProps> = () => {
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<LeftSidebar>
|
|
||||||
<LeftSidebarContent>
|
|
||||||
<LeftSidebarSection>
|
|
||||||
<SidebarTitle>TASK GROUP</SidebarTitle>
|
|
||||||
<SidebarButton loading>
|
|
||||||
<SidebarSkeleton />
|
|
||||||
</SidebarButton>
|
|
||||||
<DueDateTitle>DUE DATE</DueDateTitle>
|
|
||||||
<SidebarButton loading>
|
|
||||||
<SidebarSkeleton />
|
|
||||||
</SidebarButton>
|
|
||||||
</LeftSidebarSection>
|
|
||||||
<AssignedUsersSection>
|
|
||||||
<DueDateTitle>MEMBERS</DueDateTitle>
|
|
||||||
<SidebarButton loading>
|
|
||||||
<SidebarSkeleton />
|
|
||||||
</SidebarButton>
|
|
||||||
</AssignedUsersSection>
|
|
||||||
<ExtraActionsSection>
|
|
||||||
<DueDateTitle>ACTIONS</DueDateTitle>
|
|
||||||
<ActionButton disabled icon={<Tags width={12} height={12} />}>
|
|
||||||
Labels
|
|
||||||
</ActionButton>
|
|
||||||
<ActionButton disabled icon={<CheckSquareOutline width={12} height={12} />}>
|
|
||||||
Checklist
|
|
||||||
</ActionButton>
|
|
||||||
<ActionButton disabled>Cover</ActionButton>
|
|
||||||
</ExtraActionsSection>
|
|
||||||
</LeftSidebarContent>
|
|
||||||
</LeftSidebar>
|
|
||||||
<ContentContainer>
|
|
||||||
<HeaderContainer>
|
|
||||||
<HeaderInnerContainer>
|
|
||||||
<HeaderLeft>
|
|
||||||
<MarkCompleteButton disabled invert={false}>
|
|
||||||
<Checkmark width={8} height={8} />
|
|
||||||
<span>Mark complete</span>
|
|
||||||
</MarkCompleteButton>
|
|
||||||
</HeaderLeft>
|
|
||||||
<HeaderRight>
|
|
||||||
<HeaderActionIcon>
|
|
||||||
<Paperclip width={16} height={16} />
|
|
||||||
</HeaderActionIcon>
|
|
||||||
<HeaderActionIcon>
|
|
||||||
<Clone width={16} height={16} />
|
|
||||||
</HeaderActionIcon>
|
|
||||||
<HeaderActionIcon>
|
|
||||||
<Share width={16} height={16} />
|
|
||||||
</HeaderActionIcon>
|
|
||||||
<HeaderActionIcon>
|
|
||||||
<Trash width={16} height={16} />
|
|
||||||
</HeaderActionIcon>
|
|
||||||
</HeaderRight>
|
|
||||||
</HeaderInnerContainer>
|
|
||||||
<TaskDetailsTitleWrapper loading>
|
|
||||||
<TaskDetailsTitle value="" disabled loading />
|
|
||||||
</TaskDetailsTitleWrapper>
|
|
||||||
</HeaderContainer>
|
|
||||||
<InnerContentContainer>
|
|
||||||
<DescriptionContainer />
|
|
||||||
<TabBarSection>
|
|
||||||
<TabBarItem>Activity</TabBarItem>
|
|
||||||
</TabBarSection>
|
|
||||||
<ActivitySection />
|
|
||||||
</InnerContentContainer>
|
|
||||||
<CommentContainer>
|
|
||||||
<CommentCreator disabled onCreateComment={() => null} onMemberProfile={() => null} />
|
|
||||||
</CommentContainer>
|
|
||||||
</ContentContainer>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TaskDetailsLoading;
|
|
@ -1,9 +1,8 @@
|
|||||||
import styled, { css, keyframes } 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';
|
import { mixin } from 'shared/utils/styles';
|
||||||
import Button from 'shared/components/Button';
|
import Button from 'shared/components/Button';
|
||||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||||
import theme from 'App/ThemeStyles';
|
|
||||||
|
|
||||||
export const Container = styled.div`
|
export const Container = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -17,7 +16,7 @@ export const LeftSidebar = styled.div`
|
|||||||
background: #222740;
|
background: #222740;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const MarkCompleteButton = styled.button<{ invert: boolean; disabled?: boolean }>`
|
export const MarkCompleteButton = styled.button<{ invert: boolean }>`
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
border: none;
|
border: none;
|
||||||
@ -63,11 +62,6 @@ export const MarkCompleteButton = styled.button<{ invert: boolean; disabled?: bo
|
|||||||
color: ${props.theme.colors.success};
|
color: ${props.theme.colors.success};
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
${props =>
|
|
||||||
props.invert &&
|
|
||||||
css`
|
|
||||||
opacity: 0.6;
|
|
||||||
`}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const LeftSidebarContent = styled.div`
|
export const LeftSidebarContent = styled.div`
|
||||||
@ -95,55 +89,24 @@ export const SidebarTitle = styled.div`
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const defaultBaseColor = theme.colors.bg.primary;
|
export const SidebarButton = styled.div`
|
||||||
|
|
||||||
export const defaultHighlightColor = mixin.lighten(theme.colors.bg.primary, 0.25);
|
|
||||||
|
|
||||||
export const skeletonKeyframes = keyframes`
|
|
||||||
0% {
|
|
||||||
background-position: -200px 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: calc(200px + 100%) 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SidebarButton = styled.div<{ loading?: boolean }>`
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: ${props => props.theme.colors.text.primary};
|
color: ${props => props.theme.colors.text.primary};
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 6px;
|
|
||||||
|
|
||||||
${props =>
|
padding: 9px 8px 7px 8px;
|
||||||
props.loading
|
border-color: transparent;
|
||||||
? css`
|
border-radius: 6px;
|
||||||
background: ${props.theme.colors.bg.primary};
|
border-width: 1px;
|
||||||
`
|
border-style: solid;
|
||||||
: css`
|
|
||||||
padding: 9px 8px 7px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-color: transparent;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
&:hover {
|
|
||||||
border-color: #414561;
|
|
||||||
}
|
|
||||||
`};
|
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
`;
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
export const SidebarSkeleton = styled.div`
|
border-color: #414561;
|
||||||
background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor});
|
}
|
||||||
background-size: 200px 100%;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1px;
|
|
||||||
animation: ${skeletonKeyframes} 1.2s ease-in-out infinite;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const SidebarButtonText = styled.span`
|
export const SidebarButtonText = styled.span`
|
||||||
@ -178,18 +141,18 @@ export const HeaderLeft = styled.div`
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const TaskDetailsTitleWrapper = styled.div<{ loading?: boolean }>`
|
export const TaskDetailsTitleWrapper = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 8px 0 4px 0;
|
margin: 8px 0 4px 0;
|
||||||
display: flex;
|
display: inline-block;
|
||||||
border-radius: 6px;
|
|
||||||
${props => props.loading && `background: ${props.theme.colors.bg.primary};`}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const TaskDetailsTitle = styled(TextareaAutosize)<{ loading?: boolean }>`
|
export const TaskDetailsTitle = styled(TextareaAutosize)`
|
||||||
padding: 9px 8px 7px 8px;
|
padding: 9px 8px 7px 8px;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: #c2c6dc;
|
color: #c2c6dc;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@ -198,25 +161,13 @@ export const TaskDetailsTitle = styled(TextareaAutosize)<{ loading?: boolean }>`
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: none;
|
background: none;
|
||||||
|
|
||||||
${props =>
|
&:hover {
|
||||||
props.loading
|
border-color: #414561;
|
||||||
? css`
|
}
|
||||||
background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor});
|
|
||||||
background-size: 200px 100%;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
animation: ${skeletonKeyframes} 1.2s ease-in-out infinite;
|
|
||||||
`
|
|
||||||
: css`
|
|
||||||
&:hover {
|
|
||||||
border-color: #414561;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: ${props.theme.colors.primary};
|
border-color: ${props => props.theme.colors.primary};
|
||||||
}
|
}
|
||||||
`}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const DueDateTitle = styled.div`
|
export const DueDateTitle = styled.div`
|
||||||
@ -524,7 +475,6 @@ export const CommentEditorContainer = styled.div`
|
|||||||
border: 1px solid #414561;
|
border: 1px solid #414561;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #1f243e;
|
|
||||||
`;
|
`;
|
||||||
export const CommentProfile = styled(TaskAssignee)`
|
export const CommentProfile = styled(TaskAssignee)`
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
@ -534,7 +484,7 @@ export const CommentProfile = styled(TaskAssignee)`
|
|||||||
align-items: normal;
|
align-items: normal;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const CommentTextArea = styled(TextareaAutosize)<{ showCommentActions: boolean }>`
|
export const CommentTextArea = styled(TextareaAutosize)`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
@ -545,16 +495,14 @@ export const CommentTextArea = styled(TextareaAutosize)<{ showCommentActions: bo
|
|||||||
transition: max-height 200ms, height 200ms, min-height 200ms;
|
transition: max-height 200ms, height 200ms, min-height 200ms;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
max-height: 36px;
|
max-height: 36px;
|
||||||
${props =>
|
&:not(:focus) {
|
||||||
props.showCommentActions
|
height: 36px;
|
||||||
? css`
|
}
|
||||||
min-height: 80px;
|
&:focus {
|
||||||
max-height: none;
|
min-height: 80px;
|
||||||
line-height: 20px;
|
max-height: none;
|
||||||
`
|
line-height: 20px;
|
||||||
: css`
|
}
|
||||||
height: 36px;
|
|
||||||
`}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const CommentEditorActions = styled.div<{ visible: boolean }>`
|
export const CommentEditorActions = styled.div<{ visible: boolean }>`
|
||||||
@ -581,18 +529,6 @@ export const ActivitySection = styled.div`
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
padding: 8px 26px;
|
padding: 8px 26px;
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ActivityItemCommentAction = styled.div`
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
svg {
|
|
||||||
fill: ${props => props.theme.colors.text.primary} !important;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ActivityItem = styled.div`
|
export const ActivityItem = styled.div`
|
||||||
@ -602,20 +538,14 @@ export const ActivityItem = styled.div`
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
display: flex;
|
display: flex;
|
||||||
&:hover ${ActivityItemCommentAction} {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ActivityItemHeader = styled.div<{ editable?: boolean }>`
|
export const ActivityItemHeader = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
${props => props.editable && 'width: 100%;'}
|
|
||||||
`;
|
|
||||||
export const ActivityItemHeaderUser = styled(TaskAssignee)`
|
|
||||||
align-items: start;
|
|
||||||
`;
|
`;
|
||||||
|
export const ActivityItemHeaderUser = styled(TaskAssignee)``;
|
||||||
|
|
||||||
export const ActivityItemHeaderTitle = styled.div`
|
export const ActivityItemHeaderTitle = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -626,7 +556,7 @@ export const ActivityItemHeaderTitle = styled.div`
|
|||||||
|
|
||||||
export const ActivityItemHeaderTitleName = styled.span`
|
export const ActivityItemHeaderTitleName = styled.span`
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding-right: 3px;
|
padding-right: 2px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ActivityItemTimestamp = styled.span<{ margin: number }>`
|
export const ActivityItemTimestamp = styled.span<{ margin: number }>`
|
||||||
@ -639,10 +569,8 @@ export const ActivityItemDetails = styled.div`
|
|||||||
margin-left: 32px;
|
margin-left: 32px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ActivityItemCommentContainer = styled.div``;
|
export const ActivityItemComment = styled.div`
|
||||||
export const ActivityItemComment = styled.div<{ editable: boolean }>`
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
${mixin.boxShadowCard}
|
${mixin.boxShadowCard}
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -650,32 +578,6 @@ export const ActivityItemComment = styled.div<{ editable: boolean }>`
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
background-color: ${props => mixin.darken(props.theme.colors.alternate, 0.1)};
|
background-color: ${props => mixin.darken(props.theme.colors.alternate, 0.1)};
|
||||||
${props => props.editable && 'width: 100%;'}
|
|
||||||
|
|
||||||
& span {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
& ul {
|
|
||||||
list-style-type: disc;
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
& ul > li {
|
|
||||||
margin: 8px 8px 8px 24px;
|
|
||||||
margin-inline-start: 24px;
|
|
||||||
margin-inline-end: 8px;
|
|
||||||
}
|
|
||||||
& ul > li ul > li {
|
|
||||||
list-style: circle;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ActivityItemCommentActions = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 0;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ActivityItemLog = styled.span`
|
export const ActivityItemLog = styled.span`
|
||||||
|
@ -0,0 +1,86 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import Modal from 'shared/components/Modal';
|
||||||
|
import TaskDetails from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: TaskDetails,
|
||||||
|
title: 'TaskDetails',
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
{ name: 'gray', value: '#cdd3e1', default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<Modal
|
||||||
|
width={1040}
|
||||||
|
onClose={action('on close')}
|
||||||
|
renderContent={() => {
|
||||||
|
return (
|
||||||
|
<TaskDetails
|
||||||
|
onDeleteItem={action('delete item')}
|
||||||
|
onChangeItemName={action('change item name')}
|
||||||
|
task={{
|
||||||
|
id: '1',
|
||||||
|
taskGroup: { name: 'General', id: '1' },
|
||||||
|
name: 'Hello, world',
|
||||||
|
position: 1,
|
||||||
|
labels: [
|
||||||
|
{
|
||||||
|
id: 'soft-skills',
|
||||||
|
assignedDate: new Date().toString(),
|
||||||
|
projectLabel: {
|
||||||
|
createdDate: new Date().toString(),
|
||||||
|
id: 'label-soft-skills',
|
||||||
|
name: 'Soft Skills',
|
||||||
|
labelColor: {
|
||||||
|
id: '1',
|
||||||
|
name: 'white',
|
||||||
|
colorHex: '#fff',
|
||||||
|
position: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description,
|
||||||
|
assigned: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
profileIcon: { bgColor: null, url: null, initials: null },
|
||||||
|
fullName: 'Jordan Knott',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
onTaskNameChange={action('task name change')}
|
||||||
|
onTaskDescriptionChange={(_task, desc) => setDescription(desc)}
|
||||||
|
onDeleteTask={action('delete task')}
|
||||||
|
onCloseModal={action('close modal')}
|
||||||
|
onMemberProfile={action('profile')}
|
||||||
|
onOpenAddMemberPopup={action('open add member popup')}
|
||||||
|
onAddItem={action('add item')}
|
||||||
|
onToggleTaskComplete={action('toggle task complete')}
|
||||||
|
onToggleChecklistItem={action('toggle checklist item')}
|
||||||
|
onOpenAddLabelPopup={action('open add label popup')}
|
||||||
|
onChangeChecklistName={action('change checklist name')}
|
||||||
|
onDeleteChecklist={action('delete checklist')}
|
||||||
|
onOpenAddChecklistPopup={action(' open checklist')}
|
||||||
|
onOpenDueDatePopop={action('open due date popup')}
|
||||||
|
onChecklistDrop={action('on checklist drop')}
|
||||||
|
onChecklistItemDrop={action('on checklist item drop')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -12,31 +12,21 @@ import {
|
|||||||
At,
|
At,
|
||||||
Smile,
|
Smile,
|
||||||
} from 'shared/icons';
|
} from 'shared/icons';
|
||||||
import { toArray } from 'react-emoji-render';
|
|
||||||
import DOMPurify from 'dompurify';
|
|
||||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
|
||||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
|
||||||
import { usePopup } from 'shared/components/PopupMenu';
|
|
||||||
import CommentCreator from 'shared/components/TaskDetails/CommentCreator';
|
|
||||||
import { AngleDown } from 'shared/icons/AngleDown';
|
|
||||||
import Editor from 'rich-markdown-editor';
|
import Editor from 'rich-markdown-editor';
|
||||||
import dark from 'shared/utils/editorTheme';
|
import dark from 'shared/utils/editorTheme';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import { Picker, Emoji } from 'emoji-mart';
|
|
||||||
import 'emoji-mart/css/emoji-mart.css';
|
|
||||||
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
|
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import ActivityMessage from './ActivityMessage';
|
||||||
import Task from 'shared/icons/Task';
|
import Task from 'shared/icons/Task';
|
||||||
import {
|
import {
|
||||||
ActivityItemHeader,
|
ActivityItemHeader,
|
||||||
ActivityItemTimestamp,
|
ActivityItemTimestamp,
|
||||||
ActivityItem,
|
ActivityItem,
|
||||||
ActivityItemCommentAction,
|
|
||||||
ActivityItemCommentActions,
|
|
||||||
TaskDetailLabel,
|
TaskDetailLabel,
|
||||||
CommentContainer,
|
CommentContainer,
|
||||||
ActivityItemCommentContainer,
|
|
||||||
MetaDetailContent,
|
MetaDetailContent,
|
||||||
TaskDetailsAddLabelIcon,
|
TaskDetailsAddLabelIcon,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
@ -72,127 +62,22 @@ import {
|
|||||||
TaskMember,
|
TaskMember,
|
||||||
TabBarSection,
|
TabBarSection,
|
||||||
TabBarItem,
|
TabBarItem,
|
||||||
|
CommentTextArea,
|
||||||
|
CommentEditorContainer,
|
||||||
|
CommentEditorActions,
|
||||||
|
CommentEditorActionIcon,
|
||||||
|
CommentEditorSaveButton,
|
||||||
|
CommentProfile,
|
||||||
|
CommentInnerWrapper,
|
||||||
ActivitySection,
|
ActivitySection,
|
||||||
TaskDetailsEditor,
|
TaskDetailsEditor,
|
||||||
ActivityItemHeaderUser,
|
ActivityItemHeaderUser,
|
||||||
ActivityItemHeaderTitle,
|
ActivityItemHeaderTitle,
|
||||||
ActivityItemHeaderTitleName,
|
ActivityItemHeaderTitleName,
|
||||||
ActivityItemComment,
|
|
||||||
} from './Styles';
|
} from './Styles';
|
||||||
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
|
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
|
||||||
import onDragEnd from './onDragEnd';
|
import onDragEnd from './onDragEnd';
|
||||||
import { plugin as em } from './remark';
|
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||||
import ActivityMessage from './ActivityMessage';
|
|
||||||
|
|
||||||
const parseEmojis = (value: string) => {
|
|
||||||
const emojisArray = toArray(value);
|
|
||||||
|
|
||||||
// toArray outputs React elements for emojis and strings for other
|
|
||||||
const newValue = emojisArray.reduce((previous: any, current: any) => {
|
|
||||||
if (typeof current === 'string') {
|
|
||||||
return previous + current;
|
|
||||||
}
|
|
||||||
return previous + current.props.children;
|
|
||||||
}, '');
|
|
||||||
|
|
||||||
return newValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StreamCommentProps = {
|
|
||||||
comment?: TaskComment | null;
|
|
||||||
onUpdateComment: (message: string) => void;
|
|
||||||
onExtraActions: (commentID: string, $target: React.RefObject<HTMLElement>) => void;
|
|
||||||
onCancelCommentEdit: () => void;
|
|
||||||
editable: boolean;
|
|
||||||
};
|
|
||||||
const StreamComment: React.FC<StreamCommentProps> = ({
|
|
||||||
comment,
|
|
||||||
onExtraActions,
|
|
||||||
editable,
|
|
||||||
onUpdateComment,
|
|
||||||
onCancelCommentEdit,
|
|
||||||
}) => {
|
|
||||||
const $actions = useRef<HTMLDivElement>(null);
|
|
||||||
if (comment) {
|
|
||||||
return (
|
|
||||||
<ActivityItem>
|
|
||||||
<ActivityItemHeaderUser size={32} member={comment.createdBy} />
|
|
||||||
<ActivityItemHeader editable={editable}>
|
|
||||||
<ActivityItemHeaderTitle>
|
|
||||||
<ActivityItemHeaderTitleName>{comment.createdBy.fullName}</ActivityItemHeaderTitleName>
|
|
||||||
<ActivityItemTimestamp margin={8}>
|
|
||||||
{dayjs(comment.createdAt).format('MMM D [at] h:mm A')}
|
|
||||||
{comment.updatedAt && ' (edited)'}
|
|
||||||
</ActivityItemTimestamp>
|
|
||||||
</ActivityItemHeaderTitle>
|
|
||||||
<ActivityItemCommentContainer>
|
|
||||||
<ActivityItemComment editable={editable}>
|
|
||||||
{editable ? (
|
|
||||||
<CommentCreator
|
|
||||||
message={comment.message}
|
|
||||||
autoFocus
|
|
||||||
onCancelEdit={onCancelCommentEdit}
|
|
||||||
onCreateComment={onUpdateComment}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ReactMarkdown escapeHtml={false} plugins={[em]}>
|
|
||||||
{DOMPurify.sanitize(comment.message, { FORBID_TAGS: ['style', 'img'] })}
|
|
||||||
</ReactMarkdown>
|
|
||||||
)}
|
|
||||||
</ActivityItemComment>
|
|
||||||
<ActivityItemCommentActions>
|
|
||||||
<ActivityItemCommentAction
|
|
||||||
ref={$actions}
|
|
||||||
onClick={() => {
|
|
||||||
onExtraActions(comment.id, $actions);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AngleDown width={18} height={18} />
|
|
||||||
</ActivityItemCommentAction>
|
|
||||||
</ActivityItemCommentActions>
|
|
||||||
</ActivityItemCommentContainer>
|
|
||||||
</ActivityItemHeader>
|
|
||||||
</ActivityItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StreamActivityProps = {
|
|
||||||
activity?: TaskActivity | null;
|
|
||||||
};
|
|
||||||
const StreamActivity: React.FC<StreamActivityProps> = ({ activity }) => {
|
|
||||||
if (activity) {
|
|
||||||
return (
|
|
||||||
<ActivityItem>
|
|
||||||
<ActivityItemHeaderUser
|
|
||||||
size={32}
|
|
||||||
member={{
|
|
||||||
id: activity.causedBy.id,
|
|
||||||
fullName: activity.causedBy.fullName,
|
|
||||||
profileIcon: activity.causedBy.profileIcon
|
|
||||||
? activity.causedBy.profileIcon
|
|
||||||
: {
|
|
||||||
url: null,
|
|
||||||
initials: activity.causedBy.fullName.charAt(0),
|
|
||||||
bgColor: '#fff',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ActivityItemHeader>
|
|
||||||
<ActivityItemHeaderTitle>
|
|
||||||
<ActivityItemHeaderTitleName>{activity.causedBy.fullName}</ActivityItemHeaderTitleName>
|
|
||||||
<ActivityMessage type={activity.type} data={activity.data} />
|
|
||||||
</ActivityItemHeaderTitle>
|
|
||||||
<ActivityItemTimestamp margin={0}>
|
|
||||||
{dayjs(activity.createdAt).format('MMM D [at] h:mm A')}
|
|
||||||
</ActivityItemTimestamp>
|
|
||||||
</ActivityItemHeader>
|
|
||||||
</ActivityItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ChecklistContainer = styled.div``;
|
const ChecklistContainer = styled.div``;
|
||||||
|
|
||||||
@ -237,13 +122,8 @@ type TaskDetailsProps = {
|
|||||||
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||||
onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||||
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||||
onCreateComment: (task: Task, message: string) => void;
|
|
||||||
onCommentShowActions: (commentID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
|
||||||
onMemberProfile: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
|
onMemberProfile: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
|
||||||
onCancelCommentEdit: () => void;
|
|
||||||
onUpdateComment: (commentID: string, message: string) => void;
|
|
||||||
onChangeChecklistName: (checklistID: string, name: string) => void;
|
onChangeChecklistName: (checklistID: string, name: string) => void;
|
||||||
editableComment?: string | null;
|
|
||||||
onDeleteChecklist: ($target: React.RefObject<HTMLElement>, checklistID: string) => void;
|
onDeleteChecklist: ($target: React.RefObject<HTMLElement>, checklistID: string) => void;
|
||||||
onCloseModal: () => void;
|
onCloseModal: () => void;
|
||||||
onChecklistDrop: (checklist: TaskChecklist) => void;
|
onChecklistDrop: (checklist: TaskChecklist) => void;
|
||||||
@ -252,15 +132,11 @@ type TaskDetailsProps = {
|
|||||||
|
|
||||||
const TaskDetails: React.FC<TaskDetailsProps> = ({
|
const TaskDetails: React.FC<TaskDetailsProps> = ({
|
||||||
me,
|
me,
|
||||||
onCancelCommentEdit,
|
|
||||||
task,
|
task,
|
||||||
editableComment = null,
|
|
||||||
onDeleteChecklist,
|
onDeleteChecklist,
|
||||||
onTaskNameChange,
|
onTaskNameChange,
|
||||||
onCommentShowActions,
|
|
||||||
onOpenAddChecklistPopup,
|
onOpenAddChecklistPopup,
|
||||||
onChangeChecklistName,
|
onChangeChecklistName,
|
||||||
onCreateComment,
|
|
||||||
onChecklistDrop,
|
onChecklistDrop,
|
||||||
onChecklistItemDrop,
|
onChecklistItemDrop,
|
||||||
onToggleTaskComplete,
|
onToggleTaskComplete,
|
||||||
@ -269,7 +145,6 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
|||||||
onDeleteItem,
|
onDeleteItem,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
onCloseModal,
|
onCloseModal,
|
||||||
onUpdateComment,
|
|
||||||
onOpenAddMemberPopup,
|
onOpenAddMemberPopup,
|
||||||
onOpenAddLabelPopup,
|
onOpenAddLabelPopup,
|
||||||
onOpenDueDatePopop,
|
onOpenDueDatePopop,
|
||||||
@ -289,38 +164,11 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
|||||||
});
|
});
|
||||||
const [saveTimeout, setSaveTimeout] = useState<any>(null);
|
const [saveTimeout, setSaveTimeout] = useState<any>(null);
|
||||||
const [showRaw, setShowRaw] = useState(false);
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
|
const [showCommentActions, setShowCommentActions] = useState(false);
|
||||||
const taskDescriptionRef = useRef(task.description ?? '');
|
const taskDescriptionRef = useRef(task.description ?? '');
|
||||||
const $noMemberBtn = useRef<HTMLDivElement>(null);
|
const $noMemberBtn = useRef<HTMLDivElement>(null);
|
||||||
const $addMemberBtn = useRef<HTMLDivElement>(null);
|
const $addMemberBtn = useRef<HTMLDivElement>(null);
|
||||||
const $dueDateBtn = useRef<HTMLDivElement>(null);
|
const $dueDateBtn = useRef<HTMLDivElement>(null);
|
||||||
const $detailsTitle = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
const activityStream: Array<{ id: string; data: { time: string; type: 'comment' | 'activity' } }> = [];
|
|
||||||
|
|
||||||
if (task.activity) {
|
|
||||||
task.activity.forEach(activity => {
|
|
||||||
activityStream.push({
|
|
||||||
id: activity.id,
|
|
||||||
data: {
|
|
||||||
time: activity.createdAt,
|
|
||||||
type: 'activity',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.comments) {
|
|
||||||
task.comments.forEach(comment => {
|
|
||||||
activityStream.push({
|
|
||||||
id: comment.id,
|
|
||||||
data: {
|
|
||||||
time: comment.createdAt,
|
|
||||||
type: 'comment',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
activityStream.sort((a, b) => (dayjs(a.data.time).isAfter(dayjs(b.data.time)) ? 1 : -1));
|
|
||||||
|
|
||||||
const saveDescription = () => {
|
const saveDescription = () => {
|
||||||
onTaskDescriptionChange(task, taskDescriptionRef.current);
|
onTaskDescriptionChange(task, taskDescriptionRef.current);
|
||||||
@ -342,9 +190,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{task.dueDate ? (
|
{task.dueDate ? (
|
||||||
<SidebarButtonText>
|
<SidebarButtonText>{dayjs(task.dueDate).format('MMM D [at] h:mm A')}</SidebarButtonText>
|
||||||
{dayjs(task.dueDate).format(task.hasTime ? 'MMM D [at] h:mm A' : 'MMMM D')}
|
|
||||||
</SidebarButtonText>
|
|
||||||
) : (
|
) : (
|
||||||
<SidebarButtonText>No due date</SidebarButtonText>
|
<SidebarButtonText>No due date</SidebarButtonText>
|
||||||
)}
|
)}
|
||||||
@ -441,15 +287,6 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
|||||||
<TaskDetailsTitleWrapper>
|
<TaskDetailsTitleWrapper>
|
||||||
<TaskDetailsTitle
|
<TaskDetailsTitle
|
||||||
value={taskName}
|
value={taskName}
|
||||||
ref={$detailsTitle}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.keyCode === 13) {
|
|
||||||
e.preventDefault();
|
|
||||||
if ($detailsTitle && $detailsTitle.current) {
|
|
||||||
$detailsTitle.current.blur();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setTaskName(e.currentTarget.value);
|
setTaskName(e.currentTarget.value);
|
||||||
}}
|
}}
|
||||||
@ -597,28 +434,74 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
|||||||
<TabBarItem>Activity</TabBarItem>
|
<TabBarItem>Activity</TabBarItem>
|
||||||
</TabBarSection>
|
</TabBarSection>
|
||||||
<ActivitySection>
|
<ActivitySection>
|
||||||
{activityStream.map(stream =>
|
{task.activity &&
|
||||||
stream.data.type === 'comment' ? (
|
task.activity.map(activity => (
|
||||||
<StreamComment
|
<ActivityItem>
|
||||||
onExtraActions={onCommentShowActions}
|
<ActivityItemHeaderUser
|
||||||
onCancelCommentEdit={onCancelCommentEdit}
|
size={32}
|
||||||
onUpdateComment={message => onUpdateComment(stream.id, message)}
|
member={{
|
||||||
editable={stream.id === editableComment}
|
id: activity.causedBy.id,
|
||||||
comment={task.comments && task.comments.find(comment => comment.id === stream.id)}
|
fullName: activity.causedBy.fullName,
|
||||||
/>
|
profileIcon: activity.causedBy.profileIcon
|
||||||
) : (
|
? activity.causedBy.profileIcon
|
||||||
<StreamActivity activity={task.activity && task.activity.find(activity => activity.id === stream.id)} />
|
: {
|
||||||
),
|
url: null,
|
||||||
)}
|
initials: activity.causedBy.fullName.charAt(0),
|
||||||
|
bgColor: '#fff',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActivityItemHeader>
|
||||||
|
<ActivityItemHeaderTitle>
|
||||||
|
<ActivityItemHeaderTitleName>{activity.causedBy.fullName}</ActivityItemHeaderTitleName>
|
||||||
|
<ActivityMessage type={activity.type} data={activity.data} />
|
||||||
|
</ActivityItemHeaderTitle>
|
||||||
|
<ActivityItemTimestamp margin={0}>
|
||||||
|
{dayjs(activity.createdAt).format('MMM D [at] h:mm A')}
|
||||||
|
</ActivityItemTimestamp>
|
||||||
|
</ActivityItemHeader>
|
||||||
|
</ActivityItem>
|
||||||
|
))}
|
||||||
</ActivitySection>
|
</ActivitySection>
|
||||||
</InnerContentContainer>
|
</InnerContentContainer>
|
||||||
<CommentContainer>
|
<CommentContainer>
|
||||||
{me && (
|
{me && (
|
||||||
<CommentCreator
|
<CommentInnerWrapper>
|
||||||
me={me}
|
<CommentProfile
|
||||||
onCreateComment={message => onCreateComment(task, message)}
|
member={me}
|
||||||
onMemberProfile={onMemberProfile}
|
size={32}
|
||||||
/>
|
onMemberProfile={$target => {
|
||||||
|
onMemberProfile($target, me.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CommentEditorContainer>
|
||||||
|
<CommentTextArea
|
||||||
|
disabled
|
||||||
|
placeholder="Write a comment..."
|
||||||
|
onFocus={() => {
|
||||||
|
setShowCommentActions(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setShowCommentActions(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CommentEditorActions visible={showCommentActions}>
|
||||||
|
<CommentEditorActionIcon>
|
||||||
|
<Paperclip width={12} height={12} />
|
||||||
|
</CommentEditorActionIcon>
|
||||||
|
<CommentEditorActionIcon>
|
||||||
|
<At width={12} height={12} />
|
||||||
|
</CommentEditorActionIcon>
|
||||||
|
<CommentEditorActionIcon>
|
||||||
|
<Smile width={12} height={12} />
|
||||||
|
</CommentEditorActionIcon>
|
||||||
|
<CommentEditorActionIcon>
|
||||||
|
<Task width={12} height={12} />
|
||||||
|
</CommentEditorActionIcon>
|
||||||
|
<CommentEditorSaveButton>Save</CommentEditorSaveButton>
|
||||||
|
</CommentEditorActions>
|
||||||
|
</CommentEditorContainer>
|
||||||
|
</CommentInnerWrapper>
|
||||||
)}
|
)}
|
||||||
</CommentContainer>
|
</CommentContainer>
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
|
@ -1,68 +0,0 @@
|
|||||||
import visit from 'unist-util-visit';
|
|
||||||
import emoji from 'node-emoji';
|
|
||||||
import emoticon from 'emoticon';
|
|
||||||
import { Emoji } from 'emoji-mart';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import ReactDOMServer from 'react-dom/server';
|
|
||||||
|
|
||||||
const RE_EMOJI = /:\+1:|:-1:|:[\w-]+:/g;
|
|
||||||
const RE_SHORT = /[$@|*'",;.=:\-)([\]\\/<>038BOopPsSdDxXzZ]{2,5}/g;
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
|
||||||
padSpaceAfter: false,
|
|
||||||
emoticon: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function plugin(options) {
|
|
||||||
const settings = Object.assign({}, DEFAULT_SETTINGS, options);
|
|
||||||
const pad = !!settings.padSpaceAfter;
|
|
||||||
const emoticonEnable = !!settings.emoticon;
|
|
||||||
|
|
||||||
function getEmojiByShortCode(match) {
|
|
||||||
// find emoji by shortcode - full match or with-out last char as it could be from text e.g. :-),
|
|
||||||
const iconFull = emoticon.find(e => e.emoticons.includes(match)); // full match
|
|
||||||
const iconPart = emoticon.find(e => e.emoticons.includes(match.slice(0, -1))); // second search pattern
|
|
||||||
const trimmedChar = iconPart ? match.slice(-1) : '';
|
|
||||||
const addPad = pad ? ' ' : '';
|
|
||||||
let icon = iconFull ? iconFull.emoji + addPad : iconPart && iconPart.emoji + addPad + trimmedChar;
|
|
||||||
return icon || match;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEmoji(match) {
|
|
||||||
console.log(match);
|
|
||||||
const got = emoji.get(match);
|
|
||||||
if (pad && got !== match) {
|
|
||||||
return got + ' ';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(got);
|
|
||||||
return ReactDOMServer.renderToStaticMarkup(<Emoji set="google" emoji={match} size={16} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformer(tree) {
|
|
||||||
visit(tree, 'paragraph', function(node) {
|
|
||||||
console.log(tree);
|
|
||||||
// node.value = node.value.replace(RE_EMOJI, getEmoji);
|
|
||||||
// jnode.type = 'html';
|
|
||||||
// jnode.tagName = 'div';
|
|
||||||
// jnode.value = '';
|
|
||||||
for (let nodeIdx = 0; nodeIdx < node.children.length; nodeIdx++) {
|
|
||||||
if (node.children[nodeIdx].type === 'text') {
|
|
||||||
node.children[nodeIdx].type = 'html';
|
|
||||||
node.children[nodeIdx].tagName = 'div';
|
|
||||||
node.children[nodeIdx].value = node.children[nodeIdx].value.replace(RE_EMOJI, getEmoji);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emoticonEnable) {
|
|
||||||
// node.value = node.value.replace(RE_SHORT, getEmojiByShortCode);
|
|
||||||
}
|
|
||||||
console.log(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return transformer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { plugin };
|
|
@ -1,9 +1,11 @@
|
|||||||
import styled, { css } from 'styled-components';
|
import styled, { css } from 'styled-components';
|
||||||
|
import TextareaAutosize from 'react-autosize-textarea';
|
||||||
import { mixin } from 'shared/utils/styles';
|
import { mixin } from 'shared/utils/styles';
|
||||||
import Button from 'shared/components/Button';
|
import Button from 'shared/components/Button';
|
||||||
import { Taskcafe } from 'shared/icons';
|
import { Taskcafe } from 'shared/icons';
|
||||||
import { NavLink, Link } from 'react-router-dom';
|
import { NavLink, Link } from 'react-router-dom';
|
||||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
export const ProjectMember = styled(TaskAssignee)<{ zIndex: number }>`
|
export const ProjectMember = styled(TaskAssignee)<{ zIndex: number }>`
|
||||||
z-index: ${props => props.zIndex};
|
z-index: ${props => props.zIndex};
|
||||||
@ -41,51 +43,12 @@ export const BreadcrumpSeparator = styled.span`
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
margin: 0px 10px;
|
margin: 0px 10px;
|
||||||
`;
|
`;
|
||||||
export const ProjectInfo = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ProjectSwitchInner = styled.div`
|
|
||||||
border-radius: 12px;
|
|
||||||
height: 48px;
|
|
||||||
width: 48px;
|
|
||||||
box-shadow: inset 0 -2px rgba(0, 0, 0, 0.05);
|
|
||||||
align-items: center;
|
|
||||||
background: #cbd4db;
|
|
||||||
display: flex;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
background-color: ${props => props.theme.colors.primary};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ProjectSwitch = styled.div`
|
|
||||||
align-self: center;
|
|
||||||
position: relative;
|
|
||||||
margin-right: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
&::after {
|
|
||||||
border-radius: 12px;
|
|
||||||
bottom: 0;
|
|
||||||
content: '';
|
|
||||||
left: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
&:hover::after {
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ProjectActions = styled.div`
|
export const ProjectActions = styled.div`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-width: 1px;
|
min-width: 1px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -104,11 +67,6 @@ export const ProfileNameWrapper = styled.div`
|
|||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const NavbarLink = styled(Link)`
|
|
||||||
margin-right: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const IconContainerWrapper = styled.div<{ disabled?: boolean }>`
|
export const IconContainerWrapper = styled.div<{ disabled?: boolean }>`
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -205,20 +163,7 @@ export const ProjectName = styled.h1`
|
|||||||
padding: 3px 10px 3px 8px;
|
padding: 3px 10px 3px 8px;
|
||||||
margin: -4px 0;
|
margin: -4px 0;
|
||||||
`;
|
`;
|
||||||
|
export const ProjectNameTextarea = styled(TextareaAutosize)`
|
||||||
export const ProjectNameWrapper = styled.div`
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
export const ProjectNameSpan = styled.div`
|
|
||||||
padding: 3px 10px 3px 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 20px;
|
|
||||||
`;
|
|
||||||
export const ProjectNameTextarea = styled.input`
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
border: none;
|
border: none;
|
||||||
resize: none;
|
resize: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -226,7 +171,7 @@ export const ProjectNameTextarea = styled.input`
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
margin: 0;
|
margin: -4px 0;
|
||||||
|
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
word-spacing: normal;
|
word-spacing: normal;
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import NormalizeStyles from 'App/NormalizeStyles';
|
||||||
|
import BaseStyles from 'App/BaseStyles';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import TopNavbar from '.';
|
||||||
|
import theme from '../../../App/ThemeStyles';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: TopNavbar,
|
||||||
|
title: 'TopNavbar',
|
||||||
|
|
||||||
|
// Our exports that end in "Data" are not stories.
|
||||||
|
excludeStories: /.*Data$/,
|
||||||
|
parameters: {
|
||||||
|
backgrounds: [
|
||||||
|
{ name: 'white', value: '#ffffff' },
|
||||||
|
{ name: 'gray', value: '#f8f8f8' },
|
||||||
|
{ name: 'darkBlue', value: theme.colors.bg.secondary, default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NormalizeStyles />
|
||||||
|
<BaseStyles />
|
||||||
|
<TopNavbar
|
||||||
|
onOpenProjectFinder={action('finder')}
|
||||||
|
name="Projects"
|
||||||
|
user={{
|
||||||
|
id: '1',
|
||||||
|
fullName: 'Jordan Knott',
|
||||||
|
profileIcon: {
|
||||||
|
url: null,
|
||||||
|
initials: 'JK',
|
||||||
|
bgColor: '#000',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onChangeRole={action('change role')}
|
||||||
|
onNotificationClick={action('notifications click')}
|
||||||
|
onOpenSettings={action('open settings')}
|
||||||
|
onDashboardClick={action('open dashboard')}
|
||||||
|
onRemoveFromBoard={action('remove project')}
|
||||||
|
onProfileClick={action('profile click')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -7,17 +7,12 @@ import { RoleCode } from 'shared/generated/graphql';
|
|||||||
import NOOP from 'shared/utils/noop';
|
import NOOP from 'shared/utils/noop';
|
||||||
import { useHistory } from 'react-router';
|
import { useHistory } from 'react-router';
|
||||||
import {
|
import {
|
||||||
ProjectInfo,
|
|
||||||
NavbarLink,
|
|
||||||
TaskcafeLogo,
|
TaskcafeLogo,
|
||||||
TaskcafeTitle,
|
TaskcafeTitle,
|
||||||
ProjectFinder,
|
ProjectFinder,
|
||||||
LogoContainer,
|
LogoContainer,
|
||||||
NavSeparator,
|
NavSeparator,
|
||||||
IconContainerWrapper,
|
IconContainerWrapper,
|
||||||
ProjectSwitch,
|
|
||||||
ProjectNameWrapper,
|
|
||||||
ProjectNameSpan,
|
|
||||||
ProjectNameTextarea,
|
ProjectNameTextarea,
|
||||||
InviteButton,
|
InviteButton,
|
||||||
GlobalActions,
|
GlobalActions,
|
||||||
@ -35,7 +30,6 @@ import {
|
|||||||
ProfileNameSecondary,
|
ProfileNameSecondary,
|
||||||
ProjectMember,
|
ProjectMember,
|
||||||
ProjectMembers,
|
ProjectMembers,
|
||||||
ProjectSwitchInner,
|
|
||||||
} from './Styles';
|
} from './Styles';
|
||||||
|
|
||||||
type IconContainerProps = {
|
type IconContainerProps = {
|
||||||
@ -79,7 +73,7 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isEditProjectName, setEditProjectName] = useState(false);
|
const [isEditProjectName, setEditProjectName] = useState(false);
|
||||||
const [projectName, setProjectName] = useState(initialProjectName);
|
const [projectName, setProjectName] = useState(initialProjectName);
|
||||||
const $projectName = useRef<HTMLInputElement>(null);
|
const $projectName = useRef<HTMLTextAreaElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditProjectName && $projectName && $projectName.current) {
|
if (isEditProjectName && $projectName && $projectName.current) {
|
||||||
$projectName.current.focus();
|
$projectName.current.focus();
|
||||||
@ -90,7 +84,7 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
|
|||||||
setProjectName(initialProjectName);
|
setProjectName(initialProjectName);
|
||||||
}, [initialProjectName]);
|
}, [initialProjectName]);
|
||||||
|
|
||||||
const onProjectNameChange = (event: React.FormEvent<HTMLInputElement>): void => {
|
const onProjectNameChange = (event: React.FormEvent<HTMLTextAreaElement>): void => {
|
||||||
setProjectName(event.currentTarget.value);
|
setProjectName(event.currentTarget.value);
|
||||||
};
|
};
|
||||||
const onProjectNameBlur = () => {
|
const onProjectNameBlur = () => {
|
||||||
@ -112,17 +106,14 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isEditProjectName ? (
|
{isEditProjectName ? (
|
||||||
<ProjectNameWrapper>
|
<ProjectNameTextarea
|
||||||
<ProjectNameSpan>{projectName}</ProjectNameSpan>
|
ref={$projectName}
|
||||||
<ProjectNameTextarea
|
onChange={onProjectNameChange}
|
||||||
ref={$projectName}
|
onKeyDown={onProjectNameKeyDown}
|
||||||
onChange={onProjectNameChange}
|
onBlur={onProjectNameBlur}
|
||||||
onKeyDown={onProjectNameKeyDown}
|
spellCheck={false}
|
||||||
onBlur={onProjectNameBlur}
|
value={projectName}
|
||||||
spellCheck={false}
|
/>
|
||||||
value={projectName}
|
|
||||||
/>
|
|
||||||
</ProjectNameWrapper>
|
|
||||||
) : (
|
) : (
|
||||||
<ProjectName
|
<ProjectName
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -188,7 +179,6 @@ type NavBarProps = {
|
|||||||
onRemoveFromBoard?: (userID: string) => void;
|
onRemoveFromBoard?: (userID: string) => void;
|
||||||
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
|
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
|
||||||
onInvitedMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, email: string) => void;
|
onInvitedMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, email: string) => void;
|
||||||
onMyTasksClick: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const NavBar: React.FC<NavBarProps> = ({
|
const NavBar: React.FC<NavBarProps> = ({
|
||||||
@ -211,7 +201,6 @@ const NavBar: React.FC<NavBarProps> = ({
|
|||||||
onProfileClick,
|
onProfileClick,
|
||||||
onNotificationClick,
|
onNotificationClick,
|
||||||
onDashboardClick,
|
onDashboardClick,
|
||||||
onMyTasksClick,
|
|
||||||
user,
|
user,
|
||||||
projectMembers,
|
projectMembers,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
@ -223,50 +212,43 @@ const NavBar: React.FC<NavBarProps> = ({
|
|||||||
};
|
};
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const $finder = useRef<HTMLDivElement>(null);
|
|
||||||
return (
|
return (
|
||||||
<NavbarWrapper>
|
<NavbarWrapper>
|
||||||
<NavbarHeader>
|
<NavbarHeader>
|
||||||
<ProjectActions>
|
<ProjectActions>
|
||||||
<ProjectSwitch ref={$finder} onClick={e => onOpenProjectFinder($finder)}>
|
<ProjectMeta>
|
||||||
<ProjectSwitchInner>
|
|
||||||
<TaskcafeLogo innerColor="#9f46e4" outerColor="#000" width={32} height={32} />
|
|
||||||
</ProjectSwitchInner>
|
|
||||||
</ProjectSwitch>
|
|
||||||
<ProjectInfo>
|
|
||||||
<ProjectMeta>
|
|
||||||
{name && (
|
|
||||||
<ProjectHeading
|
|
||||||
onFavorite={onFavorite}
|
|
||||||
onOpenSettings={onOpenSettings}
|
|
||||||
name={name}
|
|
||||||
canEditProjectName={canEditProjectName}
|
|
||||||
onSaveProjectName={onSaveName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ProjectMeta>
|
|
||||||
{name && (
|
{name && (
|
||||||
<ProjectTabs>
|
<ProjectHeading
|
||||||
{menuType &&
|
onFavorite={onFavorite}
|
||||||
menuType.map((menu, idx) => {
|
onOpenSettings={onOpenSettings}
|
||||||
return (
|
name={name}
|
||||||
<ProjectTab
|
canEditProjectName={canEditProjectName}
|
||||||
key={menu.name}
|
onSaveProjectName={onSaveName}
|
||||||
to={menu.link}
|
/>
|
||||||
exact
|
|
||||||
onClick={() => {
|
|
||||||
// TODO
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{menu.name}
|
|
||||||
</ProjectTab>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ProjectTabs>
|
|
||||||
)}
|
)}
|
||||||
</ProjectInfo>
|
</ProjectMeta>
|
||||||
|
{name && (
|
||||||
|
<ProjectTabs>
|
||||||
|
{menuType &&
|
||||||
|
menuType.map((menu, idx) => {
|
||||||
|
return (
|
||||||
|
<ProjectTab
|
||||||
|
key={menu.name}
|
||||||
|
to={menu.link}
|
||||||
|
exact
|
||||||
|
onClick={() => {
|
||||||
|
// TODO
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menu.name}
|
||||||
|
</ProjectTab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ProjectTabs>
|
||||||
|
)}
|
||||||
</ProjectActions>
|
</ProjectActions>
|
||||||
<LogoContainer to="/">
|
<LogoContainer to="/">
|
||||||
|
<TaskcafeLogo width={32} height={32} />
|
||||||
<TaskcafeTitle>Taskcafé</TaskcafeTitle>
|
<TaskcafeTitle>Taskcafé</TaskcafeTitle>
|
||||||
</LogoContainer>
|
</LogoContainer>
|
||||||
<GlobalActions>
|
<GlobalActions>
|
||||||
@ -321,12 +303,12 @@ const NavBar: React.FC<NavBarProps> = ({
|
|||||||
<ProjectFinder onClick={onOpenProjectFinder} variant="gradient">
|
<ProjectFinder onClick={onOpenProjectFinder} variant="gradient">
|
||||||
Projects
|
Projects
|
||||||
</ProjectFinder>
|
</ProjectFinder>
|
||||||
<NavbarLink to="">
|
<IconContainer onClick={() => onDashboardClick()}>
|
||||||
<HomeDashboard width={20} height={20} />
|
<HomeDashboard width={20} height={20} />
|
||||||
</NavbarLink>
|
</IconContainer>
|
||||||
<NavbarLink to="/tasks">
|
<IconContainer disabled onClick={NOOP}>
|
||||||
<CheckCircle width={20} height={20} />
|
<CheckCircle width={20} height={20} />
|
||||||
</NavbarLink>
|
</IconContainer>
|
||||||
<IconContainer disabled onClick={NOOP}>
|
<IconContainer disabled onClick={NOOP}>
|
||||||
<ListUnordered width={20} height={20} />
|
<ListUnordered width={20} height={20} />
|
||||||
</IconContainer>
|
</IconContainer>
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -6,27 +6,10 @@ query findTask($taskID: UUID!) {
|
|||||||
dueDate
|
dueDate
|
||||||
position
|
position
|
||||||
complete
|
complete
|
||||||
hasTime
|
|
||||||
taskGroup {
|
taskGroup {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
comments {
|
|
||||||
id
|
|
||||||
pinned
|
|
||||||
message
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
createdBy {
|
|
||||||
id
|
|
||||||
fullName
|
|
||||||
profileIcon {
|
|
||||||
initials
|
|
||||||
bgColor
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activity {
|
activity {
|
||||||
id
|
id
|
||||||
type
|
type
|
||||||
|
@ -6,7 +6,6 @@ const TASK_FRAGMENT = gql`
|
|||||||
name
|
name
|
||||||
description
|
description
|
||||||
dueDate
|
dueDate
|
||||||
hasTime
|
|
||||||
complete
|
complete
|
||||||
completedAt
|
completedAt
|
||||||
position
|
position
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
query myTasks($status: MyTasksStatus!, $sort: MyTasksSort!) {
|
|
||||||
projects {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
myTasks(input: { status: $status, sort: $sort }) {
|
|
||||||
tasks {
|
|
||||||
id
|
|
||||||
taskGroup {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
name
|
|
||||||
dueDate
|
|
||||||
hasTime
|
|
||||||
complete
|
|
||||||
completedAt
|
|
||||||
}
|
|
||||||
projects {
|
|
||||||
projectID
|
|
||||||
taskID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,8 +2,8 @@ import gql from 'graphql-tag';
|
|||||||
import TASK_FRAGMENT from '../fragments/task';
|
import TASK_FRAGMENT from '../fragments/task';
|
||||||
|
|
||||||
const CREATE_TASK_MUTATION = gql`
|
const CREATE_TASK_MUTATION = gql`
|
||||||
mutation createTask($taskGroupID: UUID!, $name: String!, $position: Float!, $assigned: [UUID!]) {
|
mutation createTask($taskGroupID: UUID!, $name: String!, $position: Float!) {
|
||||||
createTask(input: { taskGroupID: $taskGroupID, name: $name, position: $position, assigned: $assigned }) {
|
createTask(input: { taskGroupID: $taskGroupID, name: $name, position: $position }) {
|
||||||
...TaskFields
|
...TaskFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,27 +0,0 @@
|
|||||||
import gql from 'graphql-tag';
|
|
||||||
|
|
||||||
const CREATE_TASK_MUTATION = gql`
|
|
||||||
mutation createTaskComment($taskID: UUID!, $message: String!) {
|
|
||||||
createTaskComment(input: { taskID: $taskID, message: $message }) {
|
|
||||||
taskID
|
|
||||||
comment {
|
|
||||||
id
|
|
||||||
message
|
|
||||||
pinned
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
createdBy {
|
|
||||||
id
|
|
||||||
fullName
|
|
||||||
profileIcon {
|
|
||||||
initials
|
|
||||||
bgColor
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default CREATE_TASK_MUTATION;
|
|
@ -1,11 +0,0 @@
|
|||||||
import gql from 'graphql-tag';
|
|
||||||
|
|
||||||
const CREATE_TASK_MUTATION = gql`
|
|
||||||
mutation deleteTaskComment($commentID: UUID!) {
|
|
||||||
deleteTaskComment(input: { commentID: $commentID }) {
|
|
||||||
commentID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default CREATE_TASK_MUTATION;
|
|
@ -1,15 +0,0 @@
|
|||||||
import gql from 'graphql-tag';
|
|
||||||
|
|
||||||
const CREATE_TASK_MUTATION = gql`
|
|
||||||
mutation updateTaskComment($commentID: UUID!, $message: String!) {
|
|
||||||
updateTaskComment(input: { commentID: $commentID, message: $message }) {
|
|
||||||
comment {
|
|
||||||
id
|
|
||||||
updatedAt
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default CREATE_TASK_MUTATION;
|
|
@ -1,13 +1,11 @@
|
|||||||
mutation updateTaskDueDate($taskID: UUID!, $dueDate: Time, $hasTime: Boolean!) {
|
mutation updateTaskDueDate($taskID: UUID!, $dueDate: Time) {
|
||||||
updateTaskDueDate (
|
updateTaskDueDate (
|
||||||
input: {
|
input: {
|
||||||
taskID: $taskID
|
taskID: $taskID
|
||||||
dueDate: $dueDate
|
dueDate: $dueDate
|
||||||
hasTime: $hasTime
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
id
|
id
|
||||||
dueDate
|
dueDate
|
||||||
hasTime
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,14 +16,10 @@ const useOnOutsideClick = (
|
|||||||
|
|
||||||
const handleMouseUp = (event: any) => {
|
const handleMouseUp = (event: any) => {
|
||||||
if (typeof $ignoredElementRefsMemoized !== 'undefined') {
|
if (typeof $ignoredElementRefsMemoized !== 'undefined') {
|
||||||
const isAnyIgnoredElementAncestorOfTarget = $ignoredElementRefsMemoized.some(($elementRef: any) => {
|
const isAnyIgnoredElementAncestorOfTarget = $ignoredElementRefsMemoized.some(
|
||||||
if ($elementRef && $elementRef.current) {
|
($elementRef: any) =>
|
||||||
return (
|
$elementRef.current.contains($mouseDownTargetRef.current) || $elementRef.current.contains(event.target),
|
||||||
$elementRef.current.contains($mouseDownTargetRef.current) || $elementRef.current.contains(event.target)
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
if (event.button === 0 && !isAnyIgnoredElementAncestorOfTarget) {
|
if (event.button === 0 && !isAnyIgnoredElementAncestorOfTarget) {
|
||||||
onOutsideClick();
|
onOutsideClick();
|
||||||
}
|
}
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
function useStickyState<T>(defaultValue: any, key: string): [T, React.Dispatch<React.SetStateAction<T>>] {
|
|
||||||
const [value, setValue] = React.useState<T>(() => {
|
|
||||||
const stickyValue = window.localStorage.getItem(key);
|
|
||||||
return stickyValue !== null ? JSON.parse(stickyValue) : defaultValue;
|
|
||||||
});
|
|
||||||
React.useEffect(() => {
|
|
||||||
window.localStorage.setItem(key, JSON.stringify(value));
|
|
||||||
}, [key, value]);
|
|
||||||
return [value, setValue];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useStickyState;
|
|
@ -1,21 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Icon, { IconProps } from './Icon';
|
|
||||||
|
|
||||||
export const AngleDown: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
|
|
||||||
return (
|
|
||||||
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
|
|
||||||
<path d="M143 352.3L7 216.3c-9.4-9.4-9.4-24.6 0-33.9l22.6-22.6c9.4-9.4 24.6-9.4 33.9 0l96.4 96.4 96.4-96.4c9.4-9.4 24.6-9.4 33.9 0l22.6 22.6c9.4 9.4 9.4 24.6 0 33.9l-136 136c-9.2 9.4-24.4 9.4-33.8 0z" />
|
|
||||||
</Icon>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
width?: number | string;
|
width: number | string;
|
||||||
height?: number | string;
|
height: number | string;
|
||||||
color?: string;
|
color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AngleDownOld = ({ width, height, color }: Props) => {
|
const AngleDown = ({ width, height, color }: Props) => {
|
||||||
return (
|
return (
|
||||||
<svg width={width} height={height} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
|
<svg width={width} height={height} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
|
||||||
<path
|
<path
|
||||||
@ -26,10 +17,10 @@ const AngleDownOld = ({ width, height, color }: Props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
AngleDownOld.defaultProps = {
|
AngleDown.defaultProps = {
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 16,
|
height: 16,
|
||||||
color: '#000',
|
color: '#000',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AngleDownOld;
|
export default AngleDown;
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Icon, { IconProps } from './Icon';
|
|
||||||
|
|
||||||
const Briefcase: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
|
|
||||||
return (
|
|
||||||
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
|
|
||||||
<path d="M320 336c0 8.84-7.16 16-16 16h-96c-8.84 0-16-7.16-16-16v-48H0v144c0 25.6 22.4 48 48 48h416c25.6 0 48-22.4 48-48V288H320v48zm144-208h-80V80c0-25.6-22.4-48-48-48H176c-25.6 0-48 22.4-48 48v48H48c-25.6 0-48 22.4-48 48v80h512v-80c0-25.6-22.4-48-48-48zm-144 0H192V96h128v32z" />
|
|
||||||
</Icon>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Briefcase;
|
|
@ -1,12 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Icon, { IconProps } from './Icon';
|
|
||||||
|
|
||||||
const CheckCircleOutline: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
|
|
||||||
return (
|
|
||||||
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
|
|
||||||
<path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 48c110.532 0 200 89.451 200 200 0 110.532-89.451 200-200 200-110.532 0-200-89.451-200-200 0-110.532 89.451-200 200-200m140.204 130.267l-22.536-22.718c-4.667-4.705-12.265-4.736-16.97-.068L215.346 303.697l-59.792-60.277c-4.667-4.705-12.265-4.736-16.97-.069l-22.719 22.536c-4.705 4.667-4.736 12.265-.068 16.971l90.781 91.516c4.667 4.705 12.265 4.736 16.97.068l172.589-171.204c4.704-4.668 4.734-12.266.067-16.971z" />
|
|
||||||
</Icon>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CheckCircleOutline;
|
|
@ -1,12 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Icon, { IconProps } from './Icon';
|
|
||||||
|
|
||||||
const ChevronRight: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
|
|
||||||
return (
|
|
||||||
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
|
|
||||||
<path d="M285.476 272.971L91.132 467.314c-9.373 9.373-24.569 9.373-33.941 0l-22.667-22.667c-9.357-9.357-9.375-24.522-.04-33.901L188.505 256 34.484 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L285.475 239.03c9.373 9.372 9.373 24.568.001 33.941z" />
|
|
||||||
</Icon>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChevronRight;
|
|
@ -1,12 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Icon, { IconProps } from './Icon';
|
|
||||||
|
|
||||||
const Cogs: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
|
|
||||||
return (
|
|
||||||
<Icon width={width} height={height} className={className} viewBox="0 0 640 512">
|
|
||||||
<path d="M512.1 191l-8.2 14.3c-3 5.3-9.4 7.5-15.1 5.4-11.8-4.4-22.6-10.7-32.1-18.6-4.6-3.8-5.8-10.5-2.8-15.7l8.2-14.3c-6.9-8-12.3-17.3-15.9-27.4h-16.5c-6 0-11.2-4.3-12.2-10.3-2-12-2.1-24.6 0-37.1 1-6 6.2-10.4 12.2-10.4h16.5c3.6-10.1 9-19.4 15.9-27.4l-8.2-14.3c-3-5.2-1.9-11.9 2.8-15.7 9.5-7.9 20.4-14.2 32.1-18.6 5.7-2.1 12.1.1 15.1 5.4l8.2 14.3c10.5-1.9 21.2-1.9 31.7 0L552 6.3c3-5.3 9.4-7.5 15.1-5.4 11.8 4.4 22.6 10.7 32.1 18.6 4.6 3.8 5.8 10.5 2.8 15.7l-8.2 14.3c6.9 8 12.3 17.3 15.9 27.4h16.5c6 0 11.2 4.3 12.2 10.3 2 12 2.1 24.6 0 37.1-1 6-6.2 10.4-12.2 10.4h-16.5c-3.6 10.1-9 19.4-15.9 27.4l8.2 14.3c3 5.2 1.9 11.9-2.8 15.7-9.5 7.9-20.4 14.2-32.1 18.6-5.7 2.1-12.1-.1-15.1-5.4l-8.2-14.3c-10.4 1.9-21.2 1.9-31.7 0zm-10.5-58.8c38.5 29.6 82.4-14.3 52.8-52.8-38.5-29.7-82.4 14.3-52.8 52.8zM386.3 286.1l33.7 16.8c10.1 5.8 14.5 18.1 10.5 29.1-8.9 24.2-26.4 46.4-42.6 65.8-7.4 8.9-20.2 11.1-30.3 5.3l-29.1-16.8c-16 13.7-34.6 24.6-54.9 31.7v33.6c0 11.6-8.3 21.6-19.7 23.6-24.6 4.2-50.4 4.4-75.9 0-11.5-2-20-11.9-20-23.6V418c-20.3-7.2-38.9-18-54.9-31.7L74 403c-10 5.8-22.9 3.6-30.3-5.3-16.2-19.4-33.3-41.6-42.2-65.7-4-10.9.4-23.2 10.5-29.1l33.3-16.8c-3.9-20.9-3.9-42.4 0-63.4L12 205.8c-10.1-5.8-14.6-18.1-10.5-29 8.9-24.2 26-46.4 42.2-65.8 7.4-8.9 20.2-11.1 30.3-5.3l29.1 16.8c16-13.7 34.6-24.6 54.9-31.7V57.1c0-11.5 8.2-21.5 19.6-23.5 24.6-4.2 50.5-4.4 76-.1 11.5 2 20 11.9 20 23.6v33.6c20.3 7.2 38.9 18 54.9 31.7l29.1-16.8c10-5.8 22.9-3.6 30.3 5.3 16.2 19.4 33.2 41.6 42.1 65.8 4 10.9.1 23.2-10 29.1l-33.7 16.8c3.9 21 3.9 42.5 0 63.5zm-117.6 21.1c59.2-77-28.7-164.9-105.7-105.7-59.2 77 28.7 164.9 105.7 105.7zm243.4 182.7l-8.2 14.3c-3 5.3-9.4 7.5-15.1 5.4-11.8-4.4-22.6-10.7-32.1-18.6-4.6-3.8-5.8-10.5-2.8-15.7l8.2-14.3c-6.9-8-12.3-17.3-15.9-27.4h-16.5c-6 0-11.2-4.3-12.2-10.3-2-12-2.1-24.6 0-37.1 1-6 6.2-10.4 12.2-10.4h16.5c3.6-10.1 9-19.4 15.9-27.4l-8.2-14.3c-3-5.2-1.9-11.9 2.8-15.7 9.5-7.9 20.4-14.2 32.1-18.6 5.7-2.1 12.1.1 15.1 5.4l8.2 14.3c10.5-1.9 21.2-1.9 31.7 0l8.2-14.3c3-5.3 9.4-7.5 15.1-5.4 11.8 4.4 22.6 10.7 32.1 18.6 4.6 3.8 5.8 10.5 2.8 15.7l-8.2 14.3c6.9 8 12.3 17.3 15.9 27.4h16.5c6 0 11.2 4.3 12.2 10.3 2 12 2.1 24.6 0 37.1-1 6-6.2 10.4-12.2 10.4h-16.5c-3.6 10.1-9 19.4-15.9 27.4l8.2 14.3c3 5.2 1.9 11.9-2.8 15.7-9.5 7.9-20.4 14.2-32.1 18.6-5.7 2.1-12.1-.1-15.1-5.4l-8.2-14.3c-10.4 1.9-21.2 1.9-31.7 0zM501.6 431c38.5 29.6 82.4-14.3 52.8-52.8-38.5-29.6-82.4 14.3-52.8 52.8z" />
|
|
||||||
</Icon>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Cogs;
|
|
@ -1,29 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Icon, { IconProps } from './Icon';
|
import Icon, { IconProps } from './Icon';
|
||||||
|
|
||||||
type TaskcafeProps = {
|
const Taskcafe: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
|
||||||
innerColor?: string;
|
|
||||||
outerColor?: string;
|
|
||||||
} & IconProps;
|
|
||||||
|
|
||||||
const Taskcafe: React.FC<TaskcafeProps> = ({
|
|
||||||
innerColor = '#262c49',
|
|
||||||
outerColor = '#7367f0',
|
|
||||||
width = '16px',
|
|
||||||
height = '16px',
|
|
||||||
className,
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<Icon width={width} height={height} className={className} viewBox="0 0 800 800">
|
<Icon width={width} height={height} className={className} viewBox="0 0 800 800">
|
||||||
<path
|
<path
|
||||||
d="M371.147 371.04c-59.092 0-106.995 47.903-106.995 106.995 0 59.092 47.903 106.995 106.995 106.995 59.092 0 106.995-47.903 106.995-106.995 0-59.092-47.903-106.996-106.995-106.996zm0 20.708c47.687 0 86.287 38.592 86.287 86.287 0 47.687-38.592 86.286-86.287 86.286-47.687 0-86.286-38.592-86.286-86.286 0-47.688 38.592-86.287 86.286-86.287m60.489 56.201l-9.723-9.8a5.177 5.177 0 00-7.321-.03l-60.984 60.494-25.796-26.006a5.177 5.177 0 00-7.322-.03l-9.802 9.723a5.177 5.177 0 00-.029 7.322l39.166 39.483a5.177 5.177 0 007.321.03l74.46-73.864a5.178 5.178 0 00.03-7.321z"
|
d="M371.147 371.04c-59.092 0-106.995 47.903-106.995 106.995 0 59.092 47.903 106.995 106.995 106.995 59.092 0 106.995-47.903 106.995-106.995 0-59.092-47.903-106.996-106.995-106.996zm0 20.708c47.687 0 86.287 38.592 86.287 86.287 0 47.687-38.592 86.286-86.287 86.286-47.687 0-86.286-38.592-86.286-86.286 0-47.688 38.592-86.287 86.286-86.287m60.489 56.201l-9.723-9.8a5.177 5.177 0 00-7.321-.03l-60.984 60.494-25.796-26.006a5.177 5.177 0 00-7.322-.03l-9.802 9.723a5.177 5.177 0 00-.029 7.322l39.166 39.483a5.177 5.177 0 007.321.03l74.46-73.864a5.178 5.178 0 00.03-7.321z"
|
||||||
fill={outerColor}
|
fill="#7367f0"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M264.69 682.877H467.7c56.038 0 101.504-45.465 101.504-101.504h33.835c74.648 0 135.34-60.692 135.34-135.34s-60.692-135.34-135.34-135.34H188.562a25.315 25.315 0 00-25.376 25.377v245.303c0 56.039 45.465 101.504 101.504 101.504zM603.04 378.364c37.324 0 67.67 30.345 67.67 67.67 0 37.323-30.346 67.669-67.67 67.669h-33.835v-135.34zm50.435 406.018H112.75c-50.329 0-64.497-67.67-38.064-67.67h616.746c26.434 0 12.477 67.67-37.958 67.67z"
|
d="M264.69 682.877H467.7c56.038 0 101.504-45.465 101.504-101.504h33.835c74.648 0 135.34-60.692 135.34-135.34s-60.692-135.34-135.34-135.34H188.562a25.315 25.315 0 00-25.376 25.377v245.303c0 56.039 45.465 101.504 101.504 101.504zM603.04 378.364c37.324 0 67.67 30.345 67.67 67.67 0 37.323-30.346 67.669-67.67 67.669h-33.835v-135.34zm50.435 406.018H112.75c-50.329 0-64.497-67.67-38.064-67.67h616.746c26.434 0 12.477 67.67-37.958 67.67z"
|
||||||
fill={outerColor}
|
fill="#7367f0"
|
||||||
/>
|
/>
|
||||||
<g fill="none" stroke={outerColor} strokeWidth="3.392">
|
<g fill="none" stroke="#7367f0" strokeWidth="3.392">
|
||||||
<path
|
<path
|
||||||
d="M286.302 276.2c-12.491-12.65-24.51-24.821-23.906-35.239.604-10.417 14.068-18.838 26.772-30.358 12.705-11.52 24.65-26.139 14.907-39.935-9.743-13.797-41.175-26.772-40.697-42.89.478-16.119 32.868-35.378 37.236-52.814 4.369-17.435-19.286-33.048-42.944-48.662M374.624 276.2c-12.492-12.65-24.51-24.821-23.907-35.239.604-10.417 14.068-18.838 26.773-30.358 12.704-11.52 24.65-26.139 14.906-39.935-9.742-13.797-41.174-26.772-40.696-42.89.478-16.119 32.867-35.378 37.236-52.814 4.368-17.435-19.287-33.048-42.944-48.662M462.945 276.2c-12.491-12.65-24.51-24.821-23.906-35.239.604-10.417 14.068-18.838 26.772-30.358 12.705-11.52 24.65-26.139 14.907-39.935-9.743-13.797-41.175-26.772-40.697-42.89.478-16.119 32.868-35.378 37.236-52.814 4.369-17.436-19.286-33.048-42.944-48.662"
|
d="M286.302 276.2c-12.491-12.65-24.51-24.821-23.906-35.239.604-10.417 14.068-18.838 26.772-30.358 12.705-11.52 24.65-26.139 14.907-39.935-9.743-13.797-41.175-26.772-40.697-42.89.478-16.119 32.868-35.378 37.236-52.814 4.369-17.435-19.286-33.048-42.944-48.662M374.624 276.2c-12.492-12.65-24.51-24.821-23.907-35.239.604-10.417 14.068-18.838 26.773-30.358 12.704-11.52 24.65-26.139 14.906-39.935-9.742-13.797-41.174-26.772-40.696-42.89.478-16.119 32.867-35.378 37.236-52.814 4.368-17.435-19.287-33.048-42.944-48.662M462.945 276.2c-12.491-12.65-24.51-24.821-23.906-35.239.604-10.417 14.068-18.838 26.772-30.358 12.705-11.52 24.65-26.139 14.907-39.935-9.743-13.797-41.175-26.772-40.697-42.89.478-16.119 32.868-35.378 37.236-52.814 4.369-17.436-19.286-33.048-42.944-48.662"
|
||||||
strokeWidth="28.00374144"
|
strokeWidth="28.00374144"
|
||||||
@ -31,7 +20,7 @@ const Taskcafe: React.FC<TaskcafeProps> = ({
|
|||||||
</g>
|
</g>
|
||||||
<path
|
<path
|
||||||
d="M371.147 363.194c-73.749 0-133.534 59.785-133.534 133.534 0 73.75 59.785 133.535 133.534 133.535 73.75 0 133.535-59.786 133.535-133.535s-59.786-133.534-133.535-133.534zm0 25.845c59.516 0 107.69 48.165 107.69 107.69 0 59.515-48.165 107.688-107.69 107.688-59.515 0-107.689-48.164-107.689-107.689 0-59.515 48.165-107.689 107.69-107.689m75.492 70.142l-12.135-12.232a6.462 6.462 0 00-9.138-.039l-76.11 75.498-32.194-32.456a6.463 6.463 0 00-9.138-.038l-12.233 12.134a6.451 6.451 0 00-.025 9.138l48.88 49.277a6.462 6.462 0 009.138.038l92.93-92.183a6.452 6.452 0 00.024-9.138z"
|
d="M371.147 363.194c-73.749 0-133.534 59.785-133.534 133.534 0 73.75 59.785 133.535 133.534 133.535 73.75 0 133.535-59.786 133.535-133.535s-59.786-133.534-133.535-133.534zm0 25.845c59.516 0 107.69 48.165 107.69 107.69 0 59.515-48.165 107.688-107.69 107.688-59.515 0-107.689-48.164-107.689-107.689 0-59.515 48.165-107.689 107.69-107.689m75.492 70.142l-12.135-12.232a6.462 6.462 0 00-9.138-.039l-76.11 75.498-32.194-32.456a6.463 6.463 0 00-9.138-.038l-12.233 12.134a6.451 6.451 0 00-.025 9.138l48.88 49.277a6.462 6.462 0 009.138.038l92.93-92.183a6.452 6.452 0 00.024-9.138z"
|
||||||
fill={innerColor}
|
fill="#262c49"
|
||||||
/>
|
/>
|
||||||
</Icon>
|
</Icon>
|
||||||
);
|
);
|
||||||
|
@ -1,11 +1,7 @@
|
|||||||
import Cross from './Cross';
|
import Cross from './Cross';
|
||||||
import Cog from './Cog';
|
import Cog from './Cog';
|
||||||
import Cogs from './Cogs';
|
|
||||||
import ArrowDown from './ArrowDown';
|
import ArrowDown from './ArrowDown';
|
||||||
import CheckCircleOutline from './CheckCircleOutline';
|
|
||||||
import Briefcase from './Briefcase';
|
|
||||||
import ListUnordered from './ListUnordered';
|
import ListUnordered from './ListUnordered';
|
||||||
import ChevronRight from './ChevronRight';
|
|
||||||
import Dot from './Dot';
|
import Dot from './Dot';
|
||||||
import CaretDown from './CaretDown';
|
import CaretDown from './CaretDown';
|
||||||
import Eye from './Eye';
|
import Eye from './Eye';
|
||||||
@ -106,9 +102,5 @@ export {
|
|||||||
Dot,
|
Dot,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
CaretRight,
|
CaretRight,
|
||||||
CheckCircleOutline,
|
|
||||||
Briefcase,
|
|
||||||
DotCircle,
|
DotCircle,
|
||||||
ChevronRight,
|
|
||||||
Cogs,
|
|
||||||
};
|
};
|
||||||
|
@ -61,9 +61,9 @@ export const base = {
|
|||||||
export const dark = {
|
export const dark = {
|
||||||
...base,
|
...base,
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
text: `${theme.colors.text.primary}`,
|
text: `rgba(${theme.colors.text.primary})`,
|
||||||
code: `${theme.colors.text.primary}`,
|
code: `rgba(${theme.colors.text.primary})`,
|
||||||
cursor: `${theme.colors.text.primary}`,
|
cursor: `rgba(${theme.colors.text.primary})`,
|
||||||
divider: '#4E5C6E',
|
divider: '#4E5C6E',
|
||||||
placeholder: '#52657A',
|
placeholder: '#52657A',
|
||||||
|
|
||||||
|
4
frontend/src/taskcafe.d.ts
vendored
4
frontend/src/taskcafe.d.ts
vendored
@ -206,10 +206,14 @@ type ImpactAction = {
|
|||||||
type ItemElement = {
|
type ItemElement = {
|
||||||
id: string;
|
id: string;
|
||||||
parent: null | string;
|
parent: null | string;
|
||||||
|
text: string;
|
||||||
|
focus: null | { caret: number | null };
|
||||||
|
zooming?: { x: number; y: number };
|
||||||
position: number;
|
position: number;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
children?: Array<ItemElement>;
|
children?: Array<ItemElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NodeDimensions = {
|
type NodeDimensions = {
|
||||||
entry: React.RefObject<HTMLElement>;
|
entry: React.RefObject<HTMLElement>;
|
||||||
children: React.RefObject<HTMLElement> | null;
|
children: React.RefObject<HTMLElement> | null;
|
||||||
|
17
frontend/src/types.d.ts
vendored
17
frontend/src/types.d.ts
vendored
@ -81,28 +81,12 @@ type TaskActivity = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreatedBy = {
|
|
||||||
id: string;
|
|
||||||
fullName: string;
|
|
||||||
profileIcon: ProfileIcon;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TaskComment = {
|
|
||||||
id: string;
|
|
||||||
createdBy: CreatedBy;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt?: string | null;
|
|
||||||
pinned: boolean;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Task = {
|
type Task = {
|
||||||
id: string;
|
id: string;
|
||||||
taskGroup: InnerTaskGroup;
|
taskGroup: InnerTaskGroup;
|
||||||
name: string;
|
name: string;
|
||||||
badges?: TaskBadges;
|
badges?: TaskBadges;
|
||||||
position: number;
|
position: number;
|
||||||
hasTime?: boolean;
|
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
complete?: boolean;
|
complete?: boolean;
|
||||||
completedAt?: string | null;
|
completedAt?: string | null;
|
||||||
@ -111,7 +95,6 @@ type Task = {
|
|||||||
assigned?: Array<TaskUser>;
|
assigned?: Array<TaskUser>;
|
||||||
checklists?: Array<TaskChecklist> | null;
|
checklists?: Array<TaskChecklist> | null;
|
||||||
activity?: Array<TaskActivity> | null;
|
activity?: Array<TaskActivity> | null;
|
||||||
comments?: Array<TaskComment> | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Project = {
|
type Project = {
|
||||||
|
3541
frontend/yarn.lock
3541
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user