39 Commits
0.3.0 ... 0.3.2

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
102 changed files with 4675 additions and 5409 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

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

@ -32,7 +32,7 @@
"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",
@ -43,7 +43,7 @@
"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",
@ -73,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"
@ -99,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

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

@ -4,6 +4,7 @@ import * as H from 'history';
import Dashboard from 'Dashboard';
import Admin from 'Admin';
import MyTasks from 'MyTasks';
import Confirm from 'Confirm';
import Projects from 'Projects';
import Project from 'Projects/Project';
@ -69,6 +70,7 @@ const AuthorizedRoutes = () => {
<Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
<Route path="/tasks" component={MyTasks} />
</MainContent>
</Switch>
);

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,177 +8,16 @@ 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: ${props => props.theme.colors.bg.secondary};
bottom: 0;
content: '';
left: 0;
opacity: 0.88;
position: absolute;
right: 0;
top: 0;
}
`;
const TeamProjectAvatar = styled.div<{ color: string }>`
background-image: url(null);
background-color: ${props => props.color};
display: inline-block;
flex: 0 0 auto;
background-size: cover;
border-radius: 3px 0 0 3px;
height: 36px;
width: 36px;
position: relative;
opacity: 0.7;
`;
const TeamProjectContent = styled.div`
display: flex;
position: relative;
flex: 1;
width: 100%;
padding: 9px 0 9px 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TeamProjectTitle = styled.div`
font-weight: 700;
display: block;
padding-right: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const TeamProjectContainer = styled.div`
box-sizing: border-box;
border-radius: 3px;
position: relative;
margin: 0 4px 4px 0;
min-width: 0;
&:hover ${TeamProjectTitle} {
color: ${props => props.theme.colors.text.secondary};
}
&:hover ${TeamProjectAvatar} {
opacity: 1;
}
&:hover ${TeamProjectBackground}:before {
opacity: 0.78;
}
`;
const colors = [theme.colors.primary, theme.colors.secondary];
const ProjectFinder = () => {
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'cache-and-network' });
if (data) {
const { projects, teams } = data;
const personalProjects = data.projects.filter(p => p.team === null);
const projectTeams = teams.map(team => {
return {
id: team.id,
name: team.name,
projects: projects.filter(project => project.team && project.team.id === team.id),
};
});
return (
<>
<TeamContainer>
<TeamTitle>Personal</TeamTitle>
<TeamProjects>
{personalProjects.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>
{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>loading</span>;
};
type ProjectPopupProps = {
history: any;
name: string;
@ -439,6 +277,9 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
onDashboardClick={() => {
history.push('/');
}}
onMyTasksClick={() => {
history.push('/tasks');
}}
projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
onProfileClick={onProfileClick}

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

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

View File

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

View File

@ -426,6 +426,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
name,
complete: false,
completedAt: null,
hasTime: false,
taskGroup: {
__typename: 'TaskGroup',
id: taskGroup.id,
@ -793,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}
/>

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';
@ -407,9 +408,7 @@ const Details: React.FC<DetailsProps> = ({
});
const [updateTaskComment] = useUpdateTaskCommentMutation();
const [editableComment, setEditableComment] = useState<null | string>(null);
if (!data) {
return null;
}
const isLoading = true;
return (
<>
<Modal
@ -418,7 +417,7 @@ const Details: React.FC<DetailsProps> = ({
history.push(projectURL);
}}
renderContent={() => {
return (
return data ? (
<TaskDetails
onCancelCommentEdit={() => setEditableComment(null)}
onUpdateComment={(commentID, message) => {
@ -633,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}
/>
@ -647,6 +646,8 @@ const Details: React.FC<DetailsProps> = ({
);
}}
/>
) : (
<TaskDetailsLoading />
);
}}
/>

View File

@ -150,6 +150,7 @@ const Wrapper = styled.div`
flex-direction: row;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
`;
const ProjectSectionTitleWrapper = styled.div`

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,19 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import theme from 'App/ThemeStyles';
import AddList from '.';
export default {
component: AddList,
title: 'AddList',
parameters: {
backgrounds: [
{ name: 'gray', value: theme.colors.bg.secondary, default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
export const Default = () => {
return <AddList onSave={action('on save')} />;
};

View File

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

View File

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

View File

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

View File

@ -227,18 +227,19 @@ export const ListCardOperation = styled.span`
}
`;
export const CardTitle = styled.span`
export const CardTitle = styled.div`
clear: both;
margin: 0 0 4px;
overflow: hidden;
text-decoration: none;
color: ${props => props.theme.colors.text.primary};
display: block;
align-items: center;
`;
export const CardTitleText = styled.span`
word-wrap: break-word;
line-height: 18px;
font-size: 14px;
color: ${props => props.theme.colors.text.primary};
display: flex;
align-items: center;
`;
export const CardMembers = styled.div`
@ -251,6 +252,7 @@ export const CompleteIcon = styled(CheckCircle)`
fill: ${props => props.theme.colors.success};
margin-right: 4px;
flex-shrink: 0;
margin-bottom: -2px;
`;
export const EditorContent = styled.div`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, forwardRef } from 'react';
import React, { useState, useEffect, forwardRef, useRef, useCallback } from 'react';
import dayjs from 'dayjs';
import styled from 'styled-components';
import DatePicker from 'react-datepicker';
@ -8,11 +8,27 @@ import { getYear, getMonth } from 'date-fns';
import { useForm, Controller } from 'react-hook-form';
import NOOP from 'shared/utils/noop';
import { Wrapper, ActionWrapper, RemoveDueDate, DueDateInput, DueDatePickerWrapper, ConfirmAddDueDate } from './Styles';
import {
Wrapper,
RemoveDueDate,
DueDateInput,
DueDatePickerWrapper,
ConfirmAddDueDate,
DateRangeInputs,
AddDateRange,
ActionIcon,
ActionsWrapper,
ClearButton,
ActionsSeparator,
ActionClock,
ActionLabel,
} from './Styles';
import { Clock, Cross } from 'shared/icons';
import Select from 'react-select/src/Select';
type DueDateManagerProps = {
task: Task;
onDueDateChange: (task: Task, newDueDate: Date) => void;
onDueDateChange: (task: Task, newDueDate: Date, hasTime: boolean) => void;
onRemoveDueDate: (task: Task) => void;
onCancel: () => void;
};
@ -52,14 +68,20 @@ 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 {
& 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;
@ -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,43 +0,0 @@
import React from 'react';
import BaseStyles from 'App/BaseStyles';
import NormalizeStyles from 'App/NormalizeStyles';
import theme from 'App/ThemeStyles';
import styled, { ThemeProvider } from 'styled-components';
import { User } from 'shared/icons';
import Input from '.';
export default {
component: Input,
title: 'Input',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const Wrapper = styled.div`
background: ${props => props.theme.colors.bg.primary};
padding: 45px;
margin: 25px;
display: flex;
flex-direction: column;
`;
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<ThemeProvider theme={theme}>
<Wrapper>
<Input label="Label placeholder" />
<Input width="100%" placeholder="Placeholder" />
<Input icon={<User width={20} height={20} />} width="100%" placeholder="Placeholder" />
</Wrapper>
</ThemeProvider>
</>
);
};

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,9 @@
import React, { useRef, useState, useEffect } from 'react';
import { usePopup } from 'shared/components/PopupMenu';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { At, Paperclip, Smile } from 'shared/icons';
import { Picker, Emoji } from 'emoji-mart';
import Task from 'shared/icons/Task';
import {
CommentTextArea,
CommentEditorContainer,
@ -8,11 +13,6 @@ import {
CommentProfile,
CommentInnerWrapper,
} from './Styles';
import { usePopup } from 'shared/components/PopupMenu';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { At, Paperclip, Smile } from 'shared/icons';
import { Picker, Emoji } from 'emoji-mart';
import Task from 'shared/icons/Task';
type CommentCreatorProps = {
me?: TaskUser;
@ -21,10 +21,12 @@ type CommentCreatorProps = {
message?: string | null;
onCreateComment: (message: string) => void;
onCancelEdit?: () => void;
disabled?: boolean;
};
const CommentCreator: React.FC<CommentCreatorProps> = ({
me,
disabled = false,
message,
onMemberProfile,
onCreateComment,
@ -70,6 +72,7 @@ const CommentCreator: React.FC<CommentCreatorProps> = ({
showCommentActions={showCommentActions}
placeholder="Write a comment..."
ref={$comment}
disabled={disabled}
value={comment}
onChange={e => setComment(e.currentTarget.value)}
onFocus={() => {
@ -91,12 +94,11 @@ const CommentCreator: React.FC<CommentCreatorProps> = ({
<div ref={$emojiCart}>
<Picker
onClick={emoji => {
console.log(emoji);
if ($comment && $comment.current) {
let textToInsert = `${emoji.colons} `;
let cursorPosition = $comment.current.selectionStart;
let textBeforeCursorPosition = $comment.current.value.substring(0, cursorPosition);
let textAfterCursorPosition = $comment.current.value.substring(
const textToInsert = `${emoji.colons} `;
const cursorPosition = $comment.current.selectionStart;
const textBeforeCursorPosition = $comment.current.value.substring(0, cursorPosition);
const textAfterCursorPosition = $comment.current.value.substring(
cursorPosition,
$comment.current.value.length,
);

View File

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

View File

@ -1,8 +1,9 @@
import styled, { css } from 'styled-components';
import styled, { css, keyframes } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea';
import { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button';
import TaskAssignee from 'shared/components/TaskAssignee';
import theme from 'App/ThemeStyles';
export const Container = styled.div`
display: flex;
@ -16,7 +17,7 @@ export const LeftSidebar = styled.div`
background: #222740;
`;
export const MarkCompleteButton = styled.button<{ invert: boolean }>`
export const MarkCompleteButton = styled.button<{ invert: boolean; disabled?: boolean }>`
padding: 4px 8px;
position: relative;
border: none;
@ -62,6 +63,11 @@ export const MarkCompleteButton = styled.button<{ invert: boolean }>`
color: ${props.theme.colors.success};
}
`}
${props =>
props.invert &&
css`
opacity: 0.6;
`}
`;
export const LeftSidebarContent = styled.div`
@ -89,24 +95,55 @@ export const SidebarTitle = styled.div`
text-transform: uppercase;
`;
export const SidebarButton = styled.div`
export const defaultBaseColor = theme.colors.bg.primary;
export const defaultHighlightColor = mixin.lighten(theme.colors.bg.primary, 0.25);
export const skeletonKeyframes = keyframes`
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
`;
export const SidebarButton = styled.div<{ loading?: boolean }>`
font-size: 14px;
color: ${props => props.theme.colors.text.primary};
min-height: 32px;
width: 100%;
padding: 9px 8px 7px 8px;
border-color: transparent;
border-radius: 6px;
${props =>
props.loading
? css`
background: ${props.theme.colors.bg.primary};
`
: css`
padding: 9px 8px 7px 8px;
cursor: pointer;
border-color: transparent;
border-width: 1px;
border-style: solid;
display: inline-block;
outline: 0;
cursor: pointer;
&:hover {
border-color: #414561;
}
`};
display: inline-block;
outline: 0;
`;
export const SidebarSkeleton = styled.div`
background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor});
background-size: 200px 100%;
background-repeat: no-repeat;
border-radius: 6px;
padding: 1px;
animation: ${skeletonKeyframes} 1.2s ease-in-out infinite;
width: 100%;
height: 100%;
`;
export const SidebarButtonText = styled.span`
@ -141,18 +178,18 @@ export const HeaderLeft = styled.div`
justify-content: flex-start;
`;
export const TaskDetailsTitleWrapper = styled.div`
export const TaskDetailsTitleWrapper = styled.div<{ loading?: boolean }>`
width: 100%;
margin: 8px 0 4px 0;
display: inline-block;
display: flex;
border-radius: 6px;
${props => props.loading && `background: ${props.theme.colors.bg.primary};`}
`;
export const TaskDetailsTitle = styled(TextareaAutosize)`
export const TaskDetailsTitle = styled(TextareaAutosize)<{ loading?: boolean }>`
padding: 9px 8px 7px 8px;
border-color: transparent;
border-radius: 6px;
border-width: 1px;
border-style: solid;
width: 100%;
color: #c2c6dc;
display: inline-block;
@ -161,13 +198,25 @@ export const TaskDetailsTitle = styled(TextareaAutosize)`
font-weight: 700;
background: none;
${props =>
props.loading
? css`
background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor});
background-size: 200px 100%;
background-repeat: no-repeat;
animation: ${skeletonKeyframes} 1.2s ease-in-out infinite;
`
: css`
&:hover {
border-color: #414561;
border-width: 1px;
border-style: solid;
}
&:focus {
border-color: ${props => props.theme.colors.primary};
border-color: ${props.theme.colors.primary};
}
`}
`;
export const DueDateTitle = styled.div`
@ -604,7 +653,7 @@ export const ActivityItemComment = styled.div<{ editable: boolean }>`
${props => props.editable && 'width: 100%;'}
& span {
display: flex;
display: inline-flex;
align-items: center;
}
& ul {

View File

@ -27,7 +27,6 @@ import { Picker, Emoji } from 'emoji-mart';
import 'emoji-mart/css/emoji-mart.css';
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
import dayjs from 'dayjs';
import ActivityMessage from './ActivityMessage';
import Task from 'shared/icons/Task';
import {
ActivityItemHeader,
@ -83,6 +82,7 @@ import {
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
import onDragEnd from './onDragEnd';
import { plugin as em } from './remark';
import ActivityMessage from './ActivityMessage';
const parseEmojis = (value: string) => {
const emojisArray = toArray(value);
@ -293,6 +293,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
const $noMemberBtn = useRef<HTMLDivElement>(null);
const $addMemberBtn = useRef<HTMLDivElement>(null);
const $dueDateBtn = useRef<HTMLDivElement>(null);
const $detailsTitle = useRef<HTMLTextAreaElement>(null);
const activityStream: Array<{ id: string; data: { time: string; type: 'comment' | 'activity' } }> = [];
@ -341,7 +342,9 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
}}
>
{task.dueDate ? (
<SidebarButtonText>{dayjs(task.dueDate).format('MMM D [at] h:mm A')}</SidebarButtonText>
<SidebarButtonText>
{dayjs(task.dueDate).format(task.hasTime ? 'MMM D [at] h:mm A' : 'MMMM D')}
</SidebarButtonText>
) : (
<SidebarButtonText>No due date</SidebarButtonText>
)}
@ -438,6 +441,15 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<TaskDetailsTitleWrapper>
<TaskDetailsTitle
value={taskName}
ref={$detailsTitle}
onKeyDown={e => {
if (e.keyCode === 13) {
e.preventDefault();
if ($detailsTitle && $detailsTitle.current) {
$detailsTitle.current.blur();
}
}
}}
onChange={e => {
setTaskName(e.currentTarget.value);
}}

View File

@ -44,9 +44,16 @@ function plugin(options) {
visit(tree, 'paragraph', function(node) {
console.log(tree);
// node.value = node.value.replace(RE_EMOJI, getEmoji);
node.type = 'html';
node.tagName = 'div';
node.value = node.children[0].value.replace(RE_EMOJI, getEmoji);
// jnode.type = 'html';
// jnode.tagName = 'div';
// jnode.value = '';
for (let nodeIdx = 0; nodeIdx < node.children.length; nodeIdx++) {
if (node.children[nodeIdx].type === 'text') {
node.children[nodeIdx].type = 'html';
node.children[nodeIdx].tagName = 'div';
node.children[nodeIdx].value = node.children[nodeIdx].value.replace(RE_EMOJI, getEmoji);
}
}
if (emoticonEnable) {
// node.value = node.value.replace(RE_SHORT, getEmojiByShortCode);

View File

@ -1,11 +1,9 @@
import styled, { css } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea';
import { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button';
import { Taskcafe } from 'shared/icons';
import { NavLink, Link } from 'react-router-dom';
import TaskAssignee from 'shared/components/TaskAssignee';
import { useRef } from 'react';
export const ProjectMember = styled(TaskAssignee)<{ zIndex: number }>`
z-index: ${props => props.zIndex};
@ -43,12 +41,51 @@ export const BreadcrumpSeparator = styled.span`
font-size: 18px;
margin: 0px 10px;
`;
export const ProjectInfo = styled.div`
display: flex;
flex-direction: column;
flex: 1;
`;
export const ProjectSwitchInner = styled.div`
border-radius: 12px;
height: 48px;
width: 48px;
box-shadow: inset 0 -2px rgba(0, 0, 0, 0.05);
align-items: center;
background: #cbd4db;
display: flex;
flex: 0 0 auto;
flex-direction: column;
justify-content: center;
background-color: ${props => props.theme.colors.primary};
`;
export const ProjectSwitch = styled.div`
align-self: center;
position: relative;
margin-right: 16px;
cursor: pointer;
&::after {
border-radius: 12px;
bottom: 0;
content: '';
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
}
&:hover::after {
background-color: rgba(0, 0, 0, 0.05);
}
`;
export const ProjectActions = styled.div`
flex: 1;
align-items: flex-start;
display: flex;
flex-direction: column;
min-width: 1px;
`;
@ -67,6 +104,11 @@ export const ProfileNameWrapper = styled.div`
line-height: 1.25;
`;
export const NavbarLink = styled(Link)`
margin-right: 20px;
cursor: pointer;
`;
export const IconContainerWrapper = styled.div<{ disabled?: boolean }>`
margin-right: 20px;
cursor: pointer;
@ -163,7 +205,20 @@ export const ProjectName = styled.h1`
padding: 3px 10px 3px 8px;
margin: -4px 0;
`;
export const ProjectNameTextarea = styled(TextareaAutosize)`
export const ProjectNameWrapper = styled.div`
position: relative;
`;
export const ProjectNameSpan = styled.div`
padding: 3px 10px 3px 8px;
font-weight: 600;
font-size: 20px;
`;
export const ProjectNameTextarea = styled.input`
position: absolute;
width: 100%;
left: 0;
top: 0;
border: none;
resize: none;
overflow: hidden;
@ -171,7 +226,7 @@ export const ProjectNameTextarea = styled(TextareaAutosize)`
background: transparent;
border-radius: 3px;
box-shadow: none;
margin: -4px 0;
margin: 0;
letter-spacing: normal;
word-spacing: normal;

View File

@ -1,49 +0,0 @@
import React, { useState } from 'react';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import { action } from '@storybook/addon-actions';
import TopNavbar from '.';
import theme from '../../../App/ThemeStyles';
export default {
component: TopNavbar,
title: 'TopNavbar',
// Our exports that end in "Data" are not stories.
excludeStories: /.*Data$/,
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#f8f8f8' },
{ name: 'darkBlue', value: theme.colors.bg.secondary, default: true },
],
},
};
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<TopNavbar
onOpenProjectFinder={action('finder')}
name="Projects"
user={{
id: '1',
fullName: 'Jordan Knott',
profileIcon: {
url: null,
initials: 'JK',
bgColor: '#000',
},
}}
onChangeRole={action('change role')}
onNotificationClick={action('notifications click')}
onOpenSettings={action('open settings')}
onDashboardClick={action('open dashboard')}
onRemoveFromBoard={action('remove project')}
onProfileClick={action('profile click')}
/>
</>
);
};

