50 Commits

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

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

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

users can now also be directly invited by email from the project share
window. if invited this way, then the user will receive an email
that sends them to a registration page, then a confirmation page.

the initial registration was always redone so that it uses a similar
system to the above in that it now will accept the first registered
user if there are no other accounts (besides 'system').
2020-12-17 22:39:14 -06:00
183 changed files with 14276 additions and 11203 deletions

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

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

View File

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

View File

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

View File

@ -6,7 +6,9 @@ Thanks for wanting to contribute to Taskcafe!
So you want to contribute to Taskcafe? Great!
If you have noticed a bug or want to add a new feature, please [create an issue](https://github.com/JordanKnott/taskcafe/issues/new/choose) for it before starting any work on a pull request.
If you have noticed a bug, please [create an issue](https://github.com/JordanKnott/taskcafe/issues/new/choose) for it before starting any work on a pull request.
If there is a [new feature you'd like added](https://github.com/JordanKnott/taskcafe/discussions/new?category=ideas) or [have a question](https://github.com/JordanKnott/taskcafe/discussions/new?category=q-a), please visit the [discussions section](https://github.com/JordanKnott/taskcafe/discussions)
Alternatively you can join the [Taskcafe discord](https://discord.gg/JkQDruh) and ask in the #questions channel.

View File

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

71
Pipfile.lock generated
View File

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

View File

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

View File

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

View File

@ -9,10 +9,13 @@
"@types/axios": "^0.14.0",
"@types/color": "^3.0.1",
"@types/date-fns": "^2.6.0",
"@types/dompurify": "^2.0.4",
"@types/emoji-mart": "^3.0.4",
"@types/jest": "^24.0.0",
"@types/jwt-decode": "^2.2.1",
"@types/lodash": "^4.14.149",
"@types/node": "^12.0.0",
"@types/query-string": "^6.3.0",
"@types/react": "^16.9.21",
"@types/react-beautiful-dnd": "^12.1.1",
"@types/react-datepicker": "^2.11.0",
@ -29,23 +32,29 @@
"apollo-link-http": "^1.5.16",
"apollo-link-state": "^0.4.2",
"apollo-utilities": "^1.3.3",
"axios": "^0.19.2",
"axios": "^0.21.1",
"axios-auth-refresh": "^2.2.7",
"color": "^3.1.2",
"date-fns": "^2.14.0",
"dayjs": "^1.9.1",
"dompurify": "^2.2.6",
"emoji-mart": "^3.0.0",
"emoticon": "^3.2.0",
"graphql": "^15.0.0",
"graphql-tag": "^2.10.3",
"history": "^4.10.1",
"immer": "^6.0.3",
"immer": "^8.0.1",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.20",
"node-emoji": "^1.10.0",
"prop-types": "^15.7.2",
"query-string": "^6.13.7",
"react": "^16.12.0",
"react-autosize-textarea": "^7.0.0",
"react-beautiful-dnd": "^13.0.0",
"react-datepicker": "^2.14.1",
"react-dom": "^16.12.0",
"react-emoji-render": "^1.2.4",
"react-hook-form": "^6.0.6",
"react-markdown": "^4.3.1",
"react-router": "^5.1.2",
@ -64,8 +73,6 @@
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"storybook": "start-storybook -p 9009 -s public",
"build-storybook": "build-storybook -s public",
"generate": "graphql-codegen",
"lint": "eslint --ext js,ts,tsx src",
"tsc": "tsc"
@ -90,16 +97,6 @@
"@graphql-codegen/typescript": "^1.13.2",
"@graphql-codegen/typescript-operations": "^1.13.2",
"@graphql-codegen/typescript-react-apollo": "^1.13.2",
"@storybook/addon-actions": "^5.3.13",
"@storybook/addon-backgrounds": "^5.3.17",
"@storybook/addon-docs": "^5.3.17",
"@storybook/addon-knobs": "^5.3.17",
"@storybook/addon-links": "^5.3.13",
"@storybook/addon-storysource": "^5.3.17",
"@storybook/addon-viewport": "^5.3.17",
"@storybook/addons": "^5.3.13",
"@storybook/preset-create-react-app": "^1.5.2",
"@storybook/react": "^5.3.13",
"@typescript-eslint/eslint-plugin": "^2.20.0",
"@typescript-eslint/parser": "^2.20.0",
"eslint": "^6.8.0",

View File

@ -82,7 +82,7 @@ const AddUserInput = styled(Input)`
`;
const InputError = styled.span`
color: rgba(${props => props.theme.colors.danger});
color: ${props => props.theme.colors.danger};
font-size: 12px;
`;
@ -174,7 +174,7 @@ const AdminRoute = () => {
useEffect(() => {
document.title = 'Admin | Taskcafé';
}, []);
const { loading, data } = useUsersQuery();
const { loading, data } = useUsersQuery({ fetchPolicy: 'cache-and-network' });
const { showPopup, hidePopup } = usePopup();
const { user } = useCurrentUser();
const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({
@ -182,7 +182,7 @@ const AdminRoute = () => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
produce(cache, draftCache => {
draftCache.invitedUsers = cache.invitedUsers.filter(
u => u.id !== response.data.deleteInvitedUserAccount.invitedUser.id,
u => u.id !== response.data?.deleteInvitedUserAccount.invitedUser.id,
);
}),
);
@ -192,7 +192,7 @@ const AdminRoute = () => {
update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
produce(cache, draftCache => {
draftCache.users = cache.users.filter(u => u.id !== response.data.deleteUserAccount.userAccount.id);
draftCache.users = cache.users.filter(u => u.id !== response.data?.deleteUserAccount.userAccount.id);
}),
);
},
@ -203,7 +203,7 @@ const AdminRoute = () => {
query: UsersDocument,
});
const newData = produce(cacheData, (draftState: any) => {
draftState.users = [...draftState.users, { ...createData.data.createUserAccount }];
draftState.users = [...draftState.users, { ...createData.data?.createUserAccount }];
});
client.writeQuery({
@ -214,9 +214,6 @@ const AdminRoute = () => {
});
},
});
if (loading) {
return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />;
}
if (data && user) {
if (user.roles.org !== 'admin') {
return <Redirect to="/" />;
@ -259,7 +256,7 @@ const AdminRoute = () => {
</>
);
}
return <span>error</span>;
return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />;
};
export default AdminRoute;

View File

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

View File

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

View File

@ -1,17 +1,21 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import { Switch, Route, useHistory } from 'react-router-dom';
import * as H from 'history';
import Dashboard from 'Dashboard';
import Admin from 'Admin';
import MyTasks from 'MyTasks';
import Confirm from 'Confirm';
import Projects from 'Projects';
import Outline from 'Outline';
import Project from 'Projects/Project';
import Teams from 'Teams';
import Login from 'Auth';
import Install from 'Install';
import Register from 'Register';
import Profile from 'Profile';
import styled from 'styled-components';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context';
const MainContent = styled.div`
padding: 0 0 0 0;
@ -22,14 +26,43 @@ const MainContent = styled.div`
flex-grow: 1;
`;
type RoutesProps = {
history: H.History;
type RefreshTokenResponse = {
accessToken: string;
setup?: null | { confirmToken: string };
};
const Routes: React.FC<RoutesProps> = () => (
const AuthorizedRoutes = () => {
const history = useHistory();
const [loading, setLoading] = useState(true);
const { setUser } = useCurrentUser();
useEffect(() => {
fetch('/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken, setup } = response;
if (setup) {
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
}
}
setLoading(false);
});
}, []);
return loading ? null : (
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/install" component={Install} />
<MainContent>
<Route exact path="/" component={Dashboard} />
<Route exact path="/projects" component={Projects} />
@ -37,9 +70,23 @@ const Routes: React.FC<RoutesProps> = () => (
<Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
<Route path="/outline" component={Outline} />
<Route path="/tasks" component={MyTasks} />
</MainContent>
</Switch>
);
};
type RoutesProps = {
history: H.History;
};
const Routes: React.FC<RoutesProps> = () => (
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/register" component={Register} />
<Route exact path="/confirm" component={Confirm} />
<AuthorizedRoutes />
</Switch>
);
export default Routes;

View File

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

View File

@ -1,6 +1,5 @@
import React from 'react';
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
import styled from 'styled-components/macro';
import { ProfileMenu } from 'shared/components/DropdownMenu';
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { useHistory } from 'react-router';
@ -9,164 +8,18 @@ import {
RoleCode,
useTopNavbarQuery,
useDeleteProjectMutation,
useGetProjectsQuery,
GetProjectsDocument,
} from 'shared/generated/graphql';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { History } from 'history';
import produce from 'immer';
import { Link } from 'react-router-dom';
import MiniProfile from 'shared/components/MiniProfile';
import cache from 'App/cache';
import NOOP from 'shared/utils/noop';
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
import theme from './ThemeStyles';
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: rgba(${props => props.theme.colors.bg.secondary});
bottom: 0;
content: '';
left: 0;
opacity: 0.88;
position: absolute;
right: 0;
top: 0;
}
`;
const TeamProjectAvatar = styled.div<{ color: string }>`
background-image: url(null);
background-color: ${props => props.color};
display: inline-block;
flex: 0 0 auto;
background-size: cover;
border-radius: 3px 0 0 3px;
height: 36px;
width: 36px;
position: relative;
opacity: 0.7;
`;
const TeamProjectContent = styled.div`
display: flex;
position: relative;
flex: 1;
width: 100%;
padding: 9px 0 9px 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TeamProjectTitle = styled.div`
font-weight: 700;
display: block;
padding-right: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TeamProjectContainer = styled.div`
box-sizing: border-box;
border-radius: 3px;
position: relative;
margin: 0 4px 4px 0;
min-width: 0;
&:hover ${TeamProjectTitle} {
color: rgba(${props => props.theme.colors.text.secondary});
}
&:hover ${TeamProjectAvatar} {
opacity: 1;
}
&:hover ${TeamProjectBackground}:before {
opacity: 0.78;
}
`;
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
const ProjectFinder = () => {
const { loading, data } = useGetProjectsQuery();
if (loading) {
return <span>loading</span>;
}
if (data) {
const { projects, teams } = data;
const projectTeams = teams.map(team => {
return {
id: team.id,
name: team.name,
projects: projects.filter(project => project.team && project.team.id === team.id),
};
});
return (
<>
{projectTeams.map(team => (
<TeamContainer key={team.id}>
<TeamTitle>{team.name}</TeamTitle>
<TeamProjects>
{team.projects.map((project, idx) => (
<TeamProjectContainer key={project.id}>
<TeamProjectLink to={`/projects/${project.id}`}>
<TeamProjectBackground color={colors[idx % 5]} />
<TeamProjectAvatar color={colors[idx % 5]} />
<TeamProjectContent>
<TeamProjectTitle>{project.name}</TeamProjectTitle>
</TeamProjectContent>
</TeamProjectLink>
</TeamProjectContainer>
))}
</TeamProjects>
</TeamContainer>
))}
</>
);
}
return <span>error</span>;
};
type ProjectPopupProps = {
history: History<History.PoorMansUnknown>;
history: any;
name: string;
projectID: string;
};
@ -181,7 +34,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, proje
const newData = produce(cacheData, (draftState: any) => {
draftState.projects = draftState.projects.filter(
(project: any) => project.id !== deleteData.data.deleteProject.project.id,
(project: any) => project.id !== deleteData.data?.deleteProject.project.id,
);
});
@ -328,7 +181,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
/>
))}
</NotificationPopup>,
{ width: 415, borders: false, diamondColor: '#7367f0' },
{ width: 415, borders: false, diamondColor: theme.colors.primary },
);
}
};
@ -424,6 +277,9 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
onDashboardClick={() => {
history.push('/');
}}
onMyTasksClick={() => {
history.push('/tasks');
}}
projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
onProfileClick={onProfileClick}

View File

@ -28,13 +28,13 @@ const StyledContainer = styled(ToastContainer).attrs({
color: #fff;
}
.Toastify__toast--error {
background: rgba(${props => props.theme.colors.danger});
background: ${props => props.theme.colors.danger};
}
.Toastify__toast--warning {
background: rgba(${props => props.theme.colors.warning});
background: ${props => props.theme.colors.warning};
}
.Toastify__toast--success {
background: rgba(${props => props.theme.colors.success});
background: ${props => props.theme.colors.success};
}
.Toastify__toast-body {
}
@ -46,13 +46,8 @@ const StyledContainer = styled(ToastContainer).attrs({
`;
const history = createBrowserHistory();
type RefreshTokenResponse = {
accessToken: string;
isInstalled: boolean;
};
const App = () => {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<CurrentUserRaw | null>(null);
const setUserRoles = (roles: CurrentUserRoles) => {
if (user) {
@ -63,32 +58,6 @@ const App = () => {
}
};
useEffect(() => {
fetch('/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
if (!isInstalled) {
history.replace('/install');
}
}
setLoading(false);
});
}, []);
return (
<>
<UserContext.Provider value={{ user, setUser, setUserRoles }}>
@ -97,13 +66,7 @@ const App = () => {
<BaseStyles />
<Router history={history}>
<PopupProvider>
{loading ? (
<div>loading</div>
) : (
<>
<Routes history={history} />
</>
)}
</PopupProvider>
</Router>
<StyledContainer

View File

@ -52,8 +52,21 @@ const Auth = () => {
}).then(async x => {
const { status } = x;
if (status === 200) {
const response: RefreshTokenResponse = await x.json();
const { accessToken, setup } = response;
if (setup) {
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
history.replace('/projects');
}
}
});
}, []);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,913 @@
import React, { useState, useEffect, useRef } from 'react';
import styled, { css } from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import Details from 'Projects/Project/Details';
import {
useMyTasksQuery,
MyTasksSort,
MyTasksStatus,
useCreateTaskMutation,
MyTasksQuery,
MyTasksDocument,
useUpdateTaskNameMutation,
useSetTaskCompleteMutation,
useUpdateTaskDueDateMutation,
} from 'shared/generated/graphql';
import { Route, useRouteMatch, useHistory, RouteComponentProps } from 'react-router-dom';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import NOOP from 'shared/utils/noop';
import { Sort, Cogs, CaretDown, CheckCircle, CaretRight, CheckCircleOutline } from 'shared/icons';
import Select from 'react-select';
import { editorColourStyles } from 'shared/components/Select';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import DueDateManager from 'shared/components/DueDateManager';
import dayjs from 'dayjs';
import useStickyState from 'shared/hooks/useStickyState';
import MyTasksSortPopup from './MyTasksSort';
import MyTasksStatusPopup from './MyTasksStatus';
import TaskEntry from './TaskEntry';
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ const FilterMember = styled(Member)`
margin: 2px 0;
&:hover {
cursor: pointer;
background: rgba(${props => props.theme.colors.primary});
background: ${props => props.theme.colors.primary};
}
`;
@ -71,7 +71,7 @@ export const ActionItem = styled.li`
align-items: center;
font-size: 14px;
&:hover {
background: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
}
`;
@ -80,7 +80,7 @@ export const ActionTitle = styled.span`
`;
const ActionItemSeparator = styled.li`
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)};
font-size: 12px;
padding-left: 4px;
padding-right: 4px;
@ -159,6 +159,7 @@ const FilterMeta: React.FC<FilterMetaProps> = ({ filters, onChangeTaskMetaFilter
width="100%"
onChange={e => handleNameChange(e.currentTarget.value)}
value={nameFilter}
autoFocus
variant="alternate"
placeholder="Task name..."
/>

View File

@ -30,7 +30,7 @@ export const ActionItem = styled.li`
align-items: center;
font-size: 14px;
&:hover {
background: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
}
&:hover ${ActionExtraMenuContainer} {
visibility: visible;
@ -69,11 +69,11 @@ export const ActionExtraMenuItem = styled.li`
align-items: center;
font-size: 14px;
&:hover {
background: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
}
`;
const ActionExtraMenuSeparator = styled.li`
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
color: ${props => props.theme.colors.text.primary};
font-size: 12px;
padding-left: 4px;
padding-right: 4px;

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/utils/sorting';
import { mixin } from 'shared/utils/styles';
export const ActionsList = styled.ul`
margin: 0;
@ -20,7 +21,7 @@ export const ActionItem = styled.li`
align-items: center;
font-size: 14px;
&:hover {
background: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
}
`;
@ -29,7 +30,7 @@ export const ActionTitle = styled.span`
`;
const ActionItemSeparator = styled.li`
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)};
font-size: 12px;
padding-left: 4px;
padding-right: 4px;

View File

@ -136,14 +136,14 @@ const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
display: flex;
align-items: center;
font-size: 15px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
&:not(:last-of-type) {
margin-right: 16px;
}
&:hover {
color: rgba(${props => props.theme.colors.text.secondary});
color: ${props => props.theme.colors.text.secondary};
}
${props =>
props.disabled &&
@ -280,7 +280,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter(
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id,
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data?.deleteTaskGroup.taskGroup.id,
);
}),
{ projectID },
@ -296,10 +296,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache =>
produce(cache, draftCache => {
const { taskGroups } = cache.findProject;
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id);
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data?.createTask.taskGroup.id);
if (idx !== -1) {
if (newTaskData.data) {
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
}
}
}),
{ projectID },
);
@ -313,7 +315,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (newTaskGroupData.data) {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
}
}),
{ projectID },
);
@ -332,7 +336,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
cache =>
produce(cache, draftCache => {
const idx = cache.findProject.taskGroups.findIndex(
t => t.id === resp.data.deleteTaskGroupTasks.taskGroupID,
t => t.id === resp.data?.deleteTaskGroupTasks.taskGroupID,
);
if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks = [];
@ -348,7 +352,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (resp.data) {
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
}
}),
{ projectID },
);
@ -364,21 +370,26 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (newTask.data) {
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
if (previousTaskGroupID !== task.taskGroup.id) {
const { taskGroups } = cache.findProject;
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
const previousTask = cache.findProject.taskGroups[oldTaskGroupIdx].tasks.find(t => t.id === task.id);
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
(t: Task) => t.id !== task.id,
);
if (previousTask) {
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
...taskGroups[newTaskGroupIdx].tasks,
{ ...task },
{ ...previousTask },
];
}
}
}
}
}),
{ projectID },
);
@ -415,6 +426,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
name,
complete: false,
completedAt: null,
hasTime: false,
taskGroup: {
__typename: 'TaskGroup',
id: taskGroup.id,
@ -448,9 +460,6 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
}
};
if (loading) {
return <BoardLoading />;
}
const getTaskStatusFilterLabel = (filter: TaskStatusFilter) => {
if (filter.status === TaskStatus.COMPLETE) {
return 'Complete';
@ -785,12 +794,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<DueDateManager
task={task}
onRemoveDueDate={t => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
hidePopup();
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } });
// hidePopup();
}}
onDueDateChange={(t, newDueDate) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup();
onDueDateChange={(t, newDueDate, hasTime) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } });
// hidePopup();
}}
onCancel={NOOP}
/>
@ -807,7 +816,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
);
}
return <span>Error</span>;
return <BoardLoading />;
};
export default ProjectBoard;

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import Modal from 'shared/components/Modal';
import TaskDetails from 'shared/components/TaskDetails';
import TaskDetailsLoading from 'shared/components/TaskDetails/Loading';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import MemberManager from 'shared/components/MemberManager';
import { useRouteMatch, useHistory } from 'react-router';
@ -21,6 +22,9 @@ import {
useCreateTaskChecklistItemMutation,
FindTaskDocument,
FindTaskQuery,
useCreateTaskCommentMutation,
useDeleteTaskCommentMutation,
useUpdateTaskCommentMutation,
} from 'shared/generated/graphql';
import { useCurrentUser } from 'App/context';
import MiniProfile from 'shared/components/MiniProfile';
@ -33,6 +37,73 @@ import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache';
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 total = checklists.reduce((prev: any, next: any) => {
return (
@ -130,6 +201,40 @@ const Details: React.FC<DetailsProps> = ({
const { user } = useCurrentUser();
const { showPopup, hidePopup } = usePopup();
const history = useHistory();
const [deleteTaskComment] = useDeleteTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
if (response.data) {
draftCache.findTask.comments = cache.findTask.comments.filter(
c => c.id !== response.data?.deleteTaskComment.commentID,
);
}
}),
{ taskID },
);
},
});
const [createTaskComment] = useCreateTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
if (response.data) {
draftCache.findTask.comments.push({
...response.data.createTaskComment.comment,
});
}
}),
{ taskID },
);
},
});
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
update: (client, response) => {
@ -138,10 +243,11 @@ const Details: React.FC<DetailsProps> = ({
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
if (checklistID !== prevChecklistID) {
if (response.data) {
const { prevChecklistID, taskChecklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
if (taskChecklistID !== prevChecklistID) {
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID);
const newIdx = cache.findTask.checklists.findIndex(c => c.id === taskChecklistID);
if (oldIdx > -1 && newIdx > -1) {
const item = cache.findTask.checklists[oldIdx].items.find(i => i.id === checklistItem.id);
if (item) {
@ -151,11 +257,12 @@ const Details: React.FC<DetailsProps> = ({
draftCache.findTask.checklists[newIdx].items.push({
...item,
position: checklistItem.position,
taskChecklistID: checklistID,
taskChecklistID,
});
}
}
}
}
}),
{ taskID },
);
@ -188,7 +295,7 @@ const Details: React.FC<DetailsProps> = ({
produce(cache, draftCache => {
const { checklists } = cache.findTask;
draftCache.findTask.checklists = checklists.filter(
c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id,
c => c.id !== deleteData.data?.deleteTaskChecklist.taskChecklist.id,
);
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
@ -212,8 +319,10 @@ const Details: React.FC<DetailsProps> = ({
FindTaskDocument,
cache =>
produce(cache, draftCache => {
if (createData.data) {
const item = createData.data.createTaskChecklist;
draftCache.findTask.checklists.push({ ...item });
}
}),
{ taskID },
);
@ -227,6 +336,7 @@ const Details: React.FC<DetailsProps> = ({
FindTaskDocument,
cache =>
produce(cache, draftCache => {
if (deleteData.data) {
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
if (targetIdx > -1) {
@ -240,6 +350,7 @@ const Details: React.FC<DetailsProps> = ({
complete,
total,
};
}
}),
{ taskID },
);
@ -252,6 +363,7 @@ const Details: React.FC<DetailsProps> = ({
FindTaskDocument,
cache =>
produce(cache, draftCache => {
if (newTaskItem.data) {
const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask;
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
@ -264,12 +376,17 @@ const Details: React.FC<DetailsProps> = ({
total,
};
}
}
}),
{ taskID },
);
},
});
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
const { loading, data, refetch } = useFindTaskQuery({
variables: { taskID },
pollInterval: 3000,
fetchPolicy: 'cache-and-network',
});
const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
onCompleted: () => {
@ -289,12 +406,9 @@ const Details: React.FC<DetailsProps> = ({
refreshCache();
},
});
if (loading) {
return null;
}
if (!data) {
return null;
}
const [updateTaskComment] = useUpdateTaskCommentMutation();
const [editableComment, setEditableComment] = useState<null | string>(null);
const isLoading = true;
return (
<>
<Modal
@ -303,10 +417,33 @@ const Details: React.FC<DetailsProps> = ({
history.push(projectURL);
}}
renderContent={() => {
return (
return data ? (
<TaskDetails
onCancelCommentEdit={() => setEditableComment(null)}
onUpdateComment={(commentID, message) => {
updateTaskComment({ variables: { commentID, message } });
}}
editableComment={editableComment}
me={data.me.user}
onCommentShowActions={(commentID, $targetRef) => {
showPopup(
$targetRef,
<TaskCommentActions
onDeleteComment={() => {
deleteTaskComment({ variables: { commentID } });
hidePopup();
}}
onEditComment={() => {
setEditableComment(commentID);
hidePopup();
}}
/>,
);
}}
task={data.findTask}
onCreateComment={(task, message) => {
createTaskComment({ variables: { taskID: task.id, message } });
}}
onChecklistDrop={checklist => {
updateTaskChecklistLocation({
variables: { taskChecklistID: checklist.id, position: checklist.position },
@ -495,12 +632,12 @@ const Details: React.FC<DetailsProps> = ({
<DueDateManager
task={task}
onRemoveDueDate={t => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
hidePopup();
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } });
// hidePopup();
}}
onDueDateChange={(t, newDueDate) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup();
onDueDateChange={(t, newDueDate, hasTime) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } });
// hidePopup();
}}
onCancel={NOOP}
/>
@ -509,6 +646,8 @@ const Details: React.FC<DetailsProps> = ({
);
}}
/>
) : (
<TaskDetailsLoading />
);
}}
/>

View File

@ -36,7 +36,9 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (newLabelData.data) {
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel });
}
}),
{
projectID,
@ -53,7 +55,7 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
cache =>
produce(cache, draftCache => {
draftCache.findProject.labels = cache.findProject.labels.filter(
label => label.id !== newLabelData.data.deleteProjectLabel.id,
label => label.id !== newLabelData.data?.deleteProjectLabel.id,
);
}),
{ projectID },

View File

@ -32,7 +32,6 @@ import {
FindProjectDocument,
FindProjectQuery,
} from 'shared/generated/graphql';
import produce from 'immer';
import UserContext, { useCurrentUser } from 'App/context';
import Input from 'shared/components/Input';
@ -48,6 +47,7 @@ import { colourStyles } from 'shared/components/Select';
import Board, { BoardLoading } from './Board';
import Details from './Details';
import LabelManagerEditor from './LabelManagerEditor';
import { mixin } from '../../shared/utils/styles';
const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant';
@ -71,7 +71,7 @@ const UserMember = styled(Member)`
padding: 4px 0;
cursor: pointer;
&:hover {
background: rgba(${props => props.theme.colors.bg.primary}, 0.4);
background: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
}
border-radius: 6px;
`;
@ -134,7 +134,6 @@ type MemberFilterOptions = {
};
const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => {
console.log(input.trim().length < 3);
if (input && input.trim().length < 3) {
return [];
}
@ -162,12 +161,10 @@ const fetchMembers = async (client: any, projectID: string, options: MemberFilte
let results: any = [];
const emails: Array<string> = [];
console.log(res.data && res.data.searchMembers);
if (res.data && res.data.searchMembers) {
results = [
...res.data.searchMembers.map((m: any) => {
if (m.status === 'INVITED') {
console.log(`${m.id} is added`);
return {
label: m.id,
value: {
@ -179,17 +176,15 @@ 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 },
};
}
}),
];
console.log(results);
}
if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) {
@ -242,7 +237,6 @@ const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>`
`;
const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
console.log(data);
return !isDisabled ? (
<OptionWrapper {...innerProps} isFocused={isFocused}>
<TaskAssignee
@ -422,14 +416,16 @@ const Project = () => {
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (resp.data) {
const taskGroupIdx = draftCache.findProject.taskGroups.findIndex(
tg => tg.tasks.findIndex(t => t.id === resp.data.deleteTask.taskID) !== -1,
tg => tg.tasks.findIndex(t => t.id === resp.data?.deleteTask.taskID) !== -1,
);
if (taskGroupIdx !== -1) {
draftCache.findProject.taskGroups[taskGroupIdx].tasks = cache.findProject.taskGroups[
taskGroupIdx
].tasks.filter(t => t.id !== resp.data.deleteTask.taskID);
].tasks.filter(t => t.id !== resp.data?.deleteTask.taskID);
}
}
}),
{ projectID },
@ -440,6 +436,7 @@ const Project = () => {
const { loading, data, error } = useFindProjectQuery({
variables: { projectID },
pollInterval: 3000,
});
const [updateProjectName] = useUpdateProjectNameMutation({
@ -449,7 +446,7 @@ const Project = () => {
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.name = newName.data.updateProjectName.name;
draftCache.findProject.name = newName.data?.updateProjectName.name ?? '';
}),
{ projectID },
);
@ -463,6 +460,7 @@ const Project = () => {
FindProjectDocument,
cache =>
produce(cache, draftCache => {
if (response.data) {
draftCache.findProject.members = [
...cache.findProject.members,
...response.data.inviteProjectMembers.members,
@ -471,6 +469,7 @@ const Project = () => {
...cache.findProject.invitedMembers,
...response.data.inviteProjectMembers.invitedMembers,
];
}
}),
{ projectID },
);
@ -484,7 +483,7 @@ const Project = () => {
cache =>
produce(cache, draftCache => {
draftCache.findProject.invitedMembers = cache.findProject.invitedMembers.filter(
m => m.email !== response.data.deleteInvitedProjectMember.invitedMember.email,
m => m.email !== response.data?.deleteInvitedProjectMember.invitedMember.email ?? '',
);
}),
{ projectID },
@ -499,7 +498,7 @@ const Project = () => {
cache =>
produce(cache, draftCache => {
draftCache.findProject.members = cache.findProject.members.filter(
m => m.id !== response.data.deleteProjectMember.member.id,
m => m.id !== response.data?.deleteProjectMember.member.id,
);
}),
{ projectID },
@ -518,14 +517,6 @@ const Project = () => {
document.title = `${data.findProject.name} | Taskcafé`;
}
}, [data]);
if (loading) {
return (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
<BoardLoading />
</>
);
}
if (error) {
history.push('/projects');
}
@ -638,7 +629,12 @@ const Project = () => {
</>
);
}
return <div>Error</div>;
return (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
<BoardLoading />
</>
);
};
export default Project;

View File

@ -20,6 +20,8 @@ import Input from 'shared/components/Input';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import NOOP from 'shared/utils/noop';
import theme from 'App/ThemeStyles';
import { mixin } from '../shared/utils/styles';
type CreateTeamData = { teamName: string };
@ -54,7 +56,7 @@ const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
};
const ProjectAddTile = styled.div`
background-color: rgba(${props => props.theme.colors.bg.primary}, 0.4);
background-color: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
background-size: cover;
background-position: 50%;
color: #fff;
@ -148,6 +150,7 @@ const Wrapper = styled.div`
flex-direction: row;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
`;
const ProjectSectionTitleWrapper = styled.div`
@ -176,7 +179,7 @@ const SectionActionLink = styled(Link)`
const ProjectSectionTitle = styled.h3`
font-size: 16px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
`;
const ProjectsContainer = styled.div`
@ -200,7 +203,7 @@ type ShowNewProject = {
const Projects = () => {
const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' });
const { loading, data } = useGetProjectsQuery({ pollInterval: 3000, fetchPolicy: 'cache-and-network' });
useEffect(() => {
document.title = 'Taskcafé';
}, []);
@ -208,7 +211,9 @@ const Projects = () => {
update: (client, newProject) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
if (newProject.data) {
draftCache.projects.push({ ...newProject.data.createProject });
}
}),
);
},
@ -220,16 +225,15 @@ const Projects = () => {
update: (client, createData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
draftCache.teams.push({ ...createData.data.createTeam });
if (createData.data) {
draftCache.teams.push({ ...createData.data?.createTeam });
}
}),
);
},
});
if (loading) {
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
}
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
const colors = theme.colors.multiColors;
if (data && user) {
const { projects, teams, organizations } = data;
const organizationID = organizations[0].id ?? null;
@ -389,7 +393,7 @@ const Projects = () => {
</>
);
}
return <div>Error!</div>;
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
};
export default Projects;