View File

@ -7,12 +7,17 @@ import { RoleCode } from 'shared/generated/graphql';
import NOOP from 'shared/utils/noop';
import { useHistory } from 'react-router';
import {
ProjectInfo,
NavbarLink,
TaskcafeLogo,
TaskcafeTitle,
ProjectFinder,
LogoContainer,
NavSeparator,
IconContainerWrapper,
ProjectSwitch,
ProjectNameWrapper,
ProjectNameSpan,
ProjectNameTextarea,
InviteButton,
GlobalActions,
@ -30,6 +35,7 @@ import {
ProfileNameSecondary,
ProjectMember,
ProjectMembers,
ProjectSwitchInner,
} from './Styles';
type IconContainerProps = {
@ -73,7 +79,7 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
}) => {
const [isEditProjectName, setEditProjectName] = useState(false);
const [projectName, setProjectName] = useState(initialProjectName);
const $projectName = useRef<HTMLTextAreaElement>(null);
const $projectName = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditProjectName && $projectName && $projectName.current) {
$projectName.current.focus();
@ -84,7 +90,7 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
setProjectName(initialProjectName);
}, [initialProjectName]);
const onProjectNameChange = (event: React.FormEvent<HTMLTextAreaElement>): void => {
const onProjectNameChange = (event: React.FormEvent<HTMLInputElement>): void => {
setProjectName(event.currentTarget.value);
};
const onProjectNameBlur = () => {
@ -106,6 +112,8 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
return (
<>
{isEditProjectName ? (
<ProjectNameWrapper>
<ProjectNameSpan>{projectName}</ProjectNameSpan>
<ProjectNameTextarea
ref={$projectName}
onChange={onProjectNameChange}
@ -114,6 +122,7 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
spellCheck={false}
value={projectName}
/>
</ProjectNameWrapper>
) : (
<ProjectName
onClick={() => {
@ -179,6 +188,7 @@ type NavBarProps = {
onRemoveFromBoard?: (userID: string) => void;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
onInvitedMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, email: string) => void;
onMyTasksClick: () => void;
};
const NavBar: React.FC<NavBarProps> = ({
@ -201,6 +211,7 @@ const NavBar: React.FC<NavBarProps> = ({
onProfileClick,
onNotificationClick,
onDashboardClick,
onMyTasksClick,
user,
projectMembers,
onOpenSettings,
@ -212,10 +223,17 @@ const NavBar: React.FC<NavBarProps> = ({
};
const history = useHistory();
const { showPopup } = usePopup();
const $finder = useRef<HTMLDivElement>(null);
return (
<NavbarWrapper>
<NavbarHeader>
<ProjectActions>
<ProjectSwitch ref={$finder} onClick={e => onOpenProjectFinder($finder)}>
<ProjectSwitchInner>
<TaskcafeLogo innerColor="#9f46e4" outerColor="#000" width={32} height={32} />
</ProjectSwitchInner>
</ProjectSwitch>
<ProjectInfo>
<ProjectMeta>
{name && (
<ProjectHeading
@ -246,9 +264,9 @@ const NavBar: React.FC<NavBarProps> = ({
})}
</ProjectTabs>
)}
</ProjectInfo>
</ProjectActions>
<LogoContainer to="/">
<TaskcafeLogo width={32} height={32} />
<TaskcafeTitle>Taskcafé</TaskcafeTitle>
</LogoContainer>
<GlobalActions>
@ -303,12 +321,12 @@ const NavBar: React.FC<NavBarProps> = ({
<ProjectFinder onClick={onOpenProjectFinder} variant="gradient">
Projects
</ProjectFinder>
<IconContainer onClick={() => onDashboardClick()}>
<NavbarLink to="">
<HomeDashboard width={20} height={20} />
</IconContainer>
<IconContainer disabled onClick={NOOP}>
</NavbarLink>
<NavbarLink to="/tasks">
<CheckCircle width={20} height={20} />
</IconContainer>
</NavbarLink>
<IconContainer disabled onClick={NOOP}>
<ListUnordered width={20} height={20} />
</IconContainer>

View File

@ -215,6 +215,7 @@ export type Task = {
position: Scalars['Float'];
description?: Maybe<Scalars['String']>;
dueDate?: Maybe<Scalars['Time']>;
hasTime: Scalars['Boolean'];
complete: Scalars['Boolean'];
completedAt?: Maybe<Scalars['Time']>;
assigned: Array<Member>;
@ -301,6 +302,7 @@ export type Query = {
invitedUsers: Array<InvitedUserAccount>;
labelColors: Array<LabelColor>;
me: MePayload;
myTasks: MyTasksPayload;
notifications: Array<Notification>;
organizations: Array<Organization>;
projects: Array<Project>;
@ -331,6 +333,11 @@ export type QueryFindUserArgs = {
};
export type QueryMyTasksArgs = {
input: MyTasks;
};
export type QueryProjectsArgs = {
input?: Maybe<ProjectsFilter>;
};
@ -681,6 +688,40 @@ export type MutationUpdateUserRoleArgs = {
input: UpdateUserRole;
};
export enum MyTasksStatus {
All = 'ALL',
Incomplete = 'INCOMPLETE',
CompleteAll = 'COMPLETE_ALL',
CompleteToday = 'COMPLETE_TODAY',
CompleteYesterday = 'COMPLETE_YESTERDAY',
CompleteOneWeek = 'COMPLETE_ONE_WEEK',
CompleteTwoWeek = 'COMPLETE_TWO_WEEK',
CompleteThreeWeek = 'COMPLETE_THREE_WEEK'
}
export enum MyTasksSort {
None = 'NONE',
Project = 'PROJECT',
DueDate = 'DUE_DATE'
}
export type MyTasks = {
status: MyTasksStatus;
sort: MyTasksSort;
};
export type ProjectTaskMapping = {
__typename?: 'ProjectTaskMapping';
projectID: Scalars['UUID'];
taskID: Scalars['UUID'];
};
export type MyTasksPayload = {
__typename?: 'MyTasksPayload';
tasks: Array<Task>;
projects: Array<ProjectTaskMapping>;
};
export type TeamRole = {
__typename?: 'TeamRole';
teamID: Scalars['UUID'];
@ -858,6 +899,7 @@ export type NewTask = {
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
position: Scalars['Float'];
assigned?: Maybe<Array<Scalars['UUID']>>;
};
export type AssignTaskInput = {
@ -883,6 +925,7 @@ export type UpdateTaskLocationPayload = {
export type UpdateTaskDueDate = {
taskID: Scalars['UUID'];
hasTime: Scalars['Boolean'];
dueDate?: Maybe<Scalars['Time']>;
};
@ -1451,7 +1494,7 @@ export type FindTaskQuery = (
{ __typename?: 'Query' }
& { findTask: (
{ __typename?: 'Task' }
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'position' | 'complete'>
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'position' | 'complete' | 'hasTime'>
& { taskGroup: (
{ __typename?: 'TaskGroup' }
& Pick<TaskGroup, 'id' | 'name'>
@ -1527,7 +1570,7 @@ export type FindTaskQuery = (
export type TaskFieldsFragment = (
{ __typename?: 'Task' }
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'complete' | 'completedAt' | 'position'>
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'hasTime' | 'complete' | 'completedAt' | 'position'>
& { badges: (
{ __typename?: 'TaskBadges' }
& { checklist?: Maybe<(
@ -1603,6 +1646,33 @@ export type MeQuery = (
) }
);
export type MyTasksQueryVariables = Exact<{
status: MyTasksStatus;
sort: MyTasksSort;
}>;
export type MyTasksQuery = (
{ __typename?: 'Query' }
& { projects: Array<(
{ __typename?: 'Project' }
& Pick<Project, 'id' | 'name'>
)>, myTasks: (
{ __typename?: 'MyTasksPayload' }
& { tasks: Array<(
{ __typename?: 'Task' }
& Pick<Task, 'id' | 'name' | 'dueDate' | 'hasTime' | 'complete' | 'completedAt'>
& { taskGroup: (
{ __typename?: 'TaskGroup' }
& Pick<TaskGroup, 'id' | 'name'>
) }
)>, projects: Array<(
{ __typename?: 'ProjectTaskMapping' }
& Pick<ProjectTaskMapping, 'projectID' | 'taskID'>
)> }
) }
);
export type DeleteProjectMutationVariables = Exact<{
projectID: Scalars['UUID'];
}>;
@ -1710,6 +1780,7 @@ export type CreateTaskMutationVariables = Exact<{
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
position: Scalars['Float'];
assigned?: Maybe<Array<Scalars['UUID']>>;
}>;
@ -2314,6 +2385,7 @@ export type UpdateTaskDescriptionMutation = (
export type UpdateTaskDueDateMutationVariables = Exact<{
taskID: Scalars['UUID'];
dueDate?: Maybe<Scalars['Time']>;
hasTime: Scalars['Boolean'];
}>;
@ -2321,7 +2393,7 @@ export type UpdateTaskDueDateMutation = (
{ __typename?: 'Mutation' }
& { updateTaskDueDate: (
{ __typename?: 'Task' }
& Pick<Task, 'id' | 'dueDate'>
& Pick<Task, 'id' | 'dueDate' | 'hasTime'>
) }
);
@ -2556,6 +2628,7 @@ export const TaskFieldsFragmentDoc = gql`
name
description
dueDate
hasTime
complete
completedAt
position
@ -3017,6 +3090,7 @@ export const FindTaskDocument = gql`
dueDate
position
complete
hasTime
taskGroup {
id
name
@ -3234,6 +3308,59 @@ export function useMeLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptio
export type MeQueryHookResult = ReturnType<typeof useMeQuery>;
export type MeLazyQueryHookResult = ReturnType<typeof useMeLazyQuery>;
export type MeQueryResult = ApolloReactCommon.QueryResult<MeQuery, MeQueryVariables>;
export const MyTasksDocument = gql`
query myTasks($status: MyTasksStatus!, $sort: MyTasksSort!) {
projects {
id
name
}
myTasks(input: {status: $status, sort: $sort}) {
tasks {
id
taskGroup {
id
name
}
name
dueDate
hasTime
complete
completedAt
}
projects {
projectID
taskID
}
}
}
`;
/**
* __useMyTasksQuery__
*
* To run a query within a React component, call `useMyTasksQuery` and pass it any options that fit your needs.
* When your component renders, `useMyTasksQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useMyTasksQuery({
* variables: {
* status: // value for 'status'
* sort: // value for 'sort'
* },
* });
*/
export function useMyTasksQuery(baseOptions?: ApolloReactHooks.QueryHookOptions<MyTasksQuery, MyTasksQueryVariables>) {
return ApolloReactHooks.useQuery<MyTasksQuery, MyTasksQueryVariables>(MyTasksDocument, baseOptions);
}
export function useMyTasksLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions<MyTasksQuery, MyTasksQueryVariables>) {
return ApolloReactHooks.useLazyQuery<MyTasksQuery, MyTasksQueryVariables>(MyTasksDocument, baseOptions);
}
export type MyTasksQueryHookResult = ReturnType<typeof useMyTasksQuery>;
export type MyTasksLazyQueryHookResult = ReturnType<typeof useMyTasksLazyQuery>;
export type MyTasksQueryResult = ApolloReactCommon.QueryResult<MyTasksQuery, MyTasksQueryVariables>;
export const DeleteProjectDocument = gql`
mutation deleteProject($projectID: UUID!) {
deleteProject(input: {projectID: $projectID}) {
@ -3436,8 +3563,10 @@ export type UpdateProjectMemberRoleMutationHookResult = ReturnType<typeof useUpd
export type UpdateProjectMemberRoleMutationResult = ApolloReactCommon.MutationResult<UpdateProjectMemberRoleMutation>;
export type UpdateProjectMemberRoleMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateProjectMemberRoleMutation, UpdateProjectMemberRoleMutationVariables>;
export const CreateTaskDocument = gql`
mutation createTask($taskGroupID: UUID!, $name: String!, $position: Float!) {
createTask(input: {taskGroupID: $taskGroupID, name: $name, position: $position}) {
mutation createTask($taskGroupID: UUID!, $name: String!, $position: Float!, $assigned: [UUID!]) {
createTask(
input: {taskGroupID: $taskGroupID, name: $name, position: $position, assigned: $assigned}
) {
...TaskFields
}
}
@ -3460,6 +3589,7 @@ export type CreateTaskMutationFn = ApolloReactCommon.MutationFunction<CreateTask
* taskGroupID: // value for 'taskGroupID'
* name: // value for 'name'
* position: // value for 'position'
* assigned: // value for 'assigned'
* },
* });
*/
@ -4692,10 +4822,13 @@ export type UpdateTaskDescriptionMutationHookResult = ReturnType<typeof useUpdat
export type UpdateTaskDescriptionMutationResult = ApolloReactCommon.MutationResult<UpdateTaskDescriptionMutation>;
export type UpdateTaskDescriptionMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateTaskDescriptionMutation, UpdateTaskDescriptionMutationVariables>;
export const UpdateTaskDueDateDocument = gql`
mutation updateTaskDueDate($taskID: UUID!, $dueDate: Time) {
updateTaskDueDate(input: {taskID: $taskID, dueDate: $dueDate}) {
mutation updateTaskDueDate($taskID: UUID!, $dueDate: Time, $hasTime: Boolean!) {
updateTaskDueDate(
input: {taskID: $taskID, dueDate: $dueDate, hasTime: $hasTime}
) {
id
dueDate
hasTime
}
}
`;
@ -4716,6 +4849,7 @@ export type UpdateTaskDueDateMutationFn = ApolloReactCommon.MutationFunction<Upd
* variables: {
* taskID: // value for 'taskID'
* dueDate: // value for 'dueDate'
* hasTime: // value for 'hasTime'
* },
* });
*/

View File

@ -6,6 +6,7 @@ query findTask($taskID: UUID!) {
dueDate
position
complete
hasTime
taskGroup {
id
name

View File

@ -6,6 +6,7 @@ const TASK_FRAGMENT = gql`
name
description
dueDate
hasTime
complete
completedAt
position

View File

@ -0,0 +1,24 @@
query myTasks($status: MyTasksStatus!, $sort: MyTasksSort!) {
projects {
id
name
}
myTasks(input: { status: $status, sort: $sort }) {
tasks {
id
taskGroup {
id
name
}
name
dueDate
hasTime
complete
completedAt
}
projects {
projectID
taskID
}
}
}

View File

@ -2,8 +2,8 @@ import gql from 'graphql-tag';
import TASK_FRAGMENT from '../fragments/task';
const CREATE_TASK_MUTATION = gql`
mutation createTask($taskGroupID: UUID!, $name: String!, $position: Float!) {
createTask(input: { taskGroupID: $taskGroupID, name: $name, position: $position }) {
mutation createTask($taskGroupID: UUID!, $name: String!, $position: Float!, $assigned: [UUID!]) {
createTask(input: { taskGroupID: $taskGroupID, name: $name, position: $position, assigned: $assigned }) {
...TaskFields
}
}

View File

@ -1,11 +1,13 @@
mutation updateTaskDueDate($taskID: UUID!, $dueDate: Time) {
mutation updateTaskDueDate($taskID: UUID!, $dueDate: Time, $hasTime: Boolean!) {
updateTaskDueDate (
input: {
taskID: $taskID
dueDate: $dueDate
hasTime: $hasTime
}
) {
id
dueDate
hasTime
}
}

View File

@ -0,0 +1,14 @@
import React from 'react';
function useStickyState<T>(defaultValue: any, key: string): [T, React.Dispatch<React.SetStateAction<T>>] {
const [value, setValue] = React.useState<T>(() => {
const stickyValue = window.localStorage.getItem(key);
return stickyValue !== null ? JSON.parse(stickyValue) : defaultValue;
});
React.useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
export default useStickyState;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Briefcase: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M320 336c0 8.84-7.16 16-16 16h-96c-8.84 0-16-7.16-16-16v-48H0v144c0 25.6 22.4 48 48 48h416c25.6 0 48-22.4 48-48V288H320v48zm144-208h-80V80c0-25.6-22.4-48-48-48H176c-25.6 0-48 22.4-48 48v48H48c-25.6 0-48 22.4-48 48v80h512v-80c0-25.6-22.4-48-48-48zm-144 0H192V96h128v32z" />
</Icon>
);
};
export default Briefcase;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const CheckCircleOutline: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 48c110.532 0 200 89.451 200 200 0 110.532-89.451 200-200 200-110.532 0-200-89.451-200-200 0-110.532 89.451-200 200-200m140.204 130.267l-22.536-22.718c-4.667-4.705-12.265-4.736-16.97-.068L215.346 303.697l-59.792-60.277c-4.667-4.705-12.265-4.736-16.97-.069l-22.719 22.536c-4.705 4.667-4.736 12.265-.068 16.971l90.781 91.516c4.667 4.705 12.265 4.736 16.97.068l172.589-171.204c4.704-4.668 4.734-12.266.067-16.971z" />
</Icon>
);
};
export default CheckCircleOutline;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const ChevronRight: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M285.476 272.971L91.132 467.314c-9.373 9.373-24.569 9.373-33.941 0l-22.667-22.667c-9.357-9.357-9.375-24.522-.04-33.901L188.505 256 34.484 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L285.475 239.03c9.373 9.372 9.373 24.568.001 33.941z" />
</Icon>
);
};
export default ChevronRight;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Cogs: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 640 512">
<path d="M512.1 191l-8.2 14.3c-3 5.3-9.4 7.5-15.1 5.4-11.8-4.4-22.6-10.7-32.1-18.6-4.6-3.8-5.8-10.5-2.8-15.7l8.2-14.3c-6.9-8-12.3-17.3-15.9-27.4h-16.5c-6 0-11.2-4.3-12.2-10.3-2-12-2.1-24.6 0-37.1 1-6 6.2-10.4 12.2-10.4h16.5c3.6-10.1 9-19.4 15.9-27.4l-8.2-14.3c-3-5.2-1.9-11.9 2.8-15.7 9.5-7.9 20.4-14.2 32.1-18.6 5.7-2.1 12.1.1 15.1 5.4l8.2 14.3c10.5-1.9 21.2-1.9 31.7 0L552 6.3c3-5.3 9.4-7.5 15.1-5.4 11.8 4.4 22.6 10.7 32.1 18.6 4.6 3.8 5.8 10.5 2.8 15.7l-8.2 14.3c6.9 8 12.3 17.3 15.9 27.4h16.5c6 0 11.2 4.3 12.2 10.3 2 12 2.1 24.6 0 37.1-1 6-6.2 10.4-12.2 10.4h-16.5c-3.6 10.1-9 19.4-15.9 27.4l8.2 14.3c3 5.2 1.9 11.9-2.8 15.7-9.5 7.9-20.4 14.2-32.1 18.6-5.7 2.1-12.1-.1-15.1-5.4l-8.2-14.3c-10.4 1.9-21.2 1.9-31.7 0zm-10.5-58.8c38.5 29.6 82.4-14.3 52.8-52.8-38.5-29.7-82.4 14.3-52.8 52.8zM386.3 286.1l33.7 16.8c10.1 5.8 14.5 18.1 10.5 29.1-8.9 24.2-26.4 46.4-42.6 65.8-7.4 8.9-20.2 11.1-30.3 5.3l-29.1-16.8c-16 13.7-34.6 24.6-54.9 31.7v33.6c0 11.6-8.3 21.6-19.7 23.6-24.6 4.2-50.4 4.4-75.9 0-11.5-2-20-11.9-20-23.6V418c-20.3-7.2-38.9-18-54.9-31.7L74 403c-10 5.8-22.9 3.6-30.3-5.3-16.2-19.4-33.3-41.6-42.2-65.7-4-10.9.4-23.2 10.5-29.1l33.3-16.8c-3.9-20.9-3.9-42.4 0-63.4L12 205.8c-10.1-5.8-14.6-18.1-10.5-29 8.9-24.2 26-46.4 42.2-65.8 7.4-8.9 20.2-11.1 30.3-5.3l29.1 16.8c16-13.7 34.6-24.6 54.9-31.7V57.1c0-11.5 8.2-21.5 19.6-23.5 24.6-4.2 50.5-4.4 76-.1 11.5 2 20 11.9 20 23.6v33.6c20.3 7.2 38.9 18 54.9 31.7l29.1-16.8c10-5.8 22.9-3.6 30.3 5.3 16.2 19.4 33.2 41.6 42.1 65.8 4 10.9.1 23.2-10 29.1l-33.7 16.8c3.9 21 3.9 42.5 0 63.5zm-117.6 21.1c59.2-77-28.7-164.9-105.7-105.7-59.2 77 28.7 164.9 105.7 105.7zm243.4 182.7l-8.2 14.3c-3 5.3-9.4 7.5-15.1 5.4-11.8-4.4-22.6-10.7-32.1-18.6-4.6-3.8-5.8-10.5-2.8-15.7l8.2-14.3c-6.9-8-12.3-17.3-15.9-27.4h-16.5c-6 0-11.2-4.3-12.2-10.3-2-12-2.1-24.6 0-37.1 1-6 6.2-10.4 12.2-10.4h16.5c3.6-10.1 9-19.4 15.9-27.4l-8.2-14.3c-3-5.2-1.9-11.9 2.8-15.7 9.5-7.9 20.4-14.2 32.1-18.6 5.7-2.1 12.1.1 15.1 5.4l8.2 14.3c10.5-1.9 21.2-1.9 31.7 0l8.2-14.3c3-5.3 9.4-7.5 15.1-5.4 11.8 4.4 22.6 10.7 32.1 18.6 4.6 3.8 5.8 10.5 2.8 15.7l-8.2 14.3c6.9 8 12.3 17.3 15.9 27.4h16.5c6 0 11.2 4.3 12.2 10.3 2 12 2.1 24.6 0 37.1-1 6-6.2 10.4-12.2 10.4h-16.5c-3.6 10.1-9 19.4-15.9 27.4l8.2 14.3c3 5.2 1.9 11.9-2.8 15.7-9.5 7.9-20.4 14.2-32.1 18.6-5.7 2.1-12.1-.1-15.1-5.4l-8.2-14.3c-10.4 1.9-21.2 1.9-31.7 0zM501.6 431c38.5 29.6 82.4-14.3 52.8-52.8-38.5-29.6-82.4 14.3-52.8 52.8z" />
</Icon>
);
};
export default Cogs;

View File

@ -1,18 +1,29 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Taskcafe: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
type TaskcafeProps = {
innerColor?: string;
outerColor?: string;
} & IconProps;
const Taskcafe: React.FC<TaskcafeProps> = ({
innerColor = '#262c49',
outerColor = '#7367f0',
width = '16px',
height = '16px',
className,
}) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 800 800">
<path
d="M371.147 371.04c-59.092 0-106.995 47.903-106.995 106.995 0 59.092 47.903 106.995 106.995 106.995 59.092 0 106.995-47.903 106.995-106.995 0-59.092-47.903-106.996-106.995-106.996zm0 20.708c47.687 0 86.287 38.592 86.287 86.287 0 47.687-38.592 86.286-86.287 86.286-47.687 0-86.286-38.592-86.286-86.286 0-47.688 38.592-86.287 86.286-86.287m60.489 56.201l-9.723-9.8a5.177 5.177 0 00-7.321-.03l-60.984 60.494-25.796-26.006a5.177 5.177 0 00-7.322-.03l-9.802 9.723a5.177 5.177 0 00-.029 7.322l39.166 39.483a5.177 5.177 0 007.321.03l74.46-73.864a5.178 5.178 0 00.03-7.321z"
fill="#7367f0"
fill={outerColor}
/>
<path
d="M264.69 682.877H467.7c56.038 0 101.504-45.465 101.504-101.504h33.835c74.648 0 135.34-60.692 135.34-135.34s-60.692-135.34-135.34-135.34H188.562a25.315 25.315 0 00-25.376 25.377v245.303c0 56.039 45.465 101.504 101.504 101.504zM603.04 378.364c37.324 0 67.67 30.345 67.67 67.67 0 37.323-30.346 67.669-67.67 67.669h-33.835v-135.34zm50.435 406.018H112.75c-50.329 0-64.497-67.67-38.064-67.67h616.746c26.434 0 12.477 67.67-37.958 67.67z"
fill="#7367f0"
fill={outerColor}
/>
<g fill="none" stroke="#7367f0" strokeWidth="3.392">
<g fill="none" stroke={outerColor} strokeWidth="3.392">
<path
d="M286.302 276.2c-12.491-12.65-24.51-24.821-23.906-35.239.604-10.417 14.068-18.838 26.772-30.358 12.705-11.52 24.65-26.139 14.907-39.935-9.743-13.797-41.175-26.772-40.697-42.89.478-16.119 32.868-35.378 37.236-52.814 4.369-17.435-19.286-33.048-42.944-48.662M374.624 276.2c-12.492-12.65-24.51-24.821-23.907-35.239.604-10.417 14.068-18.838 26.773-30.358 12.704-11.52 24.65-26.139 14.906-39.935-9.742-13.797-41.174-26.772-40.696-42.89.478-16.119 32.867-35.378 37.236-52.814 4.368-17.435-19.287-33.048-42.944-48.662M462.945 276.2c-12.491-12.65-24.51-24.821-23.906-35.239.604-10.417 14.068-18.838 26.772-30.358 12.705-11.52 24.65-26.139 14.907-39.935-9.743-13.797-41.175-26.772-40.697-42.89.478-16.119 32.868-35.378 37.236-52.814 4.369-17.436-19.286-33.048-42.944-48.662"
strokeWidth="28.00374144"
@ -20,7 +31,7 @@ const Taskcafe: React.FC<IconProps> = ({ width = '16px', height = '16px', classN
</g>
<path
d="M371.147 363.194c-73.749 0-133.534 59.785-133.534 133.534 0 73.75 59.785 133.535 133.534 133.535 73.75 0 133.535-59.786 133.535-133.535s-59.786-133.534-133.535-133.534zm0 25.845c59.516 0 107.69 48.165 107.69 107.69 0 59.515-48.165 107.688-107.69 107.688-59.515 0-107.689-48.164-107.689-107.689 0-59.515 48.165-107.689 107.69-107.689m75.492 70.142l-12.135-12.232a6.462 6.462 0 00-9.138-.039l-76.11 75.498-32.194-32.456a6.463 6.463 0 00-9.138-.038l-12.233 12.134a6.451 6.451 0 00-.025 9.138l48.88 49.277a6.462 6.462 0 009.138.038l92.93-92.183a6.452 6.452 0 00.024-9.138z"
fill="#262c49"
fill={innerColor}
/>
</Icon>
);

View File

@ -1,7 +1,11 @@
import Cross from './Cross';
import Cog from './Cog';
import Cogs from './Cogs';
import ArrowDown from './ArrowDown';
import CheckCircleOutline from './CheckCircleOutline';
import Briefcase from './Briefcase';
import ListUnordered from './ListUnordered';
import ChevronRight from './ChevronRight';
import Dot from './Dot';
import CaretDown from './CaretDown';
import Eye from './Eye';
@ -102,5 +106,9 @@ export {
Dot,
ArrowDown,
CaretRight,
CheckCircleOutline,
Briefcase,
DotCircle,
ChevronRight,
Cogs,
};

View File

@ -102,6 +102,7 @@ type Task = {
name: string;
badges?: TaskBadges;
position: number;
hasTime?: boolean;
dueDate?: string;
complete?: boolean;
completedAt?: string | null;

File diff suppressed because it is too large Load Diff

5
go.mod
View File

@ -5,14 +5,17 @@ go 1.13
require (
github.com/99designs/gqlgen v0.11.3
github.com/RichardKnop/machinery v1.9.1
github.com/brianvoe/gofakeit/v5 v5.11.2
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/go-chi/chi v3.3.2+incompatible
github.com/golang-migrate/migrate/v4 v4.11.0
github.com/google/uuid v1.1.1
github.com/jinzhu/now v1.1.1
github.com/jmoiron/sqlx v1.2.0
github.com/lib/pq v1.3.0
github.com/lithammer/fuzzysearch v1.1.0
github.com/magefile/mage v1.9.0
github.com/magefile/mage v1.11.0
github.com/manifoldco/promptui v0.8.0
github.com/matcornic/hermes/v2 v2.1.0
github.com/pelletier/go-toml v1.8.0 // indirect
github.com/pkg/errors v0.9.1

24
go.sum
View File

@ -96,11 +96,19 @@ github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwj
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/brianvoe/gofakeit v1.2.0 h1:GGbzCqQx9ync4ObAUhRa3F/M73eL9VZL3X09WoTwphM=
github.com/brianvoe/gofakeit v3.18.0+incompatible h1:wDOmHc9DLG4nRjUVVaxA+CEglKOW72Y5+4WNxUIkjM8=
github.com/brianvoe/gofakeit v3.18.0+incompatible/go.mod h1:kfwdRA90vvNhPutZWfH7WPaDzUjz+CZFqG+rPkOjGOc=
github.com/brianvoe/gofakeit/v5 v5.11.2 h1:Ny5Nsf4z2023ZvYP8ujW8p5B1t5sxhdFaQ/0IYXbeSA=
github.com/brianvoe/gofakeit/v5 v5.11.2/go.mod h1:/ZENnKqX+XrN8SORLe/fu5lZDIo1tuPncWuRD+eyhSI=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80=
@ -338,6 +346,8 @@ github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0f
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc=
@ -349,6 +359,8 @@ github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4=
@ -381,10 +393,14 @@ github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lithammer/fuzzysearch v1.1.0 h1:go9v8tLCrNTTlH42OAaq4eHFe81TDHEnlrMEb6R4f+A=
github.com/lithammer/fuzzysearch v1.1.0/go.mod h1:Bqx4wo8lTOFcJr3ckpY6HA9lEIOO0H5HrkJ5CsN56HQ=
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/magefile/mage v1.9.0 h1:t3AU2wNwehMCW97vuqQLtw6puppWXHO+O2MHo5a50XE=
github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw=
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls=
github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo=
github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ=
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
github.com/markbates/pkger v0.15.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
@ -392,11 +408,15 @@ github.com/matcornic/hermes/v2 v2.1.0 h1:9TDYFBPFv6mcXanaDmRDEp/RTWj0dTTi+LpFnnn
github.com/matcornic/hermes/v2 v2.1.0/go.mod h1:2+ziJeoyRfaLiATIL8VZ7f9hpzH4oDHqTmn0bhrsgVI=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=

View File

@ -52,12 +52,12 @@ func (r *ErrMalformedToken) Error() string {
}
// NewAccessToken generates a new JWT access token with the correct claims
func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string, jwtKey []byte) (string, error) {
func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string, jwtKey []byte, expirationTime time.Duration) (string, error) {
role := RoleMember
if orgRole == "admin" {
role = RoleAdmin
}
accessExpirationTime := time.Now().Add(5 * time.Second)
accessExpirationTime := time.Now().Add(expirationTime)
accessClaims := &AccessTokenClaims{
UserID: userID,
Restricted: restrictedMode,
@ -98,10 +98,6 @@ func ValidateAccessToken(accessTokenString string, jwtKey []byte) (AccessTokenCl
return jwtKey, nil
})
if err != nil {
return *accessClaims, nil
}
if accessToken.Valid {
log.WithFields(log.Fields{
"token": accessTokenString,
@ -111,7 +107,7 @@ func ValidateAccessToken(accessTokenString string, jwtKey []byte) (AccessTokenCl
}
if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors&jwt.ValidationErrorMalformed != 0 {
if ve.Errors&(jwt.ValidationErrorMalformed|jwt.ValidationErrorSignatureInvalid) != 0 {
return AccessTokenClaims{}, &ErrMalformedToken{}
} else if ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0 {
return AccessTokenClaims{}, &ErrExpiredToken{}

View File

@ -0,0 +1,56 @@
package auth
import (
"testing"
"time"
"github.com/dgrijalva/jwt-go"
)
// Override time value for jwt tests. Restore default value after.
func at(t time.Time, f func()) {
jwt.TimeFunc = func() time.Time {
return t
}
f()
jwt.TimeFunc = time.Now
}
func TestAuth_ValidateAccessToken(t *testing.T) {
expectedToken := AccessTokenClaims{
UserID: "1234",
Restricted: "unrestricted",
OrgRole: "member",
StandardClaims: jwt.StandardClaims{ExpiresAt: 1000},
}
// jwt with the claims of expectedToken signed by secretKey
jwtString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0IiwicmVzdHJpY3RlZCI6InVucmVzdHJpY3RlZCIsIm9yZ1JvbGUiOiJtZW1iZXIiLCJleHAiOjEwMDB9.Zc4mrnogDccYffA7dWogdWsZMELftQluh2X5xDyzOpA"
secretKey := []byte("secret")
// Check that decrypt failure is detected
token, err := ValidateAccessToken(jwtString, []byte("incorrectSecret"))
if err == nil {
t.Errorf("[IncorrectKey] Expected an error when validating a token with the incorrect key, instead got token %v", token)
} else if _, ok := err.(*ErrMalformedToken); !ok {
t.Errorf("[IncorrectKey] Expected an ErrMalformedToken error when validating a token with the incorrect key, instead got error %T:%v", err, err)
}
// Check that token expiration check works
token, err = ValidateAccessToken(jwtString, secretKey)
if err == nil {
t.Errorf("[TokenExpired] Expected an error when validating an expired token, instead got token %v", token)
} else if _, ok := err.(*ErrExpiredToken); !ok {
t.Errorf("[TokenExpired] Expected an ErrExpiredToken error when validating an expired token, instead got error %T:%v", err, err)
}
// Check that token validation works with a valid token
// Set the time to be valid for the token expiration
at(time.Unix(500, 0), func() {
token, err = ValidateAccessToken(jwtString, secretKey)
if err != nil {
t.Errorf("[TokenValid] Expected no errors when validating token, instead got err %v", err)
} else if token != expectedToken {
t.Errorf("[TokenValid] Expected token with claims %v but instead had claims %v", expectedToken, token)
}
})
}

View File

@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"github.com/jordanknott/taskcafe/internal/utils"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@ -12,22 +13,19 @@ import (
const mainDescription = `Taskcafé is an open soure project management
system written in Golang & React.`
var (
version = "dev"
commit = "none"
date = "unknown"
)
var versionTemplate = fmt.Sprintf(`Version: %s
func VersionTemplate() string {
info := utils.Version()
return fmt.Sprintf(`Version: %s
Commit: %s
Built: %s`, version, commit, date+"\n")
Built: %s`, info.Version, info.CommitHash, info.BuildDate+"\n")
}
var cfgFile string
var rootCmd = &cobra.Command{
Use: "taskcafe",
Long: mainDescription,
Version: version,
Version: VersionTemplate(),
}
var migration http.FileSystem
@ -82,11 +80,12 @@ func Execute() {
viper.SetDefault("database.user", "taskcafe")
viper.SetDefault("database.password", "taskcafe_test")
viper.SetDefault("database.port", "5432")
viper.SetDefault("security.token_expiration", "15m")
viper.SetDefault("queue.broker", "amqp://guest:guest@localhost:5672/")
viper.SetDefault("queue.store", "memcache://localhost:11211")
rootCmd.SetVersionTemplate(versionTemplate)
rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newTokenCmd(), newWorkerCmd(), newResetPasswordCmd())
rootCmd.SetVersionTemplate(VersionTemplate())
rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newTokenCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd())
rootCmd.Execute()
}

152
internal/commands/seed.go Normal file
View File

@ -0,0 +1,152 @@
package commands
import (
"context"
"fmt"
"time"
"github.com/brianvoe/gofakeit/v5"
"github.com/manifoldco/promptui"
"github.com/jordanknott/taskcafe/internal/db"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/jmoiron/sqlx"
log "github.com/sirupsen/logrus"
)
var (
teams int
projects int
taskGroups int
tasks int
)
func newSeedCmd() *cobra.Command {
cc := &cobra.Command{
Use: "seed",
Short: "Seeds the database with random data for testing",
Long: "Seeds the database with random data for testing. CAN NOT BE UNDONE.",
RunE: func(cmd *cobra.Command, args []string) error {
Formatter := new(log.TextFormatter)
Formatter.TimestampFormat = "02-01-2006 15:04:05"
Formatter.FullTimestamp = true
log.SetFormatter(Formatter)
log.SetLevel(log.InfoLevel)
connection := fmt.Sprintf("user=%s password=%s host=%s dbname=%s port=%s sslmode=disable",
viper.GetString("database.user"),
viper.GetString("database.password"),
viper.GetString("database.host"),
viper.GetString("database.name"),
viper.GetString("database.port"),
)
var dbConnection *sqlx.DB
var err error
var retryDuration time.Duration
maxRetryNumber := 4
for i := 0; i < maxRetryNumber; i++ {
dbConnection, err = sqlx.Connect("postgres", connection)
if err == nil {
break
}
retryDuration = time.Duration(i*2) * time.Second
log.WithFields(log.Fields{"retryNumber": i, "retryDuration": retryDuration}).WithError(err).Error("issue connecting to database, retrying")
if i != maxRetryNumber-1 {
time.Sleep(retryDuration)
}
}
if err != nil {
return err
}
dbConnection.SetMaxOpenConns(25)
dbConnection.SetMaxIdleConns(25)
dbConnection.SetConnMaxLifetime(5 * time.Minute)
defer dbConnection.Close()
if viper.GetBool("migrate") {
log.Info("running auto schema migrations")
if err = runMigration(dbConnection); err != nil {
return err
}
}
prompt := promptui.Prompt{
Label: "Seed database",
IsConfirm: true,
}
_, err = prompt.Run()
if err != nil {
return err
}
ctx := context.Background()
repository := db.NewRepository(dbConnection)
now := time.Now().UTC()
organizations, err := repository.GetAllOrganizations(ctx)
organizationId := organizations[0].OrganizationID
for teamIdx := 0; teamIdx <= teams; teamIdx++ {
teamName := gofakeit.Company()
team, err := repository.CreateTeam(ctx, db.CreateTeamParams{
Name: teamName,
CreatedAt: now,
OrganizationID: organizationId,
})
if err != nil {
return err
}
for projectIdx := 0; projectIdx <= projects; projectIdx++ {
projectName := gofakeit.Dessert()
project, err := repository.CreateTeamProject(ctx, db.CreateTeamProjectParams{
TeamID: team.TeamID,
Name: projectName,
CreatedAt: now,
})
if err != nil {
return err
}
for taskGroupIdx := 0; taskGroupIdx <= taskGroups; taskGroupIdx++ {
taskGroupName := gofakeit.LoremIpsumSentence(8)
taskGroup, err := repository.CreateTaskGroup(ctx, db.CreateTaskGroupParams{
Name: taskGroupName,
ProjectID: project.ProjectID,
CreatedAt: now,
Position: float64(65535 * (taskGroupIdx + 1)),
})
if err != nil {
return err
}
for taskIdx := 0; taskIdx <= tasks; taskIdx++ {
taskName := gofakeit.Sentence(8)
task, err := repository.CreateTask(ctx, db.CreateTaskParams{
Name: taskName,
TaskGroupID: taskGroup.TaskGroupID,
CreatedAt: now,
Position: float64(65535 * (taskIdx + 1)),
})
if err != nil {
return err
}
fmt.Printf("Creating %d / %d / %d / %d - %d\n", teamIdx, projectIdx, taskGroupIdx, taskIdx, task.TaskID)
}
}
}
}
return nil
},
}
cc.Flags().Bool("migrate", false, "if true, auto run's schema migrations before starting the web server")
cc.Flags().IntVar(&teams, "teams", 5, "number of teams to generate")
cc.Flags().IntVar(&projects, "projects", 10, "number of projects to create per team (personal projects are included)")
cc.Flags().IntVar(&taskGroups, "task_groups", 5, "number of task groups to generate per project")
cc.Flags().IntVar(&tasks, "tasks", 25, "number of tasks to generate per task group")
viper.SetDefault("migrate", false)
return cc
}

View File

@ -70,12 +70,12 @@ func newWebCmd() *cobra.Command {
}
}
log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server")
secret := viper.GetString("server.secret")
if strings.TrimSpace(secret) == "" {
log.Warn("server.secret is not set, generating a random secret")
secret = uuid.New().String()
}
security, err := utils.GetSecurityConfig(viper.GetString("security.token_expiration"), []byte(secret))
r, _ := route.NewRouter(db, utils.EmailConfig{
From: viper.GetString("smtp.from"),
Host: viper.GetString("smtp.host"),
@ -83,7 +83,8 @@ func newWebCmd() *cobra.Command {
Username: viper.GetString("smtp.username"),
Password: viper.GetString("smtp.password"),
InsecureSkipVerify: viper.GetBool("smtp.skip_verify"),
}, []byte(secret))
}, security)
log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server")
return http.ListenAndServe(viper.GetString("server.hostname"), r)
},
}

View File

@ -102,6 +102,7 @@ type Task struct {
DueDate sql.NullTime `json:"due_date"`
Complete bool `json:"complete"`
CompletedAt sql.NullTime `json:"completed_at"`
HasTime bool `json:"has_time"`
}
type TaskActivity struct {

View File

@ -69,6 +69,8 @@ type Querier interface {
GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
GetAllVisibleProjectsForUserID(ctx context.Context, userID uuid.UUID) ([]Project, error)
GetAssignedMembersForTask(ctx context.Context, taskID uuid.UUID) ([]TaskAssigned, error)
GetAssignedTasksDueDateForUserID(ctx context.Context, arg GetAssignedTasksDueDateForUserIDParams) ([]Task, error)
GetAssignedTasksProjectForUserID(ctx context.Context, arg GetAssignedTasksProjectForUserIDParams) ([]Task, error)
GetCommentsForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskComment, error)
GetConfirmTokenByEmail(ctx context.Context, email string) (UserAccountConfirmToken, error)
GetConfirmTokenByID(ctx context.Context, confirmTokenID uuid.UUID) (UserAccountConfirmToken, error)
@ -90,12 +92,14 @@ type Querier interface {
GetProjectIDForTaskChecklist(ctx context.Context, taskChecklistID uuid.UUID) (uuid.UUID, error)
GetProjectIDForTaskChecklistItem(ctx context.Context, taskChecklistItemID uuid.UUID) (uuid.UUID, error)
GetProjectIDForTaskGroup(ctx context.Context, taskGroupID uuid.UUID) (uuid.UUID, error)
GetProjectIdMappings(ctx context.Context, dollar_1 []uuid.UUID) ([]GetProjectIdMappingsRow, error)
GetProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) (ProjectLabel, error)
GetProjectLabelsForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectLabel, error)
GetProjectMemberInvitedIDByEmail(ctx context.Context, email string) (GetProjectMemberInvitedIDByEmailRow, error)
GetProjectMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]ProjectMember, error)
GetProjectRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetProjectRolesForUserIDRow, error)
GetProjectsForInvitedMember(ctx context.Context, email string) ([]uuid.UUID, error)
GetRecentlyAssignedTaskForUserID(ctx context.Context, arg GetRecentlyAssignedTaskForUserIDParams) ([]Task, error)
GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error)
GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error)
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)

View File

@ -34,7 +34,7 @@ UPDATE task SET name = $2 WHERE task_id = $1 RETURNING *;
DELETE FROM task where task_group_id = $1;
-- name: UpdateTaskDueDate :one
UPDATE task SET due_date = $2 WHERE task_id = $1 RETURNING *;
UPDATE task SET due_date = $2, has_time = $3 WHERE task_id = $1 RETURNING *;
-- name: SetTaskComplete :one
UPDATE task SET complete = $2, completed_at = $3 WHERE task_id = $1 RETURNING *;
@ -56,3 +56,42 @@ DELETE FROM task_comment WHERE task_comment_id = $1 RETURNING *;
-- name: UpdateTaskComment :one
UPDATE task_comment SET message = $2, updated_at = $3 WHERE task_comment_id = $1 RETURNING *;
-- name: GetRecentlyAssignedTaskForUserID :many
SELECT task.* FROM task_assigned INNER JOIN
task ON task.task_id = task_assigned.task_id WHERE user_id = $1
AND $4::boolean = true OR (
$4::boolean = false AND complete = $2 AND (
$2 = false OR ($2 = true AND completed_at > $3)
)
)
ORDER BY task_assigned.assigned_date DESC;
-- name: GetAssignedTasksProjectForUserID :many
SELECT task.* FROM task_assigned
INNER JOIN task ON task.task_id = task_assigned.task_id
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
WHERE user_id = $1
AND $4::boolean = true OR (
$4::boolean = false AND complete = $2 AND (
$2 = false OR ($2 = true AND completed_at > $3)
)
)
ORDER BY task_group.project_id DESC, task_assigned.assigned_date DESC;
-- name: GetProjectIdMappings :many
SELECT project_id, task_id FROM task
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
WHERE task_id = ANY($1::uuid[]);
-- name: GetAssignedTasksDueDateForUserID :many
SELECT task.* FROM task_assigned
INNER JOIN task ON task.task_id = task_assigned.task_id
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
WHERE user_id = $1
AND $4::boolean = true OR (
$4::boolean = false AND complete = $2 AND (
$2 = false OR ($2 = true AND completed_at > $3)
)
)
ORDER BY task.due_date DESC, task_group.project_id DESC;

View File

@ -9,11 +9,12 @@ import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
)
const createTask = `-- name: CreateTask :one
INSERT INTO task (task_group_id, created_at, name, position)
VALUES($1, $2, $3, $4) RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
VALUES($1, $2, $3, $4) RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time
`
type CreateTaskParams struct {
@ -41,13 +42,14 @@ func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, e
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
)
return i, err
}
const createTaskAll = `-- name: CreateTaskAll :one
INSERT INTO task (task_group_id, created_at, name, position, description, complete, due_date)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time
`
type CreateTaskAllParams struct {
@ -81,6 +83,7 @@ func (q *Queries) CreateTaskAll(ctx context.Context, arg CreateTaskAllParams) (T
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
)
return i, err
}
@ -158,7 +161,7 @@ func (q *Queries) DeleteTasksByTaskGroupID(ctx context.Context, taskGroupID uuid
}
const getAllTasks = `-- name: GetAllTasks :many
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at FROM task
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time FROM task
`
func (q *Queries) GetAllTasks(ctx context.Context) ([]Task, error) {
@ -180,6 +183,125 @@ func (q *Queries) GetAllTasks(ctx context.Context) ([]Task, error) {
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAssignedTasksDueDateForUserID = `-- name: GetAssignedTasksDueDateForUserID :many
SELECT task.task_id, task.task_group_id, task.created_at, task.name, task.position, task.description, task.due_date, task.complete, task.completed_at, task.has_time FROM task_assigned
INNER JOIN task ON task.task_id = task_assigned.task_id
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
WHERE user_id = $1
AND $4::boolean = true OR (
$4::boolean = false AND complete = $2 AND (
$2 = false OR ($2 = true AND completed_at > $3)
)
)
ORDER BY task.due_date DESC, task_group.project_id DESC
`
type GetAssignedTasksDueDateForUserIDParams struct {
UserID uuid.UUID `json:"user_id"`
Complete bool `json:"complete"`
CompletedAt sql.NullTime `json:"completed_at"`
Column4 bool `json:"column_4"`
}
func (q *Queries) GetAssignedTasksDueDateForUserID(ctx context.Context, arg GetAssignedTasksDueDateForUserIDParams) ([]Task, error) {
rows, err := q.db.QueryContext(ctx, getAssignedTasksDueDateForUserID,
arg.UserID,
arg.Complete,
arg.CompletedAt,
arg.Column4,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var i Task
if err := rows.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
&i.Description,
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAssignedTasksProjectForUserID = `-- name: GetAssignedTasksProjectForUserID :many
SELECT task.task_id, task.task_group_id, task.created_at, task.name, task.position, task.description, task.due_date, task.complete, task.completed_at, task.has_time FROM task_assigned
INNER JOIN task ON task.task_id = task_assigned.task_id
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
WHERE user_id = $1
AND $4::boolean = true OR (
$4::boolean = false AND complete = $2 AND (
$2 = false OR ($2 = true AND completed_at > $3)
)
)
ORDER BY task_group.project_id DESC, task_assigned.assigned_date DESC
`
type GetAssignedTasksProjectForUserIDParams struct {
UserID uuid.UUID `json:"user_id"`
Complete bool `json:"complete"`
CompletedAt sql.NullTime `json:"completed_at"`
Column4 bool `json:"column_4"`
}
func (q *Queries) GetAssignedTasksProjectForUserID(ctx context.Context, arg GetAssignedTasksProjectForUserIDParams) ([]Task, error) {
rows, err := q.db.QueryContext(ctx, getAssignedTasksProjectForUserID,
arg.UserID,
arg.Complete,
arg.CompletedAt,
arg.Column4,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var i Task
if err := rows.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
&i.Description,
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
); err != nil {
return nil, err
}
@ -242,8 +364,99 @@ func (q *Queries) GetProjectIDForTask(ctx context.Context, taskID uuid.UUID) (uu
return project_id, err
}
const getProjectIdMappings = `-- name: GetProjectIdMappings :many
SELECT project_id, task_id FROM task
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
WHERE task_id = ANY($1::uuid[])
`
type GetProjectIdMappingsRow struct {
ProjectID uuid.UUID `json:"project_id"`
TaskID uuid.UUID `json:"task_id"`
}
func (q *Queries) GetProjectIdMappings(ctx context.Context, dollar_1 []uuid.UUID) ([]GetProjectIdMappingsRow, error) {
rows, err := q.db.QueryContext(ctx, getProjectIdMappings, pq.Array(dollar_1))
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetProjectIdMappingsRow
for rows.Next() {
var i GetProjectIdMappingsRow
if err := rows.Scan(&i.ProjectID, &i.TaskID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRecentlyAssignedTaskForUserID = `-- name: GetRecentlyAssignedTaskForUserID :many
SELECT task.task_id, task.task_group_id, task.created_at, task.name, task.position, task.description, task.due_date, task.complete, task.completed_at, task.has_time FROM task_assigned INNER JOIN
task ON task.task_id = task_assigned.task_id WHERE user_id = $1
AND $4::boolean = true OR (
$4::boolean = false AND complete = $2 AND (
$2 = false OR ($2 = true AND completed_at > $3)
)
)
ORDER BY task_assigned.assigned_date DESC
`
type GetRecentlyAssignedTaskForUserIDParams struct {
UserID uuid.UUID `json:"user_id"`
Complete bool `json:"complete"`
CompletedAt sql.NullTime `json:"completed_at"`
Column4 bool `json:"column_4"`
}
func (q *Queries) GetRecentlyAssignedTaskForUserID(ctx context.Context, arg GetRecentlyAssignedTaskForUserIDParams) ([]Task, error) {
rows, err := q.db.QueryContext(ctx, getRecentlyAssignedTaskForUserID,
arg.UserID,
arg.Complete,
arg.CompletedAt,
arg.Column4,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var i Task
if err := rows.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
&i.Description,
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTaskByID = `-- name: GetTaskByID :one
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at FROM task WHERE task_id = $1
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time FROM task WHERE task_id = $1
`
func (q *Queries) GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, error) {
@ -259,12 +472,13 @@ func (q *Queries) GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, erro
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
)
return i, err
}
const getTasksForTaskGroupID = `-- name: GetTasksForTaskGroupID :many
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at FROM task WHERE task_group_id = $1
SELECT task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time FROM task WHERE task_group_id = $1
`
func (q *Queries) GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) ([]Task, error) {
@ -286,6 +500,7 @@ func (q *Queries) GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.U
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
); err != nil {
return nil, err
}
@ -301,7 +516,7 @@ func (q *Queries) GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.U
}
const setTaskComplete = `-- name: SetTaskComplete :one
UPDATE task SET complete = $2, completed_at = $3 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
UPDATE task SET complete = $2, completed_at = $3 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time
`
type SetTaskCompleteParams struct {
@ -323,6 +538,7 @@ func (q *Queries) SetTaskComplete(ctx context.Context, arg SetTaskCompleteParams
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
)
return i, err
}
@ -353,7 +569,7 @@ func (q *Queries) UpdateTaskComment(ctx context.Context, arg UpdateTaskCommentPa
}
const updateTaskDescription = `-- name: UpdateTaskDescription :one
UPDATE task SET description = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
UPDATE task SET description = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time
`
type UpdateTaskDescriptionParams struct {
@ -374,21 +590,23 @@ func (q *Queries) UpdateTaskDescription(ctx context.Context, arg UpdateTaskDescr
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
)
return i, err
}
const updateTaskDueDate = `-- name: UpdateTaskDueDate :one
UPDATE task SET due_date = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
UPDATE task SET due_date = $2, has_time = $3 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time
`
type UpdateTaskDueDateParams struct {
TaskID uuid.UUID `json:"task_id"`
DueDate sql.NullTime `json:"due_date"`
HasTime bool `json:"has_time"`
}
func (q *Queries) UpdateTaskDueDate(ctx context.Context, arg UpdateTaskDueDateParams) (Task, error) {
row := q.db.QueryRowContext(ctx, updateTaskDueDate, arg.TaskID, arg.DueDate)
row := q.db.QueryRowContext(ctx, updateTaskDueDate, arg.TaskID, arg.DueDate, arg.HasTime)
var i Task
err := row.Scan(
&i.TaskID,
@ -400,12 +618,13 @@ func (q *Queries) UpdateTaskDueDate(ctx context.Context, arg UpdateTaskDueDatePa
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
)
return i, err
}
const updateTaskLocation = `-- name: UpdateTaskLocation :one
UPDATE task SET task_group_id = $2, position = $3 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
UPDATE task SET task_group_id = $2, position = $3 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time
`
type UpdateTaskLocationParams struct {
@ -427,12 +646,13 @@ func (q *Queries) UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocation
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
)
return i, err
}
const updateTaskName = `-- name: UpdateTaskName :one
UPDATE task SET name = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
UPDATE task SET name = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time
`
type UpdateTaskNameParams struct {
@ -453,12 +673,13 @@ func (q *Queries) UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams)
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
)
return i, err
}
const updateTaskPosition = `-- name: UpdateTaskPosition :one
UPDATE task SET position = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
UPDATE task SET position = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at, has_time
`
type UpdateTaskPositionParams struct {
@ -479,6 +700,7 @@ func (q *Queries) UpdateTaskPosition(ctx context.Context, arg UpdateTaskPosition
&i.DueDate,
&i.Complete,
&i.CompletedAt,
&i.HasTime,
)
return i, err
}

View File

@ -273,6 +273,11 @@ type ComplexityRoot struct {
UpdateUserRole func(childComplexity int, input UpdateUserRole) int
}
MyTasksPayload struct {
Projects func(childComplexity int) int
Tasks func(childComplexity int) int
}
Notification struct {
ActionType func(childComplexity int) int
Actor func(childComplexity int) int
@ -338,6 +343,11 @@ type ComplexityRoot struct {
RoleCode func(childComplexity int) int
}
ProjectTaskMapping struct {
ProjectID func(childComplexity int) int
TaskID func(childComplexity int) int
}
Query struct {
FindProject func(childComplexity int, input FindProject) int
FindTask func(childComplexity int, input FindTask) int
@ -346,6 +356,7 @@ type ComplexityRoot struct {
InvitedUsers func(childComplexity int) int
LabelColors func(childComplexity int) int
Me func(childComplexity int) int
MyTasks func(childComplexity int, input MyTasks) int
Notifications func(childComplexity int) int
Organizations func(childComplexity int) int
Projects func(childComplexity int, input *ProjectsFilter) int
@ -383,6 +394,7 @@ type ComplexityRoot struct {
CreatedAt func(childComplexity int) int
Description func(childComplexity int) int
DueDate func(childComplexity int) int
HasTime func(childComplexity int) int
ID func(childComplexity int) int
Labels func(childComplexity int) int
Name func(childComplexity int) int
@ -621,6 +633,7 @@ type QueryResolver interface {
Projects(ctx context.Context, input *ProjectsFilter) ([]db.Project, error)
FindTeam(ctx context.Context, input FindTeam) (*db.Team, error)
Teams(ctx context.Context) ([]db.Team, error)
MyTasks(ctx context.Context, input MyTasks) (*MyTasksPayload, error)
LabelColors(ctx context.Context) ([]db.LabelColor, error)
TaskGroups(ctx context.Context) ([]db.TaskGroup, error)
Me(ctx context.Context) (*MePayload, error)
@ -1877,6 +1890,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.UpdateUserRole(childComplexity, args["input"].(UpdateUserRole)), true
case "MyTasksPayload.projects":
if e.complexity.MyTasksPayload.Projects == nil {
break
}
return e.complexity.MyTasksPayload.Projects(childComplexity), true
case "MyTasksPayload.tasks":
if e.complexity.MyTasksPayload.Tasks == nil {
break
}
return e.complexity.MyTasksPayload.Tasks(childComplexity), true
case "Notification.actionType":
if e.complexity.Notification.ActionType == nil {
break
@ -2122,6 +2149,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.ProjectRole.RoleCode(childComplexity), true
case "ProjectTaskMapping.projectID":
if e.complexity.ProjectTaskMapping.ProjectID == nil {
break
}
return e.complexity.ProjectTaskMapping.ProjectID(childComplexity), true
case "ProjectTaskMapping.taskID":
if e.complexity.ProjectTaskMapping.TaskID == nil {
break
}
return e.complexity.ProjectTaskMapping.TaskID(childComplexity), true
case "Query.findProject":
if e.complexity.Query.FindProject == nil {
break
@ -2191,6 +2232,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Query.Me(childComplexity), true
case "Query.myTasks":
if e.complexity.Query.MyTasks == nil {
break
}
args, err := ec.field_Query_myTasks_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Query.MyTasks(childComplexity, args["input"].(MyTasks)), true
case "Query.notifications":
if e.complexity.Query.Notifications == nil {
break
@ -2376,6 +2429,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Task.DueDate(childComplexity), true
case "Task.hasTime":
if e.complexity.Task.HasTime == nil {
break
}
return e.complexity.Task.HasTime(childComplexity), true
case "Task.id":
if e.complexity.Task.ID == nil {
break
@ -3135,6 +3195,7 @@ type Task {
position: Float!
description: String
dueDate: Time
hasTime: Boolean!
complete: Boolean!
completedAt: Time
assigned: [Member!]!
@ -3220,13 +3281,47 @@ type Query {
projects(input: ProjectsFilter): [Project!]!
findTeam(input: FindTeam!): Team!
teams: [Team!]!
myTasks(input: MyTasks!): MyTasksPayload!
labelColors: [LabelColor!]!
taskGroups: [TaskGroup!]!
me: MePayload!
}
type Mutation
enum MyTasksStatus {
ALL
INCOMPLETE
COMPLETE_ALL
COMPLETE_TODAY
COMPLETE_YESTERDAY
COMPLETE_ONE_WEEK
COMPLETE_TWO_WEEK
COMPLETE_THREE_WEEK
}
enum MyTasksSort {
NONE
PROJECT
DUE_DATE
}
input MyTasks {
status: MyTasksStatus!
sort: MyTasksSort!
}
type ProjectTaskMapping {
projectID: UUID!
taskID: UUID!
}
type MyTasksPayload {
tasks: [Task!]!
projects: [ProjectTaskMapping!]!
}
type TeamRole {
teamID: UUID!
roleCode: RoleCode!
@ -3453,6 +3548,7 @@ input NewTask {
taskGroupID: UUID!
name: String!
position: Float!
assigned: [UUID!]
}
input AssignTaskInput {
@ -3477,6 +3573,7 @@ type UpdateTaskLocationPayload {
input UpdateTaskDueDate {
taskID: UUID!
hasTime: Boolean!
dueDate: Time
}
@ -4796,6 +4893,20 @@ func (ec *executionContext) field_Query_findUser_args(ctx context.Context, rawAr
return args, nil
}
func (ec *executionContext) field_Query_myTasks_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
var arg0 MyTasks
if tmp, ok := rawArgs["input"]; ok {
arg0, err = ec.unmarshalNMyTasks2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMyTasks(ctx, tmp)
if err != nil {
return nil, err
}
}
args["input"] = arg0
return args, nil
}
func (ec *executionContext) field_Query_projects_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
@ -11260,6 +11371,74 @@ func (ec *executionContext) _Mutation_updateUserInfo(ctx context.Context, field
return ec.marshalNUpdateUserInfoPayload2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐUpdateUserInfoPayload(ctx, field.Selections, res)
}
func (ec *executionContext) _MyTasksPayload_tasks(ctx context.Context, field graphql.CollectedField, obj *MyTasksPayload) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "MyTasksPayload",
Field: field,
Args: nil,
IsMethod: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Tasks, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.([]db.Task)
fc.Result = res
return ec.marshalNTask2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐTaskᚄ(ctx, field.Selections, res)
}
func (ec *executionContext) _MyTasksPayload_projects(ctx context.Context, field graphql.CollectedField, obj *MyTasksPayload) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "MyTasksPayload",
Field: field,
Args: nil,
IsMethod: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Projects, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.([]ProjectTaskMapping)
fc.Result = res
return ec.marshalNProjectTaskMapping2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐProjectTaskMappingᚄ(ctx, field.Selections, res)
}
func (ec *executionContext) _Notification_id(ctx context.Context, field graphql.CollectedField, obj *db.Notification) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -12435,6 +12614,74 @@ func (ec *executionContext) _ProjectRole_roleCode(ctx context.Context, field gra
return ec.marshalNRoleCode2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐRoleCode(ctx, field.Selections, res)
}
func (ec *executionContext) _ProjectTaskMapping_projectID(ctx context.Context, field graphql.CollectedField, obj *ProjectTaskMapping) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "ProjectTaskMapping",
Field: field,
Args: nil,
IsMethod: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.ProjectID, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(uuid.UUID)
fc.Result = res
return ec.marshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res)
}
func (ec *executionContext) _ProjectTaskMapping_taskID(ctx context.Context, field graphql.CollectedField, obj *ProjectTaskMapping) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "ProjectTaskMapping",
Field: field,
Args: nil,
IsMethod: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.TaskID, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(uuid.UUID)
fc.Result = res
return ec.marshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res)
}
func (ec *executionContext) _Query_organizations(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -12808,6 +13055,47 @@ func (ec *executionContext) _Query_teams(ctx context.Context, field graphql.Coll
return ec.marshalNTeam2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐTeamᚄ(ctx, field.Selections, res)
}
func (ec *executionContext) _Query_myTasks(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Query",
Field: field,
Args: nil,
IsMethod: true,
}
ctx = graphql.WithFieldContext(ctx, fc)
rawArgs := field.ArgumentMap(ec.Variables)
args, err := ec.field_Query_myTasks_args(ctx, rawArgs)
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
fc.Args = args
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Query().MyTasks(rctx, args["input"].(MyTasks))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*MyTasksPayload)
fc.Result = res
return ec.marshalNMyTasksPayload2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMyTasksPayload(ctx, field.Selections, res)
}
func (ec *executionContext) _Query_labelColors(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -13558,6 +13846,40 @@ func (ec *executionContext) _Task_dueDate(ctx context.Context, field graphql.Col
return ec.marshalOTime2ᚖtimeᚐTime(ctx, field.Selections, res)
}
func (ec *executionContext) _Task_hasTime(ctx context.Context, field graphql.CollectedField, obj *db.Task) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Task",
Field: field,
Args: nil,
IsMethod: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.HasTime, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(bool)
fc.Result = res
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) _Task_complete(ctx context.Context, field graphql.CollectedField, obj *db.Task) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -17858,6 +18180,30 @@ func (ec *executionContext) unmarshalInputMemberSearchFilter(ctx context.Context
return it, nil
}
func (ec *executionContext) unmarshalInputMyTasks(ctx context.Context, obj interface{}) (MyTasks, error) {
var it MyTasks
var asMap = obj.(map[string]interface{})
for k, v := range asMap {
switch k {
case "status":
var err error
it.Status, err = ec.unmarshalNMyTasksStatus2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMyTasksStatus(ctx, v)
if err != nil {
return it, err
}
case "sort":
var err error
it.Sort, err = ec.unmarshalNMyTasksSort2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMyTasksSort(ctx, v)
if err != nil {
return it, err
}
}
}
return it, nil
}
func (ec *executionContext) unmarshalInputNewProject(ctx context.Context, obj interface{}) (NewProject, error) {
var it NewProject
var asMap = obj.(map[string]interface{})
@ -17954,6 +18300,12 @@ func (ec *executionContext) unmarshalInputNewTask(ctx context.Context, obj inter
if err != nil {
return it, err
}
case "assigned":
var err error
it.Assigned, err = ec.unmarshalOUUID2ᚕgithubᚗcomᚋgoogleᚋuuidᚐUUIDᚄ(ctx, v)
if err != nil {
return it, err
}
}
}
@ -18596,6 +18948,12 @@ func (ec *executionContext) unmarshalInputUpdateTaskDueDate(ctx context.Context,
if err != nil {
return it, err
}
case "hasTime":
var err error
it.HasTime, err = ec.unmarshalNBoolean2bool(ctx, v)
if err != nil {
return it, err
}
case "dueDate":
var err error
it.DueDate, err = ec.unmarshalOTime2ᚖtimeᚐTime(ctx, v)
@ -20036,6 +20394,38 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
return out
}
var myTasksPayloadImplementors = []string{"MyTasksPayload"}
func (ec *executionContext) _MyTasksPayload(ctx context.Context, sel ast.SelectionSet, obj *MyTasksPayload) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, myTasksPayloadImplementors)
out := graphql.NewFieldSet(fields)
var invalids uint32
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("MyTasksPayload")
case "tasks":
out.Values[i] = ec._MyTasksPayload_tasks(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
case "projects":
out.Values[i] = ec._MyTasksPayload_projects(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch()
if invalids > 0 {
return graphql.Null
}
return out
}
var notificationImplementors = []string{"Notification"}
func (ec *executionContext) _Notification(ctx context.Context, sel ast.SelectionSet, obj *db.Notification) graphql.Marshaler {
@ -20551,6 +20941,38 @@ func (ec *executionContext) _ProjectRole(ctx context.Context, sel ast.SelectionS
return out
}
var projectTaskMappingImplementors = []string{"ProjectTaskMapping"}
func (ec *executionContext) _ProjectTaskMapping(ctx context.Context, sel ast.SelectionSet, obj *ProjectTaskMapping) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, projectTaskMappingImplementors)
out := graphql.NewFieldSet(fields)
var invalids uint32
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("ProjectTaskMapping")
case "projectID":
out.Values[i] = ec._ProjectTaskMapping_projectID(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
case "taskID":
out.Values[i] = ec._ProjectTaskMapping_taskID(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch()
if invalids > 0 {
return graphql.Null
}
return out
}
var queryImplementors = []string{"Query"}
func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler {
@ -20692,6 +21114,20 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
}
return res
})
case "myTasks":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
}
}()
res = ec._Query_myTasks(ctx, field)
if res == graphql.Null {
atomic.AddUint32(&invalids, 1)
}
return res
})
case "labelColors":
field := field
out.Concurrently(i, func() (res graphql.Marshaler) {
@ -20968,6 +21404,11 @@ func (ec *executionContext) _Task(ctx context.Context, sel ast.SelectionSet, obj
res = ec._Task_dueDate(ctx, field, obj)
return res
})
case "hasTime":
out.Values[i] = ec._Task_hasTime(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&invalids, 1)
}
case "complete":
out.Values[i] = ec._Task_complete(ctx, field, obj)
if out.Values[i] == graphql.Null {
@ -23092,6 +23533,42 @@ func (ec *executionContext) marshalNMemberSearchResult2ᚕgithubᚗcomᚋjordank
return ret
}
func (ec *executionContext) unmarshalNMyTasks2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMyTasks(ctx context.Context, v interface{}) (MyTasks, error) {
return ec.unmarshalInputMyTasks(ctx, v)
}
func (ec *executionContext) marshalNMyTasksPayload2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMyTasksPayload(ctx context.Context, sel ast.SelectionSet, v MyTasksPayload) graphql.Marshaler {
return ec._MyTasksPayload(ctx, sel, &v)
}
func (ec *executionContext) marshalNMyTasksPayload2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMyTasksPayload(ctx context.Context, sel ast.SelectionSet, v *MyTasksPayload) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
return ec._MyTasksPayload(ctx, sel, v)
}
func (ec *executionContext) unmarshalNMyTasksSort2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMyTasksSort(ctx context.Context, v interface{}) (MyTasksSort, error) {
var res MyTasksSort
return res, res.UnmarshalGQL(v)
}
func (ec *executionContext) marshalNMyTasksSort2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMyTasksSort(ctx context.Context, sel ast.SelectionSet, v MyTasksSort) graphql.Marshaler {
return v
}
func (ec *executionContext) unmarshalNMyTasksStatus2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMyTasksStatus(ctx context.Context, v interface{}) (MyTasksStatus, error) {
var res MyTasksStatus
return res, res.UnmarshalGQL(v)
}
func (ec *executionContext) marshalNMyTasksStatus2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMyTasksStatus(ctx context.Context, sel ast.SelectionSet, v MyTasksStatus) graphql.Marshaler {
return v
}
func (ec *executionContext) unmarshalNNewProject2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNewProject(ctx context.Context, v interface{}) (NewProject, error) {
return ec.unmarshalInputNewProject(ctx, v)
}
@ -23418,6 +23895,47 @@ func (ec *executionContext) marshalNProjectRole2ᚕgithubᚗcomᚋjordanknottᚋ
return ret
}
func (ec *executionContext) marshalNProjectTaskMapping2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐProjectTaskMapping(ctx context.Context, sel ast.SelectionSet, v ProjectTaskMapping) graphql.Marshaler {
return ec._ProjectTaskMapping(ctx, sel, &v)
}
func (ec *executionContext) marshalNProjectTaskMapping2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐProjectTaskMappingᚄ(ctx context.Context, sel ast.SelectionSet, v []ProjectTaskMapping) graphql.Marshaler {
ret := make(graphql.Array, len(v))
var wg sync.WaitGroup
isLen1 := len(v) == 1
if !isLen1 {
wg.Add(len(v))
}
for i := range v {
i := i
fc := &graphql.FieldContext{
Index: &i,
Result: &v[i],
}
ctx := graphql.WithFieldContext(ctx, fc)
f := func(i int) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = nil
}
}()
if !isLen1 {
defer wg.Done()
}
ret[i] = ec.marshalNProjectTaskMapping2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐProjectTaskMapping(ctx, sel, v[i])
}
if isLen1 {
f(i)
} else {
go f(i)
}
}
wg.Wait()
return ret
}
func (ec *executionContext) marshalNRefreshToken2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐRefreshToken(ctx context.Context, sel ast.SelectionSet, v db.RefreshToken) graphql.Marshaler {
return ec._RefreshToken(ctx, sel, &v)
}
@ -24820,6 +25338,38 @@ func (ec *executionContext) marshalOUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx
return MarshalUUID(v)
}
func (ec *executionContext) unmarshalOUUID2ᚕgithubᚗcomᚋgoogleᚋuuidᚐUUIDᚄ(ctx context.Context, v interface{}) ([]uuid.UUID, error) {
var vSlice []interface{}
if v != nil {
if tmp1, ok := v.([]interface{}); ok {
vSlice = tmp1
} else {
vSlice = []interface{}{v}
}
}
var err error
res := make([]uuid.UUID, len(vSlice))
for i := range vSlice {
res[i], err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, vSlice[i])
if err != nil {
return nil, err
}
}
return res, nil
}
func (ec *executionContext) marshalOUUID2ᚕgithubᚗcomᚋgoogleᚋuuidᚐUUIDᚄ(ctx context.Context, sel ast.SelectionSet, v []uuid.UUID) graphql.Marshaler {
if v == nil {
return graphql.Null
}
ret := make(graphql.Array, len(v))
for i := range v {
ret[i] = ec.marshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, sel, v[i])
}
return ret
}
func (ec *executionContext) unmarshalOUUID2ᚖgithubᚗcomᚋgoogleᚋuuidᚐUUID(ctx context.Context, v interface{}) (*uuid.UUID, error) {
if v == nil {
return nil, nil

View File

@ -291,6 +291,16 @@ type MemberSearchResult struct {
Status ShareStatus `json:"status"`
}
type MyTasks struct {
Status MyTasksStatus `json:"status"`
Sort MyTasksSort `json:"sort"`
}
type MyTasksPayload struct {
Tasks []db.Task `json:"tasks"`
Projects []ProjectTaskMapping `json:"projects"`
}
type NewProject struct {
TeamID *uuid.UUID `json:"teamID"`
Name string `json:"name"`
@ -310,6 +320,7 @@ type NewTask struct {
TaskGroupID uuid.UUID `json:"taskGroupID"`
Name string `json:"name"`
Position float64 `json:"position"`
Assigned []uuid.UUID `json:"assigned"`
}
type NewTaskGroup struct {
@ -376,6 +387,11 @@ type ProjectRole struct {
RoleCode RoleCode `json:"roleCode"`
}
type ProjectTaskMapping struct {
ProjectID uuid.UUID `json:"projectID"`
TaskID uuid.UUID `json:"taskID"`
}
type ProjectsFilter struct {
TeamID *uuid.UUID `json:"teamID"`
}
@ -519,6 +535,7 @@ type UpdateTaskDescriptionInput struct {
type UpdateTaskDueDate struct {
TaskID uuid.UUID `json:"taskID"`
HasTime bool `json:"hasTime"`
DueDate *time.Time `json:"dueDate"`
}
@ -796,6 +813,102 @@ func (e EntityType) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type MyTasksSort string
const (
MyTasksSortNone MyTasksSort = "NONE"
MyTasksSortProject MyTasksSort = "PROJECT"
MyTasksSortDueDate MyTasksSort = "DUE_DATE"
)
var AllMyTasksSort = []MyTasksSort{
MyTasksSortNone,
MyTasksSortProject,
MyTasksSortDueDate,
}
func (e MyTasksSort) IsValid() bool {
switch e {
case MyTasksSortNone, MyTasksSortProject, MyTasksSortDueDate:
return true
}
return false
}
func (e MyTasksSort) String() string {
return string(e)
}
func (e *MyTasksSort) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = MyTasksSort(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid MyTasksSort", str)
}
return nil
}
func (e MyTasksSort) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type MyTasksStatus string
const (
MyTasksStatusAll MyTasksStatus = "ALL"
MyTasksStatusIncomplete MyTasksStatus = "INCOMPLETE"
MyTasksStatusCompleteAll MyTasksStatus = "COMPLETE_ALL"
MyTasksStatusCompleteToday MyTasksStatus = "COMPLETE_TODAY"
MyTasksStatusCompleteYesterday MyTasksStatus = "COMPLETE_YESTERDAY"
MyTasksStatusCompleteOneWeek MyTasksStatus = "COMPLETE_ONE_WEEK"
MyTasksStatusCompleteTwoWeek MyTasksStatus = "COMPLETE_TWO_WEEK"
MyTasksStatusCompleteThreeWeek MyTasksStatus = "COMPLETE_THREE_WEEK"
)
var AllMyTasksStatus = []MyTasksStatus{
MyTasksStatusAll,
MyTasksStatusIncomplete,
MyTasksStatusCompleteAll,
MyTasksStatusCompleteToday,
MyTasksStatusCompleteYesterday,
MyTasksStatusCompleteOneWeek,
MyTasksStatusCompleteTwoWeek,
MyTasksStatusCompleteThreeWeek,
}
func (e MyTasksStatus) IsValid() bool {
switch e {
case MyTasksStatusAll, MyTasksStatusIncomplete, MyTasksStatusCompleteAll, MyTasksStatusCompleteToday, MyTasksStatusCompleteYesterday, MyTasksStatusCompleteOneWeek, MyTasksStatusCompleteTwoWeek, MyTasksStatusCompleteThreeWeek:
return true
}
return false
}
func (e MyTasksStatus) String() string {
return string(e)
}
func (e *MyTasksStatus) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = MyTasksStatus(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid MyTasksStatus", str)
}
return nil
}
func (e MyTasksStatus) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type ObjectType string
const (

View File

@ -175,6 +175,7 @@ type Task {
position: Float!
description: String
dueDate: Time
hasTime: Boolean!
complete: Boolean!
completedAt: Time
assigned: [Member!]!
@ -260,13 +261,47 @@ type Query {
projects(input: ProjectsFilter): [Project!]!
findTeam(input: FindTeam!): Team!
teams: [Team!]!
myTasks(input: MyTasks!): MyTasksPayload!
labelColors: [LabelColor!]!
taskGroups: [TaskGroup!]!
me: MePayload!
}
type Mutation
enum MyTasksStatus {
ALL
INCOMPLETE
COMPLETE_ALL
COMPLETE_TODAY
COMPLETE_YESTERDAY
COMPLETE_ONE_WEEK
COMPLETE_TWO_WEEK
COMPLETE_THREE_WEEK
}
enum MyTasksSort {
NONE
PROJECT
DUE_DATE
}
input MyTasks {
status: MyTasksStatus!
sort: MyTasksSort!
}
type ProjectTaskMapping {
projectID: UUID!
taskID: UUID!
}
type MyTasksPayload {
tasks: [Task!]!
projects: [ProjectTaskMapping!]!
}
type TeamRole {
teamID: UUID!
roleCode: RoleCode!
@ -493,6 +528,7 @@ input NewTask {
taskGroupID: UUID!
name: String!
position: Float!
assigned: [UUID!]
}
input AssignTaskInput {
@ -517,6 +553,7 @@ type UpdateTaskLocationPayload {
input UpdateTaskDueDate {
taskID: UUID!
hasTime: Boolean!
dueDate: Time
}

View File

@ -12,6 +12,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/jinzhu/now"
"github.com/jordanknott/taskcafe/internal/auth"
"github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/logger"
@ -314,6 +315,21 @@ func (r *mutationResolver) CreateTask(ctx context.Context, input NewTask) (*db.T
ActivityTypeID: 1,
})
if len(input.Assigned) != 0 {
assignedDate := time.Now().UTC()
for _, assigned := range input.Assigned {
assignedTask, err := r.Repository.CreateTaskAssigned(ctx, db.CreateTaskAssignedParams{TaskID: task.TaskID, UserID: assigned, AssignedDate: assignedDate})
logger.New(ctx).WithFields(log.Fields{
"assignedUserID": assignedTask.UserID,
"taskID": assignedTask.TaskID,
"assignedTaskID": assignedTask.TaskAssignedID,
}).Info("assigned task")
if err != nil {
return &db.Task{}, err
}
}
}
if err != nil {
logger.New(ctx).WithError(err).Error("issue while creating task")
return &db.Task{}, err
@ -423,21 +439,25 @@ func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTa
activityType = TASK_DUE_DATE_CHANGED
data["PrevDueDate"] = prevTask.DueDate.Time.String()
data["CurDueDate"] = input.DueDate.String()
} else {
} else if input.DueDate != nil {
data["DueDate"] = input.DueDate.String()
}
var dueDate sql.NullTime
log.WithField("dueDate", input.DueDate).Info("before ptr!")
if input.DueDate == nil {
dueDate = sql.NullTime{Valid: false, Time: time.Now()}
} else {
dueDate = sql.NullTime{Valid: true, Time: *input.DueDate}
}
task, err := r.Repository.UpdateTaskDueDate(ctx, db.UpdateTaskDueDateParams{
var task db.Task
if !(input.DueDate == nil && !prevTask.DueDate.Valid) {
task, err = r.Repository.UpdateTaskDueDate(ctx, db.UpdateTaskDueDateParams{
TaskID: input.TaskID,
DueDate: dueDate,
HasTime: input.HasTime,
})
createdAt := time.Now().UTC()
d, err := json.Marshal(data)
d, _ := json.Marshal(data)
_, err = r.Repository.CreateTaskActivity(ctx, db.CreateTaskActivityParams{
TaskID: task.TaskID,
Data: d,
@ -445,6 +465,9 @@ func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTa
CreatedAt: createdAt,
ActivityTypeID: activityType,
})
} else {
task, err = r.Repository.GetTaskByID(ctx, input.TaskID)
}
return &task, err
}
@ -954,6 +977,7 @@ func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserA
Email: input.Email,
Username: input.Username,
CreatedAt: createdAt,
Active: true,
PasswordHash: string(hashedPwd),
})
return &userAccount, err
@ -1383,6 +1407,80 @@ func (r *queryResolver) Teams(ctx context.Context) ([]db.Team, error) {
return foundTeams, nil
}
func (r *queryResolver) MyTasks(ctx context.Context, input MyTasks) (*MyTasksPayload, error) {
userID, _ := GetUserID(ctx)
projects := []ProjectTaskMapping{}
var tasks []db.Task
var err error
showAll := false
if input.Status == MyTasksStatusAll {
showAll = true
}
complete := false
completedAt := sql.NullTime{Valid: false, Time: time.Time{}}
switch input.Status {
case MyTasksStatusCompleteAll:
complete = true
completedAt = sql.NullTime{Valid: true, Time: time.Time{}}
case MyTasksStatusCompleteToday:
complete = true
completedAt = sql.NullTime{Valid: true, Time: now.BeginningOfDay()}
case MyTasksStatusCompleteYesterday:
complete = true
completedAt = sql.NullTime{Valid: true, Time: now.With(time.Now().AddDate(0, 0, -1)).BeginningOfDay()}
case MyTasksStatusCompleteOneWeek:
complete = true
completedAt = sql.NullTime{Valid: true, Time: now.With(time.Now().AddDate(0, 0, -7)).BeginningOfDay()}
case MyTasksStatusCompleteTwoWeek:
complete = true
completedAt = sql.NullTime{Valid: true, Time: now.With(time.Now().AddDate(0, 0, -14)).BeginningOfDay()}
case MyTasksStatusCompleteThreeWeek:
complete = true
completedAt = sql.NullTime{Valid: true, Time: now.With(time.Now().AddDate(0, 0, -21)).BeginningOfDay()}
}
if input.Sort == MyTasksSortNone {
tasks, err = r.Repository.GetRecentlyAssignedTaskForUserID(ctx, db.GetRecentlyAssignedTaskForUserIDParams{
UserID: userID,
Complete: complete,
CompletedAt: completedAt,
Column4: showAll,
})
if err != nil && err != sql.ErrNoRows {
return &MyTasksPayload{}, err
}
} else if input.Sort == MyTasksSortProject {
tasks, err = r.Repository.GetAssignedTasksProjectForUserID(ctx, db.GetAssignedTasksProjectForUserIDParams{
UserID: userID,
Complete: complete,
CompletedAt: completedAt,
Column4: showAll,
})
if err != nil && err != sql.ErrNoRows {
return &MyTasksPayload{}, err
}
} else if input.Sort == MyTasksSortDueDate {
tasks, err = r.Repository.GetAssignedTasksDueDateForUserID(ctx, db.GetAssignedTasksDueDateForUserIDParams{
UserID: userID,
Complete: complete,
CompletedAt: completedAt,
Column4: showAll,
})
if err != nil && err != sql.ErrNoRows {
return &MyTasksPayload{}, err
}
}
taskIds := []uuid.UUID{}
for _, task := range tasks {
taskIds = append(taskIds, task.TaskID)
}
mappings, err := r.Repository.GetProjectIdMappings(ctx, taskIds)
for _, mapping := range mappings {
projects = append(projects, ProjectTaskMapping{ProjectID: mapping.ProjectID, TaskID: mapping.TaskID})
}
return &MyTasksPayload{Tasks: tasks, Projects: projects}, err
}
func (r *queryResolver) LabelColors(ctx context.Context) ([]db.LabelColor, error) {
return r.Repository.GetLabelColors(ctx)
}

View File

@ -175,6 +175,7 @@ type Task {
position: Float!
description: String
dueDate: Time
hasTime: Boolean!
complete: Boolean!
completedAt: Time
assigned: [Member!]!

View File

@ -37,13 +37,47 @@ type Query {
projects(input: ProjectsFilter): [Project!]!
findTeam(input: FindTeam!): Team!
teams: [Team!]!
myTasks(input: MyTasks!): MyTasksPayload!
labelColors: [LabelColor!]!
taskGroups: [TaskGroup!]!
me: MePayload!
}
type Mutation
enum MyTasksStatus {
ALL
INCOMPLETE
COMPLETE_ALL
COMPLETE_TODAY
COMPLETE_YESTERDAY
COMPLETE_ONE_WEEK
COMPLETE_TWO_WEEK
COMPLETE_THREE_WEEK
}
enum MyTasksSort {
NONE
PROJECT
DUE_DATE
}
input MyTasks {
status: MyTasksStatus!
sort: MyTasksSort!
}
type ProjectTaskMapping {
projectID: UUID!
taskID: UUID!
}
type MyTasksPayload {
tasks: [Task!]!
projects: [ProjectTaskMapping!]!
}
type TeamRole {
teamID: UUID!
roleCode: RoleCode!

View File

@ -25,6 +25,7 @@ input NewTask {
taskGroupID: UUID!
name: String!
position: Float!
assigned: [UUID!]
}
input AssignTaskInput {
@ -49,6 +50,7 @@ type UpdateTaskLocationPayload {
input UpdateTaskDueDate {
taskID: UUID!
hasTime: Boolean!
dueDate: Time
}

View File

@ -143,7 +143,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
}
log.Info("here 1")
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
@ -220,7 +220,7 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
@ -283,7 +283,7 @@ func (h *TaskcafeHandler) InstallHandler(w http.ResponseWriter, r *http.Request)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
log.WithField("userID", user.UserID.String()).Info("creating install access token")
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
@ -386,7 +386,7 @@ func (h *TaskcafeHandler) ConfirmUser(w http.ResponseWriter, r *http.Request) {
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.SecurityConfig.Secret, h.SecurityConfig.AccessTokenExpiration)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}

View File

@ -61,11 +61,11 @@ func (h FrontendHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// TaskcafeHandler contains all the route handlers
type TaskcafeHandler struct {
repo db.Repository
jwtKey []byte
SecurityConfig utils.SecurityConfig
}
// NewRouter creates a new router for chi
func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, jwtKey []byte) (chi.Router, error) {
func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, securityConfig utils.SecurityConfig) (chi.Router, error) {
formatter := new(log.TextFormatter)
formatter.TimestampFormat = "02-01-2006 15:04:05"
formatter.FullTimestamp = true
@ -81,7 +81,7 @@ func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, jwtKey []by
r.Use(middleware.Timeout(60 * time.Second))
repository := db.NewRepository(dbConnection)
taskcafeHandler := TaskcafeHandler{*repository, jwtKey}
taskcafeHandler := TaskcafeHandler{*repository, securityConfig}
var imgServer = http.FileServer(http.Dir("./uploads/"))
r.Group(func(mux chi.Router) {
@ -91,7 +91,7 @@ func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, jwtKey []by
mux.Post("/auth/confirm", taskcafeHandler.ConfirmUser)
mux.Post("/auth/register", taskcafeHandler.RegisterUser)
})
auth := AuthenticationMiddleware{jwtKey}
auth := AuthenticationMiddleware{securityConfig.Secret}
r.Group(func(mux chi.Router) {
mux.Use(auth.Middleware)
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)

View File

@ -0,0 +1,21 @@
package utils
import (
"time"
log "github.com/sirupsen/logrus"
)
type SecurityConfig struct {
AccessTokenExpiration time.Duration
Secret []byte
}
func GetSecurityConfig(accessTokenExp string, secret []byte) (SecurityConfig, error) {
exp, err := time.ParseDuration(accessTokenExp)
if err != nil {
log.WithError(err).Error("issue parsing duration")
return SecurityConfig{}, err
}
return SecurityConfig{AccessTokenExpiration: exp, Secret: secret}, nil
}

21
internal/utils/version.go Normal file
View File

@ -0,0 +1,21 @@
package utils
var (
version = "dev"
commitHash = "none"
buildDate = "unknown"
)
type Info struct {
Version string
CommitHash string
BuildDate string
}
func Version() Info {
return Info{
Version: version,
CommitHash: commitHash,
BuildDate: buildDate,
}
}

View File

@ -8,12 +8,24 @@ import (
"net/http"
"os"
"strings"
"time"
"github.com/magefile/mage/mg"
"github.com/magefile/mage/sh"
"github.com/shurcooL/vfsgen"
)
const (
packageName = "github.com/jordanknott/taskcafe"
)
var ldflags = "-X $PACKAGE/internal/utils.commitHash=$COMMIT_HASH -X $PACKAGE/internal/utils.buildDate=$BUILD_DATE -X $PACKAGE/internal/utils.version=$VERSION"
func runWith(env map[string]string, cmd string, inArgs ...interface{}) error {
s := argsToStrings(inArgs...)
return sh.RunWith(env, cmd, s...)
}
// Aliases is a list of short names for often used commands
var Aliases = map[string]interface{}{
"s": Backend.Schema,
@ -85,10 +97,25 @@ func (Backend) GenFrontend() error {
return nil
}
func flagEnv() map[string]string {
hash, _ := sh.Output("git", "rev-parse", "--short", "HEAD")
fmt.Println("[ignore] fatal: no tag matches")
tag, err := sh.Output("git", "describe", "--exact-match", "--tags")
if err != nil {
tag = "nightly"
}
return map[string]string{
"PACKAGE": packageName,
"COMMIT_HASH": hash,
"BUILD_DATE": time.Now().Format("2006-01-02T15:04:05Z0700"),
"VERSION": tag,
}
}
// Build the Go api service
func (Backend) Build() error {
fmt.Println("compiling binary dist/taskcafe")
return sh.Run("go", "build", "-tags", "prod", "-o", "dist/taskcafe", "cmd/taskcafe/main.go")
return runWith(flagEnv(), "go", "build", "-ldflags", ldflags, "-tags", "prod", "-o", "dist/taskcafe", "cmd/taskcafe/main.go")
}
// Schema merges GraphQL schema files into single schema & runs gqlgen
@ -118,6 +145,11 @@ func (Backend) Schema() error {
return sh.Run("gqlgen")
}
func (Backend) Test() error {
fmt.Println("running taskcafe backend unit tests")
return sh.RunV("go", "test", "./...")
}
// Install runs frontend:install
func Install() {
mg.SerialDeps(Frontend.Install)
@ -140,3 +172,23 @@ func (Docker) Up() error {
func (Docker) Migrate() error {
return sh.RunV("docker-compose", "-p", "taskcafe", "-f", "docker-compose.yml", "-f", "docker-compose.migrate.yml", "run", "--rm", "migrate")
}
func argsToStrings(v ...interface{}) []string {
var args []string
for _, arg := range v {
switch v := arg.(type) {
case string:
if v != "" {
args = append(args, v)
}
case []string:
if v != nil {
args = append(args, v...)
}
default:
panic("invalid type")
}
}
return args
}

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