View File

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

View File

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

View File

@ -21,6 +21,7 @@ import TaskAssignee from 'shared/components/TaskAssignee';
import Member from 'shared/components/Member';
import ControlledInput from 'shared/components/ControlledInput';
import NOOP from 'shared/utils/noop';
import { mixin } from 'shared/utils/styles';
const MemberListWrapper = styled.div`
flex: 1 1;
@ -34,7 +35,7 @@ const UserMember = styled(Member)`
padding: 4px 0;
cursor: pointer;
&:hover {
background: rgba(${props => props.theme.colors.bg.primary}, 0.4);
background: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
}
border-radius: 6px;
`;
@ -119,12 +120,12 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
? css`
user-select: none;
pointer-events: none;
color: rgba(${props.theme.colors.text.primary}, 0.4);
color: ${mixin.rgba(props.theme.colors.text.primary, 0.4)};
`
: css`
cursor: pointer;
&:hover {
background: rgb(115, 103, 240);
background: ${props.theme.colors.primary};
}
`}
`;
@ -135,7 +136,7 @@ export const Content = styled.div`
export const CurrentPermission = styled.span`
margin-left: 4px;
color: rgba(${props => props.theme.colors.text.secondary}, 0.4);
color: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
`;
export const Separator = styled.div`
@ -146,13 +147,13 @@ export const Separator = styled.div`
export const WarningText = styled.span`
display: flex;
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)};
padding: 6px;
`;
export const DeleteDescription = styled.div`
font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
`;
export const RemoveMemberButton = styled(Button)`
@ -305,14 +306,14 @@ const MemberItemOption = styled(Button)`
`;
const MemberList = styled.div`
border-top: 1px solid rgba(${props => props.theme.colors.border});
border-top: 1px solid ${props => props.theme.colors.border};
`;
const MemberListItem = styled.div`
display: flex;
flex-flow: row wrap;
justify-content: space-between;
border-bottom: 1px solid rgba(${props => props.theme.colors.border});
border-bottom: 1px solid ${props => props.theme.colors.border};
min-height: 40px;
padding: 12px 0 12px 40px;
position: relative;
@ -336,11 +337,11 @@ const MemberProfile = styled(TaskAssignee)`
`;
const MemberItemName = styled.p`
color: rgba(${props => props.theme.colors.text.secondary});
color: ${props => props.theme.colors.text.secondary};
`;
const MemberItemUsername = styled.p`
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
`;
const MemberListHeader = styled.div`
@ -349,12 +350,12 @@ const MemberListHeader = styled.div`
`;
const ListTitle = styled.h3`
font-size: 18px;
color: rgba(${props => props.theme.colors.text.secondary});
color: ${props => props.theme.colors.text.secondary};
margin-bottom: 12px;
`;
const ListDesc = styled.span`
font-size: 16px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
`;
const FilterSearch = styled(Input)`
margin: 0;
@ -386,11 +387,11 @@ const FilterTabItem = styled.li`
font-weight: 700;
text-decoration: none;
padding: 6px 8px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
&:hover {
border-radius: 6px;
background: rgba(${props => props.theme.colors.primary});
color: rgba(${props => props.theme.colors.text.secondary});
background: ${props => props.theme.colors.primary};
color: ${props => props.theme.colors.text.secondary};
}
`;
@ -418,7 +419,11 @@ type MembersProps = {
const Members: React.FC<MembersProps> = ({ teamID }) => {
const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetTeamQuery({ variables: { teamID } });
const { loading, data } = useGetTeamQuery({
variables: { teamID },
fetchPolicy: 'cache-and-network',
pollInterval: 3000,
});
const { user, setUserRoles } = useCurrentUser();
const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
@ -429,11 +434,13 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
GetTeamDocument,
cache =>
produce(cache, draftCache => {
if (response.data) {
draftCache.findTeam.members.push({
...response.data.createTeamMember.teamMember,
member: { __typename: 'MemberList', projects: [], teams: [] },
owned: { __typename: 'OwnedList', projects: [], teams: [] },
});
}
}),
{ teamID },
);
@ -458,16 +465,13 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
cache =>
produce(cache, draftCache => {
draftCache.findTeam.members = cache.findTeam.members.filter(
member => member.id !== response.data.deleteTeamMember.userID,
member => member.id !== response.data?.deleteTeamMember.userID,
);
}),
{ teamID },
);
},
});
if (loading) {
return <span>loading</span>;
}
if (data && user) {
return (
@ -555,7 +559,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
);
}
return <div>error</div>;
return <div>loading</div>;
};
export default Members;

View File

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

View File

@ -33,7 +33,7 @@ const Wrapper = styled.div`
`;
type TeamPopupProps = {
history: History<History.PoorMansUnknown>;
history: History<any>;
name: string;
teamID: string;
};
@ -44,9 +44,9 @@ export const TeamPopup: React.FC<TeamPopupProps> = ({ history, name, teamID }) =
update: (client, deleteData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data.deleteTeam.team.id);
draftCache.teams = cache.teams.filter((team: any) => team.id !== deleteData.data?.deleteTeam.team.id);
draftCache.projects = cache.projects.filter(
(project: any) => project.team.id !== deleteData.data.deleteTeam.team.id,
(project: any) => project.team.id !== deleteData.data?.deleteTeam.team.id,
);
}),
);
@ -94,23 +94,6 @@ const Teams = () => {
const { user } = useCurrentUser();
const [currentTab, setCurrentTab] = useState(0);
const match = useRouteMatch();
if (loading) {
return (
<GlobalTopNavbar
menuType={[
{ name: 'Projects', link: `${match.url}` },
{ name: 'Members', link: `${match.url}/members` },
]}
currentTab={currentTab}
onSetTab={tab => {
setCurrentTab(tab);
}}
onSaveProjectName={NOOP}
projectID={null}
name={null}
/>
);
}
if (data && user) {
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
return <Redirect to="/" />;
@ -146,7 +129,21 @@ const Teams = () => {
</>
);
}
return <div>Error!</div>;
return (
<GlobalTopNavbar
menuType={[
{ name: 'Projects', link: `${match.url}` },
{ name: 'Members', link: `${match.url}/members` },
]}
currentTab={currentTab}
onSetTab={tab => {
setCurrentTab(tab);
}}
onSaveProjectName={NOOP}
projectID={null}
name={null}
/>
);
};
export default Teams;

View File

@ -20,15 +20,13 @@ import App from './App';
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
dayjs.extend(isSameOrAfter);
enableMapSet();
dayjs.extend(isSameOrAfter);
dayjs.extend(weekday);
dayjs.extend(isBetween);
dayjs.extend(customParseFormat);
enableMapSet();
dayjs.extend(updateLocale);
dayjs.updateLocale('en', {
week: {
dow: 1, // First day of week is Monday

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import { RoleCode, useUpdateUserRoleMutation } from 'shared/generated/graphql';
import Input from 'shared/components/Input';
import Button from 'shared/components/Button';
import NOOP from 'shared/utils/noop';
import { mixin } from 'shared/utils/styles';
export const RoleCheckmark = styled(Checkmark)`
padding-left: 4px;
@ -58,12 +59,12 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
? css`
user-select: none;
pointer-events: none;
color: rgba(${props.theme.colors.text.primary}, 0.4);
color: ${mixin.rgba(props.theme.colors.text.primary, 0.4)};
`
: css`
cursor: pointer;
&:hover {
background: rgb(115, 103, 240);
background: ${props.theme.colors.primary};
}
`}
`;
@ -74,7 +75,7 @@ export const Content = styled.div`
export const CurrentPermission = styled.span`
margin-left: 4px;
color: rgba(${props => props.theme.colors.text.secondary}, 0.4);
color: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
`;
export const Separator = styled.div`
@ -85,13 +86,13 @@ export const Separator = styled.div`
export const WarningText = styled.span`
display: flex;
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)};
padding: 6px;
`;
export const DeleteDescription = styled.div`
font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
`;
export const RemoveMemberButton = styled(Button)`
@ -333,14 +334,14 @@ const MemberItemOption = styled(Button)`
`;
const MemberList = styled.div`
border-top: 1px solid rgba(${props => props.theme.colors.border});
border-top: 1px solid ${props => props.theme.colors.border};
`;
const MemberListItem = styled.div`
display: flex;
flex-flow: row wrap;
justify-content: space-between;
border-bottom: 1px solid rgba(${props => props.theme.colors.border});
border-bottom: 1px solid ${props => props.theme.colors.border};
min-height: 40px;
padding: 12px 0 12px 40px;
position: relative;
@ -364,11 +365,11 @@ const MemberProfile = styled(TaskAssignee)`
`;
const MemberItemName = styled.p`
color: rgba(${props => props.theme.colors.text.secondary});
color: ${props => props.theme.colors.text.secondary};
`;
const MemberItemUsername = styled.p`
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
`;
const MemberListHeader = styled.div`
@ -377,12 +378,12 @@ const MemberListHeader = styled.div`
`;
const ListTitle = styled.h3`
font-size: 18px;
color: rgba(${props => props.theme.colors.text.secondary});
color: ${props => props.theme.colors.text.secondary};
margin-bottom: 12px;
`;
const ListDesc = styled.span`
font-size: 16px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
`;
const FilterSearch = styled(Input)`
margin: 0;
@ -443,17 +444,17 @@ const TabNavItemButton = styled.button<{ active: boolean }>`
width: 100%;
position: relative;
color: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')};
color: ${props => (props.active ? `${props.theme.colors.secondary}` : props.theme.colors.text.primary)};
&:hover {
color: rgba(115, 103, 240);
color: ${props => `${props.theme.colors.primary}`};
}
&:hover svg {
fill: rgba(115, 103, 240);
fill: ${props => props.theme.colors.primary};
}
`;
const TabItemUser = styled(User)<{ active: boolean }>`
fill: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')}
stroke: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')}
fill: ${props => (props.active ? `${props.theme.colors.primary}` : props.theme.colors.text.primary)}
stroke: ${props => (props.active ? `${props.theme.colors.primary}` : props.theme.colors.text.primary)}
`;
const TabNavItemSpan = styled.span`
@ -470,8 +471,8 @@ const TabNavLine = styled.span<{ top: number }>`
transform: scaleX(1);
top: ${props => props.top}px;
background: linear-gradient(30deg, rgba(115, 103, 240), rgba(115, 103, 240));
box-shadow: 0 0 8px 0 rgba(115, 103, 240);
background: linear-gradient(30deg, ${props => props.theme.colors.primary}, ${props => props.theme.colors.primary});
box-shadow: 0 0 8px 0 ${props => props.theme.colors.primary};
display: block;
position: absolute;
transition: all 0.2s ease;

View File

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

View File

@ -1,5 +1,6 @@
import React, { useRef } from 'react';
import styled, { css } from 'styled-components/macro';
import { mixin } from '../../utils/styles';
const Text = styled.span<{ fontSize: string; justifyTextContent: string; hasIcon?: boolean }>`
position: relative;
@ -8,7 +9,7 @@ const Text = styled.span<{ fontSize: string; justifyTextContent: string; hasIcon
justify-content: ${props => props.justifyTextContent};
transition: all 0.2s ease;
font-size: ${props => props.fontSize};
color: rgba(${props => props.theme.colors.text.secondary});
color: ${props => props.theme.colors.text.secondary};
${props =>
props.hasIcon &&
css`
@ -36,35 +37,36 @@ const Base = styled.button<{ color: string; disabled: boolean }>`
`;
const Filled = styled(Base)<{ hoverVariant: HoverVariant }>`
background: rgba(${props => props.theme.colors[props.color]});
background: ${props => props.theme.colors[props.color]};
${props =>
props.hoverVariant === 'boxShadow' &&
css`
&:hover {
box-shadow: 0 8px 25px -8px rgba(${props.theme.colors[props.color]});
box-shadow: 0 8px 25px -8px ${props.theme.colors[props.color]};
}
`}
`;
const Outline = styled(Base)<{ invert: boolean }>`
border: 1px solid rgba(${props => props.theme.colors[props.color]});
border: 1px solid ${props => props.theme.colors[props.color]};
background: transparent;
${props =>
props.invert
? css`
background: rgba(${props.theme.colors[props.color]});
background: ${props.theme.colors[props.color]});
& ${Text} {
color: rgba(${props.theme.colors.text.secondary});
color: ${props.theme.colors.text.secondary});
}
&:hover {
background: rgba(${props.theme.colors[props.color]}, 0.8);
background: ${mixin.rgba(props.theme.colors[props.color], 0.8)};
}
`
: css`
& ${Text} {
color: rgba(${props.theme.colors[props.color]});
color: ${props.theme.colors[props.color]});
}
&:hover {
background: rgba(${props.theme.colors[props.color]}, 0.08);
background: ${mixin.rgba(props.theme.colors[props.color], 0.08)};
}
`}
`;
@ -72,7 +74,7 @@ const Outline = styled(Base)<{ invert: boolean }>`
const Flat = styled(Base)`
background: transparent;
&:hover {
background: rgba(${props => props.theme.colors[props.color]}, 0.2);
background: ${props => mixin.rgba(props.theme.colors[props.color], 0.2)};
}
`;
@ -85,7 +87,7 @@ const LineX = styled.span<{ color: string }>`
bottom: -2px;
left: 50%;
transform: translate(-50%);
background: rgba(${props => props.theme.colors[props.color]}, 1);
background: ${props => mixin.rgba(props.theme.colors[props.color], 1)};
`;
const LineDown = styled(Base)`
@ -94,7 +96,7 @@ const LineDown = styled(Base)`
border-width: 0;
border-style: solid;
border-bottom-width: 2px;
border-color: rgba(${props => props.theme.colors[props.color]}, 0.2);
border-color: ${props => mixin.rgba(props.theme.colors[props.color], 0.2)};
&:hover ${LineX} {
width: 100%;
@ -107,8 +109,8 @@ const LineDown = styled(Base)`
const Gradient = styled(Base)`
background: linear-gradient(
30deg,
rgba(${props => props.theme.colors[props.color]}, 1),
rgba(${props => props.theme.colors[props.color]}, 0.5)
${props => mixin.rgba(props.theme.colors[props.color], 1)},
${props => mixin.rgba(props.theme.colors[props.color], 0.5)}
);
text-shadow: 1px 2px 4px rgba(0, 0, 0, 0.3);
&:hover {
@ -117,7 +119,7 @@ const Gradient = styled(Base)`
`;
const Relief = styled(Base)`
background: rgba(${props => props.theme.colors[props.color]}, 1);
background: ${props => mixin.rgba(props.theme.colors[props.color], 1)};
-webkit-box-shadow: 0 -3px 0 0 rgba(0, 0, 0, 0.2) inset;
box-shadow: inset 0 -3px 0 0 rgba(0, 0, 0, 0.2);

View File

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

View File

@ -5,8 +5,8 @@ import { CheckCircle, CheckSquareOutline, Clock } from 'shared/icons';
import TaskAssignee from 'shared/components/TaskAssignee';
export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
box-shadow: 0 0 0 2px rgba(${props => props.theme.colors.bg.secondary}),
inset 0 0 0 1px rgba(${props => props.theme.colors.bg.secondary}, 0.07);
box-shadow: 0 0 0 2px ${props => props.theme.colors.bg.secondary},
inset 0 0 0 1px ${props => mixin.rgba(props.theme.colors.bg.secondary, 0.07)};
z-index: ${props => props.zIndex};
position: relative;
`;
@ -14,8 +14,8 @@ export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'no
${props =>
props.color === 'success' &&
css`
fill: rgba(${props.theme.colors.success});
stroke: rgba(${props.theme.colors.success});
fill: ${props.theme.colors.success};
stroke: ${props.theme.colors.success};
`}
`;
export const ClockIcon = styled(Clock)<{ color: string }>`
@ -38,7 +38,7 @@ export const EditorTextarea = styled(TextareaAutosize)`
padding: 0;
font-size: 14px;
line-height: 18px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
&:focus {
border: none;
outline: none;
@ -89,7 +89,7 @@ export const ListCardBadgeText = styled.span<{ color?: 'success' | 'normal' }>`
padding: 0 4px 0 6px;
vertical-align: top;
white-space: nowrap;
${props => props.color === 'success' && `color: rgba(${props.theme.colors.success});`}
${props => props.color === 'success' && `color: ${props.theme.colors.success};`}
`;
export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>`
@ -101,7 +101,9 @@ export const ListCardContainer = styled.div<{ isActive: boolean; editable: boole
position: relative;
background-color: ${props =>
props.isActive && !props.editable ? mixin.darken('#262c49', 0.1) : `rgba(${props.theme.colors.bg.secondary})`};
props.isActive && !props.editable
? mixin.darken(props.theme.colors.bg.secondary, 0.1)
: `${props.theme.colors.bg.secondary}`};
`;
export const ListCardInnerContainer = styled.div`
@ -221,22 +223,23 @@ export const ListCardOperation = styled.span`
top: 2px;
z-index: 100;
&:hover {
background-color: ${props => mixin.darken('#262c49', 0.25)};
background-color: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.25)};
}
`;
export const CardTitle = styled.span`
export const CardTitle = styled.div`
clear: both;
margin: 0 0 4px;
overflow: hidden;
text-decoration: none;
color: ${props => props.theme.colors.text.primary};
display: block;
align-items: center;
`;
export const CardTitleText = styled.span`
word-wrap: break-word;
line-height: 18px;
font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary});
display: flex;
align-items: center;
`;
export const CardMembers = styled.div`
@ -246,9 +249,10 @@ export const CardMembers = styled.div`
`;
export const CompleteIcon = styled(CheckCircle)`
fill: rgba(${props => props.theme.colors.success});
fill: ${props => props.theme.colors.success};
margin-right: 4px;
flex-shrink: 0;
margin-bottom: -2px;
`;
export const EditorContent = styled.div`

View File

@ -22,6 +22,7 @@ import {
ListCardOperation,
CardTitle,
CardMembers,
CardTitleText,
} from './Styles';
type DueDate = {
@ -210,7 +211,7 @@ const Card = React.forwardRef(
) : (
<CardTitle>
{complete && <CompleteIcon width={16} height={16} />}
{`${title}${position ? ` - ${position}` : ''}`}
<CardTitleText>{`${title}${position ? ` - ${position}` : ''}`}</CardTitleText>
</CardTitle>
)}
<ListCardBadges>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import styled, { css } from 'styled-components/macro';
import theme from '../../../App/ThemeStyles';
const InputWrapper = styled.div<{ width: string }>`
position: relative;
@ -57,14 +58,14 @@ const InputInput = styled.input<{
background: ${props => props.focusBg};
}
&:focus ~ ${InputLabel} {
color: rgba(115, 103, 240);
color: ${props => props.theme.colors.primary};
transform: translate(-3px, -90%);
}
${props =>
props.hasValue &&
css`
& ~ ${InputLabel} {
color: rgba(115, 103, 240);
color: ${props.theme.colors.primary};
transform: translate(-3px, -90%);
}
`}
@ -115,8 +116,8 @@ const ControlledInput = ({
}: ControlledInputProps) => {
const $input = useRef<HTMLInputElement>(null);
const [hasValue, setHasValue] = useState(false);
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561';
const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)';
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : theme.colors.alternate;
const focusBg = variant === 'normal' ? theme.colors.bg.secondary : theme.colors.bg.primary;
useEffect(() => {
if (autoFocus && $input && $input.current) {
$input.current.focus();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -391,16 +391,16 @@ const SimpleLists: React.FC<SimpleProps> = ({
</Draggable>
);
})}
<AddList
onSave={listName => {
onCreateTaskGroup(listName);
}}
/>
{provided.placeholder}
</Container>
)}
</Droppable>
</DragDropContext>
<AddList
onSave={listName => {
onCreateTaskGroup(listName);
}}
/>
</BoardWrapper>
</BoardContainer>
);

View File

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

View File

@ -22,10 +22,10 @@ export const LoadingSpinnerWrapper = styled.div<{ color: string; size: string; b
width: ${props => props.size};
height: ${props => props.size};
margin: ${props => props.thickness};
border: ${props => props.thickness} solid rgba(${props => props.theme.colors[props.color]});
border: ${props => props.thickness} solid ${props => props.theme.colors[props.color]};
border-radius: 50%;
animation: 1.2s ${LoadingSpinnerKeyframes} cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: rgba(${props => props.theme.colors[props.color]}) transparent transparent transparent;
border-color: ${props => props.theme.colors[props.color]} transparent transparent transparent;
}
& > div:nth-child(1) {

View File

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

View File

@ -1,5 +1,6 @@
import styled from 'styled-components';
import Button from 'shared/components/Button';
import { mixin } from 'shared/utils/styles';
export const Wrapper = styled.div`
background: #eff2f7;
@ -68,7 +69,7 @@ export const FormIcon = styled.div`
export const FormError = styled.span`
font-size: 0.875rem;
color: rgb(234, 84, 85);
color: ${props => props.theme.colors.danger};
`;
export const LoginButton = styled(Button)``;
@ -99,5 +100,5 @@ export const LogoWrapper = styled.div`
padding-bottom: 16px;
margin-bottom: 24px;
color: rgb(222, 235, 255);
border-bottom: 1px solid rgba(65, 69, 97, 0.65);
border-bottom: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0.65)};
`;

View File

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

View File

@ -20,14 +20,14 @@ export const MemberManagerSearch = styled(TextareaAutosize)`
font-size: 14px;
font-weight: 400;
background: #262c49;
background: ${props => props.theme.colors.bg.secondary};
outline: none;
color: #c2c6dc;
border-color: #414561;
color: ${props => props.theme.colors.text.primary};
border-color: ${props => props.theme.colors.border};
&:focus {
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
background: ${mixin.darken('#262c49', 0.15)};
box-shadow: ${props => props.theme.colors.primary} 0px 0px 0px 1px;
background: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.15)};
}
`;
@ -66,8 +66,8 @@ export const BoardMemberListItemContent = styled(Member)`
color: #c2c6dc;
&:hover {
background-color: rgba(${props => props.theme.colors.primary});
color: rgba(${props => props.theme.colors.text.secondary});
background-color: ${props => props.theme.colors.primary};
color: ${props => props.theme.colors.text.secondary};
}
`;
@ -80,7 +80,7 @@ export const ProfileIcon = styled.div`
justify-content: center;
color: #c2c6dc;
font-weight: 700;
background: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
cursor: pointer;
margin-right: 6px;
`;

View File

@ -1,6 +1,7 @@
import styled, { css } from 'styled-components';
import Button from 'shared/components/Button';
import { Checkmark } from 'shared/icons';
import { mixin } from 'shared/utils/styles';
export const RoleCheckmark = styled(Checkmark)`
padding-left: 4px;
@ -80,36 +81,36 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
? css`
user-select: none;
pointer-events: none;
color: rgba(${props.theme.colors.text.primary}, 0.4);
color: ${mixin.rgba(props.theme.colors.text.primary, 0.4)};
`
: css`
cursor: pointer;
&:hover {
background: rgb(115, 103, 240);
background: ${props.theme.colors.primary};
}
`}
`;
export const CurrentPermission = styled.span`
margin-left: 4px;
color: rgba(${props => props.theme.colors.text.secondary}, 0.4);
color: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.4)};
`;
export const Separator = styled.div`
height: 1px;
border-top: 1px solid #414561;
border-top: 1px solid ${props => props.theme.colors.alternate};
margin: 0.25rem !important;
`;
export const WarningText = styled.span`
display: flex;
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.4)};
padding: 6px;
`;
export const DeleteDescription = styled.div`
font-size: 14px;
color: rgba(${props => props.theme.colors.text.primary});
color: ${props => props.theme.colors.text.primary};
`;
export const RemoveMemberButton = styled(Button)`

View File

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

View File

@ -30,9 +30,9 @@ const CloseIcon = styled(Cross)`
top: 16px;
right: -32px;
cursor: pointer;
fill: rgba(${props => props.theme.colors.text.primary});
fill: ${props => props.theme.colors.text.primary};
&:hover {
fill: rgba(${props => props.theme.colors.text.secondary});
fill: ${props => props.theme.colors.text.secondary};
}
`;

View File

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

View File

@ -1,4 +1,5 @@
import styled, { css } from 'styled-components';
import { mixin } from 'shared/utils/styles';
export const Logo = styled.div``;
@ -9,7 +10,7 @@ export const LogoTitle = styled.div`
font-size: 24px;
font-weight: 600;
transition: visibility, opacity, transform 0.25s ease;
color: #7367f0;
color: #22ff00;
`;
export const ActionContainer = styled.div`
position: relative;
@ -46,8 +47,8 @@ export const ActionButtonWrapper = styled.div<{ active?: boolean }>`
${props =>
props.active &&
css`
background: rgb(115, 103, 240);
box-shadow: 0 0 10px 1px rgba(115, 103, 240, 0.7);
background: ${props.theme.colors.primary};
box-shadow: 0 0 10px 1px ${mixin.rgba(props.theme.colors.primary, 0.7)};
`}
border-radius: 6px;
cursor: pointer;
@ -73,7 +74,7 @@ export const LogoWrapper = styled.div`
color: rgb(222, 235, 255);
cursor: pointer;
transition: color 0.1s ease 0s, border 0.1s ease 0s;
border-bottom: 1px solid rgba(65, 69, 97, 0.65);
border-bottom: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0.65)};
`;
export const Container = styled.aside`
@ -87,12 +88,12 @@ export const Container = styled.aside`
transform: translateZ(0px);
background: #10163a;
transition: all 0.1s ease 0s;
border-right: 1px solid rgba(65, 69, 97, 0.65);
border-right: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0.65)};
&:hover {
width: 260px;
box-shadow: rgba(0, 0, 0, 0.6) 0px 0px 50px 0px;
border-right: 1px solid rgba(65, 69, 97, 0);
border-right: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0)};
}
&:hover ${LogoTitle} {
bottom: -12px;
@ -106,6 +107,6 @@ export const Container = styled.aside`
}
&:hover ${LogoWrapper} {
border-bottom: 1px solid rgba(65, 69, 97, 0);
border-bottom: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0)};
}
`;

View File

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

View File

@ -3,16 +3,17 @@ import styled from 'styled-components';
import { mixin } from 'shared/utils/styles';
import Select from 'react-select';
import { ArrowLeft, Cross } from 'shared/icons';
import theme from '../../../App/ThemeStyles';
function getBackgroundColor(isDisabled: boolean, isSelected: boolean, isFocused: boolean) {
if (isDisabled) {
return null;
}
if (isSelected) {
return mixin.darken('#262c49', 0.25);
return mixin.darken(theme.colors.bg.secondary, 0.25);
}
if (isFocused) {
return mixin.darken('#262c49', 0.15);
return mixin.darken(theme.colors.bg.secondary, 0.15);
}
return null;
}
@ -97,8 +98,8 @@ const ProjectName = styled.input`
font-weight: 400;
&:focus {
background: ${mixin.darken('#262c49', 0.15)};
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
background: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.15)};
box-shadow: ${props => props.theme.colors.primary} 0px 0px 0px 1px;
}
`;
const ProjectNameLabel = styled.label`
@ -126,35 +127,35 @@ const colourStyles = {
control: (styles: any, data: any) => {
return {
...styles,
backgroundColor: data.isMenuOpen ? mixin.darken('#262c49', 0.15) : '#262c49',
boxShadow: data.menuIsOpen ? 'rgb(115, 103, 240) 0px 0px 0px 1px' : 'none',
backgroundColor: data.isMenuOpen ? mixin.darken(theme.colors.bg.secondary, 0.15) : theme.colors.bg.secondary,
boxShadow: data.menuIsOpen ? `${theme.colors.primary} 0px 0px 0px 1px` : 'none',
borderRadius: '3px',
borderWidth: '1px',
borderStyle: 'solid',
borderImage: 'initial',
borderColor: '#414561',
borderColor: theme.colors.alternate,
':hover': {
boxShadow: 'rgb(115, 103, 240) 0px 0px 0px 1px',
boxShadow: `${theme.colors.primary} 0px 0px 0px 1px`,
borderRadius: '3px',
borderWidth: '1px',
borderStyle: 'solid',
borderImage: 'initial',
borderColor: '#414561',
borderColor: theme.colors.alternate,
},
':active': {
boxShadow: 'rgb(115, 103, 240) 0px 0px 0px 1px',
boxShadow: `${theme.colors.primary} 0px 0px 0px 1px`,
borderRadius: '3px',
borderWidth: '1px',
borderStyle: 'solid',
borderImage: 'initial',
borderColor: 'rgb(115, 103, 240)',
borderColor: `${theme.colors.primary}`,
},
};
},
menu: (styles: any) => {
return {
...styles,
backgroundColor: mixin.darken('#262c49', 0.15),
backgroundColor: mixin.darken(theme.colors.bg.secondary, 0.15),
};
},
dropdownIndicator: (styles: any) => ({ ...styles, color: '#c2c6dc', ':hover': { color: '#c2c6dc' } }),
@ -167,11 +168,11 @@ const colourStyles = {
cursor: isDisabled ? 'not-allowed' : 'default',
':active': {
...styles[':active'],
backgroundColor: !isDisabled && (isSelected ? mixin.darken('#262c49', 0.25) : '#fff'),
backgroundColor: !isDisabled && (isSelected ? mixin.darken(theme.colors.bg.secondary, 0.25) : '#fff'),
},
':hover': {
...styles[':hover'],
backgroundColor: !isDisabled && (isSelected ? 'rgb(115, 103, 240)' : 'rgb(115, 103, 240)'),
backgroundColor: !isDisabled && (isSelected ? theme.colors.primary : theme.colors.primary),
},
};
},
@ -209,8 +210,8 @@ const CreateButton = styled.button`
&:hover {
color: #fff;
background: rgb(115, 103, 240);
border-color: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
border-color: ${props => props.theme.colors.primary};
}
`;
type NewProjectProps = {
@ -262,7 +263,7 @@ const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose,
onChange={(e: any) => {
setTeam(e.value);
}}
value={options.filter(d => d.value === team)}
value={options.find(d => d.value === team)}
styles={colourStyles}
classNamePrefix="teamSelect"
options={options}

View File

@ -37,7 +37,7 @@ const ItemTextContainer = styled.div`
const ItemTextTitle = styled.span`
font-weight: 500;
display: block;
color: rgba(${props => props.theme.colors.primary});
color: ${props => props.theme.colors.primary};
font-size: 14px;
`;
const ItemTextDesc = styled.span`
@ -76,21 +76,21 @@ const NotificationHeader = styled.div`
text-align: center;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
background: rgba(${props => props.theme.colors.primary});
background: ${props => props.theme.colors.primary};
`;
const NotificationHeaderTitle = styled.span`
font-size: 14px;
color: rgba(${props => props.theme.colors.text.secondary});
color: ${props => props.theme.colors.text.secondary};
`;
const NotificationFooter = styled.div`
cursor: pointer;
padding: 0.5rem;
text-align: center;
color: rgba(${props => props.theme.colors.primary});
color: ${props => props.theme.colors.primary};
&:hover {
background: #10163a;
background: ${props => props.theme.colors.bg.primary};
}
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;

View File

@ -4,7 +4,7 @@ import styled from 'styled-components';
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
const WhiteCheckmark = styled(Checkmark)`
fill: rgba(${props => props.theme.colors.text.secondary});
fill: ${props => props.theme.colors.text.secondary};
`;
type Props = {
labelColors: Array<LabelColor>;

View File

@ -1,6 +1,7 @@
import styled, { css } from 'styled-components';
import { mixin } from 'shared/utils/styles';
import ControlledInput from 'shared/components/ControlledInput';
import theme from 'App/ThemeStyles';
export const Container = styled.div<{
invertY: boolean;
@ -86,6 +87,12 @@ export const HeaderTitle = styled.span`
export const Content = styled.div`
max-height: 632px;
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar-track-piece {
background: ${props => props.theme.colors.bg.primary};
border-radius: 20px;
}
`;
export const LabelSearch = styled(ControlledInput)`
@ -176,7 +183,7 @@ export const LabelIcon = styled.div`
cursor: pointer;
&:hover {
background: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
}
`;
@ -233,8 +240,8 @@ export const FieldName = styled.input`
font-weight: 400;
&:focus {
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
background: ${mixin.darken('#262c49', 0.15)};
box-shadow: ${props => props.theme.colors.primary} 0px 0px 0px 1px;
background: ${mixin.darken(theme.colors.bg.secondary, 0.15)};
}
`;
@ -258,7 +265,7 @@ export const LabelBox = styled.span<{ color: string }>`
`;
export const SaveButton = styled.input`
background: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
box-shadow: none;
border: none;
color: #fff;
@ -296,7 +303,7 @@ export const DeleteButton = styled.input`
&:hover {
color: #fff;
background: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
border-color: transparent;
}
`;
@ -317,7 +324,7 @@ export const CreateLabelButton = styled.button`
cursor: pointer;
&:hover {
background: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
}
`;

View File

@ -4,6 +4,7 @@ import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { createPortal } from 'react-dom';
import NOOP from 'shared/utils/noop';
import produce from 'immer';
import theme from 'App/ThemeStyles';
import {
Container,
ContainerDiamond,
@ -16,9 +17,9 @@ import {
} from './Styles';
function getPopupOptions(options?: PopupOptions) {
const popupOptions = {
const popupOptions: PopupOptionsInternal = {
borders: true,
diamondColor: '#262c49',
diamondColor: theme.colors.bg.secondary,
targetPadding: '10px',
showDiamond: true,
width: 316,
@ -39,6 +40,9 @@ function getPopupOptions(options?: PopupOptions) {
if (options.diamondColor) {
popupOptions.diamondColor = options.diamondColor;
}
if (options.onClose) {
popupOptions.onClose = options.onClose;
}
}
return popupOptions;
}
@ -135,6 +139,7 @@ type PopupOptionsInternal = {
targetPadding: string;
diamondColor: string;
showDiamond: boolean;
onClose?: () => void;
};
type PopupOptions = {
@ -143,6 +148,7 @@ type PopupOptions = {
width?: number | null;
borders?: boolean | null;
diamondColor?: string | null;
onClose?: () => void;
};
const defaultState = {
isOpen: false,
@ -238,7 +244,12 @@ export const PopupProvider: React.FC = ({ children }) => {
top={currentState.top}
targetPadding={currentState.options.targetPadding}
left={currentState.left}
onClose={() => setState(defaultState)}
onClose={() => {
if (currentState.options && currentState.options.onClose) {
currentState.options.onClose();
}
setState(defaultState);
}}
width={currentState.options.width}
>
{currentState.content}

View File

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

View File

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

View File

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

View File

@ -1,6 +1,4 @@
import styled, { keyframes, css } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea';
import { mixin } from 'shared/utils/styles';
export const Wrapper = styled.div<{ open: boolean }>`
background: rgba(0, 0, 0, 0.55);
@ -30,7 +28,7 @@ export const Container = styled.div<{ fixed: boolean; width: number; top: number
export const SaveButton = styled.button`
cursor: pointer;
background: rgb(115, 103, 240);
background: ${props => props.theme.colors.primary};
box-shadow: none;
border: none;
color: #fff;

View File

@ -1,5 +1,6 @@
import styled from 'styled-components';
import Button from 'shared/components/Button';
import { mixin } from 'shared/utils/styles';
export const Wrapper = styled.div`
background: #eff2f7;
@ -68,7 +69,7 @@ export const FormIcon = styled.div`
export const FormError = styled.span`
font-size: 0.875rem;
color: rgb(234, 84, 85);
color: ${props => props.theme.colors.danger};
`;
export const LoginButton = styled(Button)``;
@ -99,5 +100,5 @@ export const LogoWrapper = styled.div`
padding-bottom: 16px;
margin-bottom: 24px;
color: rgb(222, 235, 255);
border-bottom: 1px solid rgba(65, 69, 97, 0.65);
border-bottom: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0.65)};
`;

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