Compare commits

..

46 Commits

Author SHA1 Message Date
Jordan Knott
998db2a5da fix: add clarification to arguments for reset-password cmd 2022-09-02 12:05:43 -05:00
Jordan Knott
dfa8a4fba0 fix: frontend not being built due to lint errors 2022-09-02 12:05:20 -05:00
CarlosSalda
4f5aa2deb8 feat: new login mobile device 2021-12-11 10:50:31 -06:00
Jordan Knott
886b2763ee feat!: due date reminder notifications 2021-11-17 17:11:28 -06:00
Jordan Knott
0d00fc7518 feat: redesign due date manager 2021-11-05 22:35:57 -05:00
Jordan Knott
df6140a10f feat: change url structure to use short ids instead of full uuids 2021-11-04 14:08:30 -05:00
Jordan Knott
f9a5007104 feat: store notification filter state in localStorage 2021-11-04 11:27:26 -05:00
Jordan Knott
de6fe78004 fix: add check for when notifications is empty 2021-11-04 10:57:38 -05:00
Jordan Knott
799d7f3ad0 feat: add bell notification system for task assignment 2021-11-02 14:51:59 -05:00
Jordan Knott
3afd860534 fix: teams can now be created 2021-11-01 20:58:42 -05:00
Jordan Knott
cea99397db fix: user profile not rendering in top navbar 2021-10-30 17:20:41 -05:00
Jordan Knott
800dd2014c refactor: move config related code into dedicated package 2021-10-26 22:10:29 -05:00
Jordan Knott
54553cfbdd refactor: redesign notification table design 2021-10-26 21:35:48 -05:00
Jordan Knott
d5d85c5e30 refactor(magefile): update schema generator to use 0644 file permissions 2021-10-26 14:42:04 -05:00
Jordan Knott
ef2aadefbb refactor: add client log on task list change 2021-10-25 21:03:22 -05:00
Jordan Knott
cf63783174 refactor: split resolver into multiple files based on domain 2021-10-25 17:42:57 -05:00
Jordan Knott
fe90631df5 refactor: clean task control components 2021-10-25 15:38:20 -05:00
Jordan Knott
119a4b2868 feat: add comments badge to task card 2021-10-25 15:14:24 -05:00
Jordan Knott
3992e4c2de fix: task sort popup active checkmarks not showing 2021-10-24 10:57:46 -05:00
Jordan Knott
ce3afec8a0 fix: filtering tasks by label or member not working due to typescript
Upgrading all libraries fixed the error (ref.current is read-only)
2021-10-24 10:51:03 -05:00
Jordan Knott
25df251cc5 fix: remove translate on hover for gradient button 2021-10-24 10:51:03 -05:00
Jordan Knott
2b3084ea52 docs: update changelog 2021-10-24 10:51:03 -05:00
Mashiro
d725e42adf fix(docker-compose): add volume for uploads 2021-10-06 19:09:36 -05:00
Jordan Knott
aa84cbabb2 fix: add user popup is submittable again
react-form-hooks no longer played nice with custom input. created
a third input type `FormInput` that is made to play well
with the react-form-hooks.

also fixes auto complete overriding bg + text color on inputs.
2021-10-06 19:03:38 -05:00
Jordan Knott
8b1de30204 feat: redirect to register page if no users exist
fixes #130
2021-10-06 14:20:36 -05:00
Jordan Knott
eab33bfd9a refactor: fix docker tag names in release target 2021-09-13 13:15:34 -05:00
Jordan Knott
8d724fa3cf refactor: add release target 2021-09-13 13:07:49 -05:00
Jordan Knott
76e398488f fix: rewrite the label manager to no longer use useRef
useRef was causing a `readonly` error when trying to overwrite
`ref.current`. Rewrote components to use an Apollo query instead.

fixes #121
2021-09-13 12:44:02 -05:00
Jordan Knott
d1b867db35 deps: upgrade @types/react & @types/react-dom 2021-09-13 12:43:39 -05:00
Jordan Knott
aeb97a30d8 refactor: add docker testing targets to magefile 2021-09-13 11:23:09 -05:00
Jordan Knott
56e925a48d fix: add error to log when user creation fails 2021-09-13 11:22:48 -05:00
Jordan Knott
65cd431c1a fix: TaskDetails editor theme updated to work with latest version 2021-09-07 11:32:29 -05:00
Jordan Knott
a188c4b0ca fix: clean up component to fix lint warnings preventing frontend build 2021-09-04 14:08:44 -05:00
Jordan Knott
3bfce1825c docs: update unreleased changelog section 2021-09-04 13:16:03 -05:00
Jordan Knott
2b4f94117c fix: add missing rich-markdown-editor dependency
fixes #122
2021-09-04 12:16:01 -05:00
Jordan Knott
05799fce90 fix: hide any open popups when closing task details modal 2021-05-10 12:46:46 -05:00
Jordan Knott
b4f37350a9 refactor: switch to personal fork of rich-markdown-editor 2021-05-10 12:45:40 -05:00
Jordan Knott
8c6a3db0bc deps: upgrade all dependencies 2021-05-02 17:31:24 -05:00
Jordan Knott
5a9a66effe feat: apply new label to task when available 2021-04-30 23:49:12 -05:00
Jordan Knott
167d285d02 refactor: polling is now turned off in development mode 2021-04-30 23:36:58 -05:00
Jordan Knott
e2634dc490 feat: redirect after login when applicable 2021-04-30 23:25:48 -05:00
Jordan Knott
04c12e4da9 feat: projects can be set to public 2021-04-30 22:55:37 -05:00
Jordan Knott
3e72271d9b refactor(Project): split out components into their own files 2021-04-30 20:06:05 -05:00
Jordan Knott
bd34f4b3ad feat: change primary font to Open Sans 2021-04-30 16:35:43 -05:00
Jordan Knott
f45e359402 refactor: clean up components 2021-04-28 21:51:47 -05:00
Jordan Knott
229a53fa0a refactor: replace refresh & access token with auth token only
changes authentication to no longer use a refresh token & access token
for accessing protected endpoints. Instead only an auth token is used.

Before the login flow was:

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

now it looks like this:

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

the reasoning for using the refresh + access token was to reduce DB
calls, but in the end I don't think its worth the hassle.
2021-04-28 21:38:49 -05:00
219 changed files with 25128 additions and 16081 deletions

View File

@ -10,6 +10,8 @@ windows:
- yarn:
- cd frontend
- yarn start
- worker:
- go run cmd/taskcafe/main.go worker
- web/editor:
root: ./frontend
panes:

View File

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

View File

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

View File

@ -12,6 +12,9 @@ services:
environment:
TASKCAFE_DATABASE_HOST: postgres
TASKCAFE_MIGRATE: "true"
volumes:
- taskcafe-uploads:/root/uploads
postgres:
image: postgres:12.3-alpine
restart: always
@ -27,6 +30,8 @@ services:
volumes:
taskcafe-postgres:
external: false
taskcafe-uploads:
external: false
networks:
taskcafe-test:

2
frontend/.env Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Switch, Route, useHistory } from 'react-router-dom';
import { Switch, Route, useHistory, useLocation, Redirect } from 'react-router-dom';
import * as H from 'history';
import Dashboard from 'Dashboard';
@ -13,8 +13,6 @@ import Login from 'Auth';
import Register from 'Register';
import Profile from 'Profile';
import styled from 'styled-components';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context';
const MainContent = styled.div`
@ -26,67 +24,64 @@ const MainContent = styled.div`
flex-grow: 1;
`;
type RefreshTokenResponse = {
accessToken: string;
setup?: null | { confirmToken: string };
type ValidateTokenResponse = {
valid: boolean;
userID: string;
};
const AuthorizedRoutes = () => {
const history = useHistory();
const UserRequiredRoute: React.FC<any> = ({ children }) => {
const { user } = useCurrentUser();
const location = useLocation();
if (user) {
return children;
}
return (
<Redirect
to={{
pathname: '/login',
state: { redirect: location.pathname },
}}
/>
);
};
const Routes: React.FC = () => {
const [loading, setLoading] = useState(true);
const { setUser } = useCurrentUser();
useEffect(() => {
fetch('/auth/refresh_token', {
fetch('/auth/validate', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken, setup } = response;
if (setup) {
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
}
}).then(async (x) => {
const response: ValidateTokenResponse = await x.json();
const { valid, userID } = response;
if (valid) {
setUser(userID);
}
setLoading(false);
});
}, []);
return loading ? null : (
<Switch>
<MainContent>
<Route exact path="/" component={Dashboard} />
<Route exact path="/projects" component={Projects} />
<Route path="/projects/:projectID" component={Project} />
<Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
<Route path="/tasks" component={MyTasks} />
</MainContent>
</Switch>
);
};
type RoutesProps = {
history: H.History;
};
const Routes: React.FC<RoutesProps> = () => (
if (loading) return null;
return (
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/register" component={Register} />
<Route exact path="/confirm" component={Confirm} />
<AuthorizedRoutes />
<Switch>
<MainContent>
<Route path="/p/:projectID" component={Project} />
<UserRequiredRoute>
<Route exact path="/" component={Projects} />
<Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
<Route path="/tasks" component={MyTasks} />
</UserRequiredRoute>
</MainContent>
</Switch>
</Switch>
);
};
export default Routes;

35
frontend/src/App/Toast.ts Normal file
View File

@ -0,0 +1,35 @@
import styled from 'styled-components';
import { ToastContainer } from 'react-toastify';
const ToastedContainer = styled(ToastContainer).attrs({
// custom props
})`
.Toastify__toast-container {
}
.Toastify__toast {
padding: 5px;
margin-left: 5px;
margin-right: 5px;
border-radius: 10px;
background: #7367f0;
color: #fff;
}
.Toastify__toast--error {
background: ${props => props.theme.colors.danger};
}
.Toastify__toast--warning {
background: ${props => props.theme.colors.warning};
}
.Toastify__toast--success {
background: ${props => props.theme.colors.success};
}
.Toastify__toast-body {
}
.Toastify__progress-bar {
}
.Toastify__close-button {
display: none;
}
`;
export default ToastedContainer;

View File

@ -1,16 +1,13 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState } from 'react';
import styled from 'styled-components/macro';
import { useGetProjectsQuery } from 'shared/generated/graphql';
import { Link } from 'react-router-dom';
import LoadingSpinner from 'shared/components/LoadingSpinner';
import 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;
@ -27,6 +24,7 @@ const TeamTitleText = styled.span`
font-size: 14px;
font-weight: 700;
`;
const TeamProjects = styled.div`
display: flex;
flex-direction: column;

View File

@ -0,0 +1,89 @@
import React, { useState } from 'react';
import ProjectSettings, { DeleteConfirm, DELETE_INFO, PublicConfirm } from 'shared/components/ProjectSettings';
import {
useDeleteProjectMutation,
GetProjectsDocument,
useToggleProjectVisibilityMutation,
} from 'shared/generated/graphql';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer';
type ProjectPopupProps = {
history: any;
name: string;
publicOn: string | null;
projectID: string;
};
const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, projectID, publicOn: initialPublicOn }) => {
const { hidePopup, setTab } = usePopup();
const [publicOn, setPublicOn] = useState(initialPublicOn);
const [toggleProjectVisibility] = useToggleProjectVisibilityMutation({
onCompleted: data => {
setPublicOn(data.toggleProjectVisibility.project.publicOn);
},
});
const [deleteProject] = useDeleteProjectMutation({
update: (client, deleteData) => {
const cacheData: any = client.readQuery({
query: GetProjectsDocument,
});
const newData = produce(cacheData, (draftState: any) => {
draftState.projects = draftState.projects.filter(
(project: any) => project.id !== deleteData.data?.deleteProject.project.id,
);
});
client.writeQuery({
query: GetProjectsDocument,
data: {
...newData,
},
});
},
});
return (
<>
<Popup title={null} tab={0}>
<ProjectSettings
publicOn={publicOn}
onToggleProjectVisible={visible => {
if (visible) {
setTab(2, { width: 300 });
} else {
toggleProjectVisibility({ variables: { projectID, isPublic: false } });
}
}}
onDeleteProject={() => {
setTab(1, { width: 300 });
}}
/>
</Popup>
<Popup title="Change to public?" tab={1}>
<PublicConfirm
onConfirm={() => {
if (projectID) {
toggleProjectVisibility({ variables: { projectID, isPublic: true } });
}
}}
/>
</Popup>
<Popup title={`Delete the "${name}" project?`} tab={1}>
<DeleteConfirm
description={DELETE_INFO.DELETE_PROJECTS.description}
deletedItems={DELETE_INFO.DELETE_PROJECTS.deletedItems}
onConfirmDelete={() => {
if (projectID) {
deleteProject({ variables: { projectID } });
hidePopup();
history.push('/projects');
}
}}
/>
</Popup>
</>
);
};
export default ProjectPopup;

View File

@ -1,76 +1,24 @@
import React from 'react';
import React, { useState } from 'react';
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
import LoggedOutNavbar from 'shared/components/TopNavbar/LoggedOut';
import { ProfileMenu } from 'shared/components/DropdownMenu';
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { useHistory } from 'react-router';
import { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import polling from 'shared/utils/polling';
import { useHistory, useRouteMatch } from 'react-router';
import { useCurrentUser } from 'App/context';
import {
RoleCode,
useTopNavbarQuery,
useDeleteProjectMutation,
GetProjectsDocument,
useNotificationAddedSubscription,
useHasUnreadNotificationsQuery,
} from 'shared/generated/graphql';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer';
import MiniProfile from 'shared/components/MiniProfile';
import cache from 'App/cache';
import NotificationPopup, { NotificationItem } from 'shared/components/NotifcationPopup';
import theme from './ThemeStyles';
import theme from 'App/ThemeStyles';
import ProjectFinder from './ProjectFinder';
type ProjectPopupProps = {
history: any;
name: string;
projectID: string;
};
export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, projectID }) => {
const { hidePopup, setTab } = usePopup();
const [deleteProject] = useDeleteProjectMutation({
update: (client, deleteData) => {
const cacheData: any = client.readQuery({
query: GetProjectsDocument,
});
const newData = produce(cacheData, (draftState: any) => {
draftState.projects = draftState.projects.filter(
(project: any) => project.id !== deleteData.data?.deleteProject.project.id,
);
});
client.writeQuery({
query: GetProjectsDocument,
data: {
...newData,
},
});
},
});
return (
<>
<Popup title={null} tab={0}>
<ProjectSettings
onDeleteProject={() => {
setTab(1, { width: 300 });
}}
/>
</Popup>
<Popup title={`Delete the "${name}" project?`} tab={1}>
<DeleteConfirm
description={DELETE_INFO.DELETE_PROJECTS.description}
deletedItems={DELETE_INFO.DELETE_PROJECTS.deletedItems}
onConfirmDelete={() => {
if (projectID) {
deleteProject({ variables: { projectID } });
hidePopup();
history.push('/projects');
}
}}
/>
</Popup>
</>
);
};
// TODO: Move to context based navbar?
type GlobalTopNavbarProps = {
nameOnly?: boolean;
@ -91,7 +39,7 @@ type GlobalTopNavbarProps = {
onRemoveInvitedFromBoard?: (email: string) => void;
};
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
currentTab,
onSetTab,
menuType,
@ -107,31 +55,33 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
onRemoveInvitedFromBoard,
onRemoveFromBoard,
}) => {
const { user, setUserRoles, setUser } = useCurrentUser();
const { loading, data } = useTopNavbarQuery({
onCompleted: response => {
if (user && user.roles) {
setUserRoles({
org: user.roles.org,
teams: response.me.teamRoles.reduce((map, obj) => {
map.set(obj.teamID, obj.roleCode);
return map;
}, new Map<string, string>()),
projects: response.me.projectRoles.reduce((map, obj) => {
map.set(obj.projectID, obj.roleCode);
return map;
}, new Map<string, string>()),
const [notifications, setNotifications] = useState<Array<{ id: string; notification: { actionType: string } }>>([]);
const { data } = useTopNavbarQuery({
onCompleted: (d) => {
setNotifications((n) => [...n, ...d.notifications]);
},
});
const { data: nData, loading } = useNotificationAddedSubscription({
onSubscriptionData: (d) => {
setNotifications((n) => {
if (d.subscriptionData.data) {
return [...n, d.subscriptionData.data.notificationAdded];
}
return n;
});
},
});
const { showPopup, hidePopup } = usePopup();
const { setUser } = useCurrentUser();
const { data: unreadData, refetch: refetchHasUnread } = useHasUnreadNotificationsQuery({
pollInterval: polling.UNREAD_NOTIFICATIONS,
});
const history = useHistory();
const onLogout = () => {
fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
}).then(async x => {
}).then(async (x) => {
const { status } = x;
if (status === 200) {
cache.reset();
@ -147,7 +97,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
<Popup title={null} tab={0}>
<ProfileMenu
onLogout={onLogout}
showAdminConsole={user ? user.roles.org === 'admin' : false}
showAdminConsole // TODO: add permision check
onAdminConsole={() => {
history.push('/admin');
hidePopup();
@ -168,30 +118,20 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
}
};
// TODO: rewrite popup to contain subscription and notification fetch
const onNotificationClick = ($target: React.RefObject<HTMLElement>) => {
if (data) {
showPopup(
$target,
<NotificationPopup>
{data.notifications.map(notification => (
<NotificationItem
title={notification.entity.name}
description={`${notification.actor.name} added you as a meber to the task "${notification.entity.name}"`}
createdAt={notification.createdAt}
/>
))}
</NotificationPopup>,
{ width: 415, borders: false, diamondColor: theme.colors.primary },
);
}
showPopup($target, <NotificationPopup onToggleRead={() => refetchHasUnread()} />, {
width: 605,
borders: false,
diamondColor: theme.colors.primary,
});
};
if (!user) {
return null;
}
const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
// TODO: readd permision check
// const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
const userIsTeamOrProjectAdmin = true;
const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => {
const member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null;
const member = projectInvitedMembers ? projectInvitedMembers.find((u) => u.email === email) : null;
if (member) {
showPopup(
$targetRef,
@ -219,7 +159,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
};
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
const member = projectMembers ? projectMembers.find((u) => u.id === memberID) : null;
const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
if (member) {
@ -228,7 +168,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
<MiniProfile
warning={member.role && member.role.code === 'owner' ? warning : null}
canChangeRole={userIsTeamOrProjectAdmin}
onChangeRole={roleCode => {
onChangeRole={(roleCode) => {
if (onChangeRole) {
onChangeRole(member.id, roleCode);
}
@ -249,12 +189,15 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
}
};
const user = data ? data.me?.user : null;
return (
<>
<TopNavbar
hasUnread={unreadData ? unreadData.hasUnreadNotifications.unread : false}
name={name}
menuType={menuType}
onOpenProjectFinder={$target => {
onOpenProjectFinder={($target) => {
showPopup(
$target,
<Popup tab={0} title={null}>
@ -263,7 +206,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
);
}}
currentTab={currentTab}
user={data ? data.me.user : null}
user={user ?? null}
canEditProjectName={userIsTeamOrProjectAdmin}
canInviteUser={userIsTeamOrProjectAdmin}
onMemberProfile={onMemberProfile}
@ -290,4 +233,46 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
);
};
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
currentTab,
onSetTab,
menuType,
teamID,
onChangeProjectOwner,
onChangeRole,
name,
popupContent,
projectMembers,
projectInvitedMembers,
onInviteUser,
onSaveProjectName,
onRemoveInvitedFromBoard,
onRemoveFromBoard,
}) => {
const { user } = useCurrentUser();
const match = useRouteMatch();
if (user) {
return (
<LoggedInNavbar
currentTab={currentTab}
projectID={null}
onSetTab={onSetTab}
menuType={menuType}
teamID={teamID}
onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner}
name={name}
popupContent={popupContent}
projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
onInviteUser={onInviteUser}
onSaveProjectName={onSaveProjectName}
onRemoveInvitedFromBoard={onRemoveInvitedFromBoard}
onRemoveFromBoard={onRemoveFromBoard}
/>
);
}
return <LoggedOutNavbar match={match.url} name={name} menuType={menuType} />;
};
export default GlobalTopNavbar;

View File

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

View File

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

View File

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

View File

@ -1,75 +1,32 @@
import React, { useState, useEffect } from 'react';
import jwtDecode from 'jwt-decode';
import { createBrowserHistory } from 'history';
import { Router } from 'react-router';
import { BrowserRouter } from 'react-router-dom';
import { PopupProvider } from 'shared/components/PopupMenu';
import { ToastContainer } from 'react-toastify';
import { setAccessToken } from 'shared/utils/accessToken';
import styled, { ThemeProvider } from 'styled-components';
import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles';
import theme from './ThemeStyles';
import Routes from './Routes';
import { UserContext, CurrentUserRaw, CurrentUserRoles, PermissionLevel, PermissionObjectType } from './context';
import ToastedContainer from './Toast';
import { UserContext } from './context';
import 'react-toastify/dist/ReactToastify.css';
const StyledContainer = styled(ToastContainer).attrs({
// custom props
})`
.Toastify__toast-container {
}
.Toastify__toast {
padding: 5px;
margin-left: 5px;
margin-right: 5px;
border-radius: 10px;
background: #7367f0;
color: #fff;
}
.Toastify__toast--error {
background: ${props => props.theme.colors.danger};
}
.Toastify__toast--warning {
background: ${props => props.theme.colors.warning};
}
.Toastify__toast--success {
background: ${props => props.theme.colors.success};
}
.Toastify__toast-body {
}
.Toastify__progress-bar {
}
.Toastify__close-button {
display: none;
}
`;
const history = createBrowserHistory();
import './fonts.css';
const App = () => {
const [user, setUser] = useState<CurrentUserRaw | null>(null);
const setUserRoles = (roles: CurrentUserRoles) => {
if (user) {
setUser({
...user,
roles,
});
}
};
const [user, setUser] = useState<string | null>(null);
return (
<>
<UserContext.Provider value={{ user, setUser, setUserRoles }}>
<UserContext.Provider value={{ user, setUser }}>
<ThemeProvider theme={theme}>
<NormalizeStyles />
<BaseStyles />
<Router history={history}>
<BrowserRouter>
<PopupProvider>
<Routes history={history} />
<Routes />
</PopupProvider>
</Router>
<StyledContainer
</BrowserRouter>
<ToastedContainer
position="bottom-right"
autoClose={5000}
hideProgressBar

View File

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

View File

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

View File

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

View File

@ -26,6 +26,7 @@ import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import DueDateManager from 'shared/components/DueDateManager';
import dayjs from 'dayjs';
import useStickyState from 'shared/hooks/useStickyState';
import { StaticContext } from 'react-router';
import MyTasksSortPopup from './MyTasksSort';
import MyTasksStatusPopup from './MyTasksStatus';
import TaskEntry from './TaskEntry';
@ -61,11 +62,7 @@ function prettySort(sort: MyTasksSort) {
if (sort === MyTasksSort.None) {
return 'Sort';
}
return `Sort: ${sort.charAt(0) +
sort
.slice(1)
.toLowerCase()
.replace(/_/gi, ' ')}`;
return `Sort: ${sort.charAt(0) + sort.slice(1).toLowerCase().replace(/_/gi, ' ')}`;
}
type Group = {
@ -75,7 +72,7 @@ type Group = {
};
const DueDateEditorLabel = styled.div`
align-items: center;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
font-size: 11px;
padding: 0 8px;
@ -107,16 +104,16 @@ const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
display: flex;
align-items: center;
font-size: 15px;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
&:not(:last-of-type) {
margin-right: 16px;
}
&:hover {
color: ${props => props.theme.colors.text.secondary};
color: ${(props) => props.theme.colors.text.secondary};
}
${props =>
${(props) =>
props.disabled &&
css`
opacity: 0.5;
@ -150,7 +147,7 @@ const ProjectAction: React.FC<ProjectActionProps> = ({ onClick, disabled = false
const EditorPositioner = styled.div<{ top: number; left: number }>`
position: absolute;
top: ${p => p.top}px;
top: ${(p) => p.top}px;
justify-content: flex-end;
margin-left: -100vw;
z-index: 10000;
@ -160,7 +157,7 @@ const EditorPositioner = styled.div<{ top: number; left: number }>`
height: 0;
position: fixed;
width: 100vw;
left: ${p => p.left}px;
left: ${(p) => p.left}px;
`;
const EditorPositionerContents = styled.div`
@ -168,15 +165,15 @@ const EditorPositionerContents = styled.div`
`;
const EditorContainer = styled.div<{ width: number }>`
border: 1px solid ${props => props.theme.colors.primary};
background: ${props => props.theme.colors.bg.secondary};
border: 1px solid ${(props) => props.theme.colors.primary};
background: ${(props) => props.theme.colors.bg.secondary};
position: relative;
width: ${p => p.width}px;
width: ${(p) => p.width}px;
`;
const EditorCell = styled.div<{ width: number }>`
display: flex;
width: ${p => p.width}px;
width: ${(p) => p.width}px;
`;
// TABLE
@ -224,7 +221,7 @@ const TaskGroupItems = styled.div`
`;
const ProjectPill = styled.div`
background-color: ${props => props.theme.colors.bg.primary};
background-color: ${(props) => props.theme.colors.bg.primary};
text-overflow: ellipsis;
border-radius: 10px;
box-sizing: border-box;
@ -250,7 +247,7 @@ const ProjectPillName = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
`;
const ProjectPillColor = styled.svg`
@ -299,7 +296,7 @@ const OptionTitle = styled.div`
white-space: nowrap;
`;
const OptionSubTitle = styled.div`
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
font-size: 11px;
margin-left: 8px;
min-width: 50px;
@ -319,7 +316,7 @@ const Option = ({ innerProps, data }: any) => {
};
const TaskGroupHeaderContents = styled.div<{ width: number }>`
width: ${p => p.width}px;
width: ${(p) => p.width}px;
left: 0;
position: absolute;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;
@ -356,13 +353,13 @@ const TaskGroupMinify = styled.div`
transition-property: background, border, box-shadow, fill;
cursor: pointer;
svg {
fill: ${props => props.theme.colors.text.primary};
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};
fill: ${(props) => props.theme.colors.text.secondary};
}
`;
const TaskGroupName = styled.div`
@ -371,7 +368,7 @@ const TaskGroupName = styled.div`
display: flex;
height: 50px;
min-width: 1px;
color: ${props => props.theme.colors.text.secondary};
color: ${(props) => props.theme.colors.text.secondary};
font-weight: 400;
`;
@ -393,7 +390,7 @@ const Row = styled.div`
`;
const RowHeaderLeft = styled.div<{ width: number }>`
width: ${p => p.width}px;
width: ${(p) => p.width}px;
align-items: stretch;
display: flex;
@ -405,7 +402,7 @@ const RowHeaderLeft = styled.div<{ width: number }>`
`;
const RowHeaderLeftInner = styled.div`
align-items: stretch;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
display: flex;
flex: 1 0 auto;
font-size: 12px;
@ -429,7 +426,7 @@ const RowHeaderLeftNameText = styled.div`
`;
const RowHeaderRight = styled.div<{ left: number }>`
left: ${p => p.left}px;
left: ${(p) => p.left}px;
right: 0px;
height: 37px;
position: absolute;
@ -461,7 +458,7 @@ const RowHeaderRightContainer = styled.div`
`;
const ItemWrapper = styled.div<{ width: number }>`
width: ${p => p.width}px;
width: ${(p) => p.width}px;
align-items: center;
border: 1px solid #414561;
border-bottom: 0;
@ -474,11 +471,11 @@ const ItemWrapper = styled.div<{ width: number }>`
margin-right: -1px;
padding: 0 8px;
position: relative;
color: ${props => props.theme.colors.text.primary};
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};
background: ${(props) => props.theme.colors.primary};
color: ${(props) => props.theme.colors.text.secondary};
}
`;
const ItemsContainer = styled.div`
@ -565,14 +562,37 @@ const Projects = () => {
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 } }));
hidePopup();
updateTaskDueDate({
variables: {
taskID: dateEditor.task.id,
dueDate,
hasTime,
deleteNotifications: [],
updateNotifications: [],
createNotifications: [],
},
});
setDateEditor((prev) => ({
...prev,
task: { ...task, dueDate: { at: dueDate.toISOString(), notifications: [] }, hasTime },
}));
}
}}
onRemoveDueDate={task => {
onRemoveDueDate={(task) => {
if (dateEditor.task) {
updateTaskDueDate({ variables: { taskID: dateEditor.task.id, dueDate: null, hasTime: false } });
setDateEditor(prev => ({ ...prev, task: { ...task, hasTime: false } }));
hidePopup();
updateTaskDueDate({
variables: {
taskID: dateEditor.task.id,
dueDate: null,
hasTime: false,
deleteNotifications: [],
updateNotifications: [],
createNotifications: [],
},
});
setDateEditor((prev) => ({ ...prev, task: { ...task, hasTime: false } }));
}
}}
/>
@ -587,8 +607,8 @@ const Projects = () => {
updateApolloCache<MyTasksQuery>(
client,
MyTasksDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (newTaskData.data) {
draftCache.myTasks.tasks.unshift(newTaskData.data.createTask);
}
@ -618,7 +638,7 @@ const Projects = () => {
groups.push({
id: 'recently-assigned',
name: 'Recently Assigned',
tasks: data.myTasks.tasks.map(task => ({
tasks: data.myTasks.tasks.map((task) => ({
...task,
labels: [],
position: 0,
@ -628,27 +648,27 @@ const Projects = () => {
let { tasks } = data.myTasks;
if (filters.sort === MyTasksSort.DueDate) {
const group: Group = { id: 'due_date', name: null, tasks: [] };
data.myTasks.tasks.forEach(task => {
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);
tasks = tasks.filter((t) => t.dueDate === null);
}
const projects = new Map<string, Array<Task>>();
data.myTasks.projects.forEach(p => {
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);
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);
const project = data.projects.find((c) => c.id === id);
if (pTasks.length === 0) continue;
if (project) {
groups.push({
@ -658,8 +678,8 @@ const Projects = () => {
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);
const first = dayjs(a.dueDate.at);
const second = dayjs(b.dueDate.at);
if (first.isSame(second, 'minute')) return 0;
if (first.isAfter(second)) return -1;
return 1;
@ -681,13 +701,13 @@ const Projects = () => {
<ProjectActions />
<ProjectActions>
<ProjectAction
onClick={$target => {
onClick={($target) => {
showPopup(
$target,
<MyTasksStatusPopup
status={filters.status}
onChangeStatus={status => {
setFilters(prev => ({ ...prev, status }));
onChangeStatus={(status) => {
setFilters((prev) => ({ ...prev, status }));
hidePopup();
}}
/>,
@ -699,13 +719,13 @@ const Projects = () => {
<ProjectActionText>{prettyStatus(filters.status)}</ProjectActionText>
</ProjectAction>
<ProjectAction
onClick={$target => {
onClick={($target) => {
showPopup(
$target,
<MyTasksSortPopup
sort={filters.sort}
onChangeSort={sort => {
setFilters(prev => ({ ...prev, sort }));
onChangeSort={(sort) => {
setFilters((prev) => ({ ...prev, sort }));
hidePopup();
}}
/>,
@ -752,8 +772,8 @@ const Projects = () => {
<VerticalScoller>
<VerticalScollerInner>
<TableContents>
{groups.map(group => {
const isMinified = minified.find(m => m === group.id) ?? false;
{groups.map((group) => {
const isMinified = minified.find((m) => m === group.id) ?? false;
return (
<TaskGroupContainer key={group.id}>
{group.name && (
@ -761,9 +781,9 @@ const Projects = () => {
<TaskGroupHeaderContents width={leftRow}>
<TaskGroupMinify
onClick={() => {
setMinified(prev => {
setMinified((prev) => {
if (isMinified) {
return prev.filter(c => c !== group.id);
return prev.filter((c) => c !== group.id);
}
return [...prev, group.id];
});
@ -781,29 +801,40 @@ const Projects = () => {
)}
<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;
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 => {
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 } });
updateTaskDueDate({
variables: {
taskID: task.id,
dueDate: null,
hasTime: false,
deleteNotifications: [],
updateNotifications: [],
createNotifications: [],
},
});
}}
project={projectName ?? 'none'}
dueDate={task.dueDate}
dueDate={task.dueDate.at}
hasTime={task.hasTime ?? false}
name={task.name}
onEditName={name => updateTaskName({ variables: { taskID: task.id, name } })}
onEditName={(name) => updateTaskName({ variables: { taskID: task.id, name } })}
onEditProject={onEditProject}
onEditDueDate={$target => onEditDueDate({ ...task, position: 0, labels: [] }, $target)}
onEditDueDate={($target) =>
onEditDueDate({ ...task, position: 0, labels: [] }, $target)
}
/>
);
})}
@ -822,7 +853,9 @@ const Projects = () => {
<EditorCell width={120}>
<DueDateEditorLabel>
{dateEditor.task.dueDate
? dayjs(dateEditor.task.dueDate).format(dateEditor.task.hasTime ? 'MMM D [at] h:mm A' : 'MMM D')
? dayjs(dateEditor.task.dueDate.at).format(
dateEditor.task.hasTime ? 'MMM D [at] h:mm A' : 'MMM D',
)
: ''}
</DueDateEditorLabel>
</EditorCell>
@ -856,12 +889,12 @@ const Projects = () => {
)}
<Route
path={`${match.path}/c/:taskID`}
render={(routeProps: RouteComponentProps<TaskRouteProps>) => (
render={() => {
return (
<Details
refreshCache={NOOP}
availableMembers={[]}
projectURL={`${match.url}`}
taskID={routeProps.match.params.taskID}
onTaskNameChange={(updatedTask, newName) => {
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
}}
@ -880,7 +913,7 @@ const Projects = () => {
});
*/
}}
onDeleteTask={deletedTask => {
onDeleteTask={(deletedTask) => {
// deleteTask({ variables: { taskID: deletedTask.id } });
history.push(`${match.url}`);
}}
@ -902,7 +935,8 @@ const Projects = () => {
*/
}}
/>
)}
);
}}
/>
</>
);

View File

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

View File

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

View File

@ -1,7 +1,11 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/utils/sorting';
import { mixin } from 'shared/utils/styles';
import { Checkmark } from 'shared/icons';
const ActiveIcon = styled(Checkmark)`
position: absolute;
`;
export const ActionsList = styled.ul`
margin: 0;
@ -21,7 +25,7 @@ export const ActionItem = styled.li`
align-items: center;
font-size: 14px;
&:hover {
background: ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.primary};
}
`;
@ -29,21 +33,12 @@ 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;
`;
type SortPopupProps = {
type ControlSortProps = {
sorting: TaskSorting;
onChangeTaskSorting: (taskSorting: TaskSorting) => void;
};
const SortPopup: React.FC<SortPopupProps> = ({ sorting, onChangeTaskSorting }) => {
const ControlSort: React.FC<ControlSortProps> = ({ sorting, onChangeTaskSorting }) => {
const [currentSorting, setSorting] = useState(sorting);
const handleSetSorting = (s: TaskSorting) => {
setSorting(s);
@ -52,35 +47,41 @@ const SortPopup: React.FC<SortPopupProps> = ({ sorting, onChangeTaskSorting }) =
return (
<ActionsList>
<ActionItem onClick={() => handleSetSorting({ type: TaskSortingType.NONE, direction: TaskSortingDirection.ASC })}>
{currentSorting.type === TaskSortingType.NONE && <ActiveIcon width={12} height={12} />}
<ActionTitle>None</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.DUE_DATE, direction: TaskSortingDirection.ASC })}
>
{currentSorting.type === TaskSortingType.DUE_DATE && <ActiveIcon width={12} height={12} />}
<ActionTitle>Due date</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.MEMBERS, direction: TaskSortingDirection.ASC })}
>
{currentSorting.type === TaskSortingType.MEMBERS && <ActiveIcon width={12} height={12} />}
<ActionTitle>Members</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.LABELS, direction: TaskSortingDirection.ASC })}
>
{currentSorting.type === TaskSortingType.LABELS && <ActiveIcon width={12} height={12} />}
<ActionTitle>Labels</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
>
{currentSorting.type === TaskSortingType.TASK_TITLE && <ActiveIcon width={12} height={12} />}
<ActionTitle>Task title</ActionTitle>
</ActionItem>
<ActionItem
onClick={() => handleSetSorting({ type: TaskSortingType.COMPLETE, direction: TaskSortingDirection.ASC })}
>
{currentSorting.type === TaskSortingType.COMPLETE && <ActiveIcon width={12} height={12} />}
<ActionTitle>Complete</ActionTitle>
</ActionItem>
</ActionsList>
);
};
export default SortPopup;
export default ControlSort;

View File

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

View File

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

View File

@ -4,13 +4,15 @@ import TaskDetails from 'shared/components/TaskDetails';
import TaskDetailsLoading from 'shared/components/TaskDetails/Loading';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import MemberManager from 'shared/components/MemberManager';
import { useRouteMatch, useHistory } from 'react-router';
import { useRouteMatch, useHistory, useParams } from 'react-router';
import {
useDeleteTaskChecklistMutation,
useToggleTaskWatchMutation,
useUpdateTaskChecklistNameMutation,
useUpdateTaskChecklistItemLocationMutation,
useCreateTaskChecklistMutation,
useFindTaskQuery,
DueDateNotificationDuration,
useUpdateTaskDueDateMutation,
useSetTaskCompleteMutation,
useAssignTaskMutation,
@ -36,6 +38,7 @@ import Input from 'shared/components/Input';
import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache';
import NOOP from 'shared/utils/noop';
import polling from 'shared/utils/polling';
export const ActionsList = styled.ul`
margin: 0;
@ -55,7 +58,7 @@ export const ActionItem = styled.li`
align-items: center;
font-size: 14px;
&:hover {
background: ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.primary};
}
`;
@ -165,10 +168,8 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
defaultValue="Checklist"
width="100%"
label="Name"
id="name"
name="name"
variant="alternate"
ref={register({ required: 'Checklist name is required' })}
{...register('name', { required: 'Checklist name is required' })}
/>
<CreateChecklistButton type="submit">Create</CreateChecklistButton>
</CreateChecklistForm>
@ -176,7 +177,6 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
};
type DetailsProps = {
taskID: string;
projectURL: string;
onTaskNameChange: (task: Task, newName: string) => void;
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
@ -190,7 +190,6 @@ const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 };
const Details: React.FC<DetailsProps> = ({
projectURL,
taskID,
onTaskNameChange,
onTaskDescriptionChange,
onDeleteTask,
@ -199,6 +198,7 @@ const Details: React.FC<DetailsProps> = ({
refreshCache,
}) => {
const { user } = useCurrentUser();
const { taskID } = useParams<{ taskID: string }>();
const { showPopup, hidePopup } = usePopup();
const history = useHistory();
const [deleteTaskComment] = useDeleteTaskCommentMutation({
@ -206,11 +206,11 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
draftCache.findTask.comments = cache.findTask.comments.filter(
c => c.id !== response.data?.deleteTaskComment.commentID,
(c) => c.id !== response.data?.deleteTaskComment.commentID,
);
}
}),
@ -218,13 +218,14 @@ const Details: React.FC<DetailsProps> = ({
);
},
});
const [toggleTaskWatch] = useToggleTaskWatchMutation();
const [createTaskComment] = useCreateTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
draftCache.findTask.comments.push({
...response.data.createTaskComment.comment,
@ -241,18 +242,18 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (response.data) {
const { prevChecklistID, taskChecklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
if (taskChecklistID !== prevChecklistID) {
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex(c => c.id === taskChecklistID);
const oldIdx = cache.findTask.checklists.findIndex((c) => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex((c) => c.id === taskChecklistID);
if (oldIdx > -1 && newIdx > -1) {
const item = cache.findTask.checklists[oldIdx].items.find(i => i.id === checklistItem.id);
const item = cache.findTask.checklists[oldIdx].items.find((i) => i.id === checklistItem.id);
if (item) {
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
i => i.id !== checklistItem.id,
(i) => i.id !== checklistItem.id,
);
draftCache.findTask.checklists[newIdx].items.push({
...item,
@ -269,12 +270,12 @@ const Details: React.FC<DetailsProps> = ({
},
});
const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({
update: client => {
update: (client) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
@ -291,11 +292,11 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
const { checklists } = cache.findTask;
draftCache.findTask.checklists = checklists.filter(
c => c.id !== deleteData.data?.deleteTaskChecklist.taskChecklist.id,
(c) => c.id !== deleteData.data?.deleteTaskChecklist.taskChecklist.id,
);
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
@ -317,8 +318,8 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (createData.data) {
const item = createData.data.createTaskChecklist;
draftCache.findTask.checklists.push({ ...item });
@ -334,14 +335,14 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (deleteData.data) {
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
const targetIdx = cache.findTask.checklists.findIndex((c) => c.id === item.taskChecklistID);
if (targetIdx > -1) {
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
c => item.id !== c.id,
(c) => item.id !== c.id,
);
}
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
@ -361,12 +362,12 @@ const Details: React.FC<DetailsProps> = ({
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
(cache) =>
produce(cache, (draftCache) => {
if (newTaskItem.data) {
const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask;
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
const idx = checklists.findIndex((c) => c.id === item.taskChecklistID);
if (idx !== -1) {
draftCache.findTask.checklists[idx].items.push({ ...item });
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
@ -384,7 +385,7 @@ const Details: React.FC<DetailsProps> = ({
});
const { loading, data, refetch } = useFindTaskQuery({
variables: { taskID },
pollInterval: 3000,
pollInterval: polling.TASK_DETAILS,
fetchPolicy: 'cache-and-network',
});
const [setTaskComplete] = useSetTaskCompleteMutation();
@ -415,6 +416,7 @@ const Details: React.FC<DetailsProps> = ({
width={1070}
onClose={() => {
history.push(projectURL);
hidePopup();
}}
renderContent={() => {
return data ? (
@ -424,7 +426,7 @@ const Details: React.FC<DetailsProps> = ({
updateTaskComment({ variables: { commentID, message } });
}}
editableComment={editableComment}
me={data.me.user}
me={data.me ? data.me.user : null}
onCommentShowActions={(commentID, $targetRef) => {
showPopup(
$targetRef,
@ -441,10 +443,23 @@ const Details: React.FC<DetailsProps> = ({
);
}}
task={data.findTask}
onToggleTaskWatch={(task, watched) => {
toggleTaskWatch({
variables: { taskID: task.id },
optimisticResponse: {
__typename: 'Mutation',
toggleTaskWatch: {
id: task.id,
__typename: 'Task',
watched,
},
},
});
}}
onCreateComment={(task, message) => {
createTaskComment({ variables: { taskID: task.id, message } });
}}
onChecklistDrop={checklist => {
onChecklistDrop={(checklist) => {
updateTaskChecklistLocation({
variables: { taskChecklistID: checklist.id, position: checklist.position },
@ -486,7 +501,7 @@ const Details: React.FC<DetailsProps> = ({
}}
onTaskNameChange={onTaskNameChange}
onTaskDescriptionChange={onTaskDescriptionChange}
onToggleTaskComplete={task => {
onToggleTaskComplete={(task) => {
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
}}
onDeleteTask={onDeleteTask}
@ -531,7 +546,7 @@ const Details: React.FC<DetailsProps> = ({
createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
}}
onMemberProfile={($targetRef, memberID) => {
const member = data.findTask.assigned.find(m => m.id === memberID);
const member = data.findTask.assigned.find((m) => m.id === memberID);
if (member) {
showPopup(
$targetRef,
@ -541,7 +556,8 @@ const Details: React.FC<DetailsProps> = ({
bio="None"
onRemoveFromTask={() => {
if (user) {
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
unassignTask({ variables: { taskID: data.findTask.id, userID: member.id ?? '' } });
hidePopup();
}
}}
/>
@ -581,7 +597,7 @@ const Details: React.FC<DetailsProps> = ({
}}
>
<CreateChecklistPopup
onCreateChecklist={checklistData => {
onCreateChecklist={(checklistData) => {
let position = 65535;
if (data.findTask.checklists) {
const [lastChecklist] = data.findTask.checklists.slice(-1);
@ -631,13 +647,80 @@ const Details: React.FC<DetailsProps> = ({
>
<DueDateManager
task={task}
onRemoveDueDate={t => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } });
// hidePopup();
onRemoveDueDate={(t) => {
updateTaskDueDate({
variables: {
taskID: t.id,
dueDate: null,
hasTime: false,
deleteNotifications: t.dueDate.notifications
? t.dueDate.notifications.map((n) => ({ id: n.id }))
: [],
updateNotifications: [],
createNotifications: [],
},
});
hidePopup();
}}
onDueDateChange={(t, newDueDate, hasTime) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } });
// hidePopup();
onDueDateChange={(t, newDueDate, hasTime, notifications) => {
const updatedNotifications = notifications.current
.filter((c) => c.externalId !== null)
.map((c) => {
let duration = DueDateNotificationDuration.Minute;
switch (c.duration.value) {
case 'hour':
duration = DueDateNotificationDuration.Hour;
break;
case 'day':
duration = DueDateNotificationDuration.Day;
break;
case 'week':
duration = DueDateNotificationDuration.Week;
break;
default:
break;
}
return {
id: c.externalId ?? '',
period: c.period,
duration,
};
});
const newNotifications = notifications.current
.filter((c) => c.externalId === null)
.map((c) => {
let duration = DueDateNotificationDuration.Minute;
switch (c.duration.value) {
case 'hour':
duration = DueDateNotificationDuration.Hour;
break;
case 'day':
duration = DueDateNotificationDuration.Day;
break;
case 'week':
duration = DueDateNotificationDuration.Week;
break;
default:
break;
}
return {
taskID: task.id,
period: c.period,
duration,
};
});
// const updatedNotifications = notifications.filter(c => c.externalId === null);
updateTaskDueDate({
variables: {
taskID: t.id,
dueDate: newDueDate,
hasTime,
createNotifications: newNotifications,
updateNotifications: updatedNotifications,
deleteNotifications: notifications.removed.map((n) => ({ id: n })),
},
});
hidePopup();
}}
onCancel={NOOP}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,21 +9,23 @@ import {
GetProjectsDocument,
GetProjectsQuery,
} from 'shared/generated/graphql';
import FormInput from 'shared/components/FormInput';
import { Link } from 'react-router-dom';
import NewProject from 'shared/components/NewProject';
import { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import { useCurrentUser } from 'App/context';
import Button from 'shared/components/Button';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useForm } from 'react-hook-form';
import Input from 'shared/components/Input';
import ControlledInput from 'shared/components/ControlledInput';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import NOOP from 'shared/utils/noop';
import theme from 'App/ThemeStyles';
import polling from 'shared/utils/polling';
import { mixin } from '../shared/utils/styles';
type CreateTeamData = { teamName: string };
type CreateTeamData = { name: string };
type CreateTeamFormProps = {
onCreateTeam: (teamName: string) => void;
@ -35,28 +37,30 @@ const CreateTeamButton = styled(Button)`
width: 100%;
`;
const ErrorText = styled.span`
font-size: 14px;
color: ${(props) => props.theme.colors.danger};
`;
const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
const { register, handleSubmit } = useForm<CreateTeamData>();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateTeamData>();
const createTeam = (data: CreateTeamData) => {
onCreateTeam(data.teamName);
onCreateTeam(data.name);
};
return (
<CreateTeamFormContainer onSubmit={handleSubmit(createTeam)}>
<Input
width="100%"
label="Team name"
id="teamName"
name="teamName"
variant="alternate"
ref={register({ required: 'Team name is required' })}
/>
{errors.name && <ErrorText>{errors.name.message}</ErrorText>}
<FormInput width="100%" label="Team name" variant="alternate" {...register('name')} />
<CreateTeamButton type="submit">Create</CreateTeamButton>
</CreateTeamFormContainer>
);
};
const ProjectAddTile = styled.div`
background-color: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
background-color: ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.4)};
background-size: cover;
background-position: 50%;
color: #fff;
@ -70,7 +74,7 @@ const ProjectAddTile = styled.div`
`;
const ProjectTile = styled(Link)<{ color: string }>`
background-color: ${props => props.color};
background-color: ${(props) => props.color};
background-size: cover;
background-position: 50%;
color: #fff;
@ -141,7 +145,7 @@ const ProjectTileName = styled.div<{ centered?: boolean }>`
max-height: 40px;
width: 100%;
word-wrap: break-word;
${props => props.centered && 'text-align: center;'}
${(props) => props.centered && 'text-align: center;'}
`;
const Wrapper = styled.div`
@ -179,7 +183,7 @@ const SectionActionLink = styled(Link)`
const ProjectSectionTitle = styled.h3`
font-size: 16px;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
`;
const ProjectsContainer = styled.div`
@ -203,14 +207,14 @@ type ShowNewProject = {
const Projects = () => {
const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetProjectsQuery({ pollInterval: 3000, fetchPolicy: 'cache-and-network' });
const { loading, data } = useGetProjectsQuery({ pollInterval: polling.PROJECTS, fetchPolicy: 'cache-and-network' });
useEffect(() => {
document.title = 'Taskcafé';
}, []);
const [createProject] = useCreateProjectMutation({
update: (client, newProject) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, (cache) =>
produce(cache, (draftCache) => {
if (newProject.data) {
draftCache.projects.push({ ...newProject.data.createProject });
}
@ -223,8 +227,8 @@ const Projects = () => {
const { user } = useCurrentUser();
const [createTeam] = useCreateTeamMutation({
update: (client, createData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
produce(cache, draftCache => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, (cache) =>
produce(cache, (draftCache) => {
if (createData.data) {
draftCache.teams.push({ ...createData.data?.createTeam });
}
@ -238,7 +242,7 @@ const Projects = () => {
const { projects, teams, organizations } = data;
const organizationID = organizations[0].id ?? null;
const personalProjects = projects
.filter(p => p.team === null)
.filter((p) => p.team === null)
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
@ -250,12 +254,12 @@ const Projects = () => {
const textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0; // eslint-disable-line no-nested-ternary
})
.map(team => {
.map((team) => {
return {
id: team.id,
name: team.name,
projects: projects
.filter(project => project.team && project.team.id === team.id)
.filter((project) => project.team && project.team.id === team.id)
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
@ -268,10 +272,10 @@ const Projects = () => {
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<Wrapper>
<ProjectsContainer>
{user.roles.org === 'admin' && (
{true && ( // TODO: add permision check
<AddTeamButton
variant="outline"
onClick={$target => {
onClick={($target) => {
showPopup(
$target,
<Popup
@ -282,7 +286,7 @@ const Projects = () => {
}}
>
<CreateTeamForm
onCreateTeam={teamName => {
onCreateTeam={(teamName) => {
if (organizationID) {
createTeam({ variables: { name: teamName, organizationID } });
hidePopup();
@ -303,7 +307,7 @@ const Projects = () => {
<ProjectList>
{personalProjects.map((project, idx) => (
<ProjectListItem key={project.id}>
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
<ProjectTile color={colors[idx % 5]} to={`/p/${project.shortId}`}>
<ProjectTileFade />
<ProjectTileDetails>
<ProjectTileName>{project.name}</ProjectTileName>
@ -325,12 +329,12 @@ const Projects = () => {
</ProjectListItem>
</ProjectList>
</div>
{projectTeams.map(team => {
{projectTeams.map((team) => {
return (
<div key={team.id}>
<ProjectSectionTitleWrapper>
<ProjectSectionTitle>{team.name}</ProjectSectionTitle>
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
{true && ( // TODO: add permision check
<SectionActions>
<SectionActionLink to={`/teams/${team.id}`}>
<SectionAction variant="outline">Projects</SectionAction>
@ -347,7 +351,7 @@ const Projects = () => {
<ProjectList>
{team.projects.map((project, idx) => (
<ProjectListItem key={project.id}>
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}>
<ProjectTile color={colors[idx % 5]} to={`/p/${project.shortId}`}>
<ProjectTileFade />
<ProjectTileDetails>
<ProjectTileName>{project.name}</ProjectTileName>
@ -355,7 +359,7 @@ const Projects = () => {
</ProjectTile>
</ProjectListItem>
))}
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
{true && ( // TODO: add permision check
<ProjectListItem>
<ProjectAddTile
onClick={() => {

View File

@ -17,6 +17,7 @@ const UsersRegister = () => {
<Register
registered={registered}
onSubmit={(data, setComplete, setError) => {
let isRedirected = false;
if (data.password !== data.password_confirm) {
setError('password', { type: 'error', message: 'Passwords must match' });
setError('password_confirm', { type: 'error', message: 'Passwords must match' });
@ -35,23 +36,26 @@ const UsersRegister = () => {
},
}),
})
.then(async x => {
.then(async (x) => {
const response = await x.json();
const { setup } = response;
console.log(response);
if (setup) {
history.replace(`/confirm?confirmToken=xxxx`);
isRedirected = true;
} else if (params.confirmToken) {
history.replace(`/confirm?confirmToken=${params.confirmToken}`);
isRedirected = true;
} else {
setRegistered(true);
}
})
.catch(() => {
.catch((e) => {
toast('There was an issue trying to register');
});
}
if (!isRedirected) {
setComplete(true);
}
}}
/>
</LoginWrapper>

View File

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

View File

@ -9,6 +9,7 @@ import {
import { Link } from 'react-router-dom';
import Input from 'shared/components/Input';
import theme from 'App/ThemeStyles';
import polling from 'shared/utils/polling';
const FilterSearch = styled(Input)`
margin: 0;
@ -158,7 +159,7 @@ const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
const { loading, data } = useGetTeamQuery({
variables: { teamID },
fetchPolicy: 'cache-and-network',
pollInterval: 3000,
pollInterval: polling.TEAM_PROJECTS,
});
if (data) {
return (

View File

@ -13,7 +13,7 @@ import { usePopup, Popup } from 'shared/components/PopupMenu';
import { History } from 'history';
import produce from 'immer';
import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { PermissionObjectType, PermissionLevel, useCurrentUser } from 'App/context';
import { useCurrentUser } from 'App/context';
import NOOP from 'shared/utils/noop';
import Members from './Members';
import Projects from './Projects';
@ -95,9 +95,12 @@ const Teams = () => {
const [currentTab, setCurrentTab] = useState(0);
const match = useRouteMatch();
if (data && user) {
/*
TODO: re-add permission check
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
return <Redirect to="/" />;
}
*/
return (
<>
<GlobalTopNavbar

View File

@ -1,24 +1,35 @@
import React from 'react';
import ReactDOM from 'react-dom';
import axios from 'axios';
import createAuthRefreshInterceptor from 'axios-auth-refresh';
import { ApolloProvider } from '@apollo/react-hooks';
import { ApolloClient } from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error';
import { ApolloClient } from '@apollo/client';
import { ApolloProvider } from '@apollo/client/react';
import { enableMapSet } from 'immer';
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import dayjs from 'dayjs';
import updateLocale from 'dayjs/plugin/updateLocale';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween';
import weekday from 'dayjs/plugin/weekday';
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import log from 'loglevel';
import remote from 'loglevel-plugin-remote';
import cache from './App/cache';
import App from './App';
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
if (process.env.REACT_APP_NODE_ENV === 'production') {
remote.apply(log, { format: remote.json });
switch (process.env.REACT_APP_LOG_LEVEL) {
case 'info':
log.setLevel(log.levels.INFO);
break;
case 'debug':
log.setLevel(log.levels.DEBUG);
break;
default:
log.setLevel(log.levels.ERROR);
}
}
enableMapSet();
@ -27,6 +38,8 @@ dayjs.extend(weekday);
dayjs.extend(isBetween);
dayjs.extend(customParseFormat);
dayjs.extend(updateLocale);
dayjs.extend(duration);
dayjs.extend(relativeTime);
dayjs.updateLocale('en', {
week: {
dow: 1, // First day of week is Monday
@ -34,131 +47,7 @@ dayjs.updateLocale('en', {
},
});
let forward$;
let isRefreshing = false;
let pendingRequests: any = [];
const refreshAuthLogic = (failedRequest: any) =>
axios.post('/auth/refresh_token', {}, { withCredentials: true }).then(tokenRefreshResponse => {
setAccessToken(tokenRefreshResponse.data.accessToken);
failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`;
return Promise.resolve();
});
createAuthRefreshInterceptor(axios, refreshAuthLogic);
const resolvePendingRequests = () => {
pendingRequests.map((callback: any) => callback());
pendingRequests = [];
};
const resolvePromise = (resolve: () => void) => {
pendingRequests.push(() => resolve());
};
const resetPendingRequests = () => {
pendingRequests = [];
};
const setRefreshing = (newVal: boolean) => {
isRefreshing = newVal;
};
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
if (err.extensions && err.extensions.code) {
switch (err.extensions.code) {
case 'UNAUTHENTICATED':
if (!isRefreshing) {
setRefreshing(true);
forward$ = fromPromise(
getNewToken()
.then((response: any) => {
setAccessToken(response.accessToken);
resolvePendingRequests();
return response.accessToken;
})
.catch(() => {
resetPendingRequests();
// TODO
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
return undefined;
})
.finally(() => {
setRefreshing(false);
}),
).filter(value => Boolean(value));
} else {
forward$ = fromPromise(new Promise(resolvePromise));
}
return forward$.flatMap(() => forward(operation));
default:
// pass
}
}
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`); // eslint-disable-line no-console
}
return undefined;
});
const requestLink = new ApolloLink(
(operation, forward) =>
new Observable((observer: any) => {
let handle: any;
Promise.resolve(operation)
.then((op: any) => {
const accessToken = getAccessToken();
if (accessToken) {
op.setContext({
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
}
})
.then(() => {
handle = forward(operation).subscribe({
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
})
.catch(observer.error.bind(observer));
return () => {
if (handle) {
handle.unsubscribe();
}
};
}),
);
const client = new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(
({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`), // eslint-disable-line no-console
);
}
if (networkError) {
console.log(`[Network error]: ${networkError}`); // eslint-disable-line no-console
}
}),
errorLink,
requestLink,
new HttpLink({
uri: '/graphql',
credentials: 'same-origin',
}),
]),
cache,
});
const client = new ApolloClient({ uri: '/graphql', cache });
ReactDOM.render(
<ApolloProvider client={client}>

View File

@ -1,5 +1,5 @@
import styled, { css } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea/lib';
import TextareaAutosize from 'react-autosize-textarea';
import { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,14 +21,7 @@ import {
SubTitle,
} from './Styles';
const Confirm = ({ onConfirmUser, hasConfirmToken }: ConfirmProps) => {
const [hasFailed, setFailed] = useState(false);
const setHasFailed = () => {
setFailed(true);
};
useEffect(() => {
onConfirmUser(setHasFailed);
});
const Confirm = ({ hasFailed, hasConfirmToken }: ConfirmProps) => {
return (
<Wrapper>
<Column>

View File

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

View File

@ -1,9 +1,8 @@
import styled from 'styled-components';
import styled, { css } 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';
import { Bell, Clock } from 'shared/icons';
export const Wrapper = styled.div`
display: flex
@ -22,27 +21,27 @@ display: flex
& .react-datepicker__close-icon::after {
background: none;
font-size: 16px;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
}
& .react-datepicker-time__header {
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
}
& .react-datepicker__time-list-item {
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
}
& .react-datepicker__time-container .react-datepicker__time
.react-datepicker__time-box ul.react-datepicker__time-list
li.react-datepicker__time-list-item:hover {
color: ${props => props.theme.colors.text.secondary};
background: ${props => props.theme.colors.bg.secondary};
color: ${(props) => props.theme.colors.text.secondary};
background: ${(props) => props.theme.colors.bg.secondary};
}
& .react-datepicker__time-container .react-datepicker__time {
background: ${props => props.theme.colors.bg.primary};
background: ${(props) => props.theme.colors.bg.primary};
}
& .react-datepicker--time-only {
background: ${props => props.theme.colors.bg.primary};
border: 1px solid ${props => props.theme.colors.border};
background: ${(props) => props.theme.colors.bg.primary};
border: 1px solid ${(props) => props.theme.colors.border};
}
& .react-datepicker * {
@ -82,12 +81,12 @@ display: flex
}
& .react-datepicker__day--selected {
border-radius: 50%;
background: ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.primary};
color: #fff;
}
& .react-datepicker__day--selected:hover {
border-radius: 50%;
background: ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.primary};
color: #fff;
}
& .react-datepicker__header {
@ -95,12 +94,12 @@ display: flex
border: none;
}
& .react-datepicker__header--time {
border-bottom: 1px solid ${props => props.theme.colors.border};
border-bottom: 1px solid ${(props) => props.theme.colors.border};
}
& .react-datepicker__input-container input {
border: 1px solid rgba(0, 0, 0, 0.2);
border-color: ${props => props.theme.colors.alternate};
border-color: ${(props) => props.theme.colors.alternate};
background: #262c49;
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
padding: 0.7rem;
@ -114,7 +113,7 @@ padding: 0.7rem;
&: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};
background: ${(props) => props.theme.colors.bg.primary};
}
`;
@ -142,9 +141,9 @@ export const AddDateRange = styled.div`
width: 100%;
font-size: 12px;
line-height: 16px;
color: ${props => mixin.rgba(props.theme.colors.primary, 0.8)};
color: ${(props) => mixin.rgba(props.theme.colors.primary, 0.8)};
&:hover {
color: ${props => mixin.rgba(props.theme.colors.primary, 1)};
color: ${(props) => mixin.rgba(props.theme.colors.primary, 1)};
text-decoration: underline;
}
`;
@ -201,18 +200,62 @@ export const ActionsWrapper = styled.div`
align-items: center;
& .react-datepicker-wrapper {
margin-left: auto;
width: 82px;
width: 86px;
}
& .react-datepicker__input-container input {
padding-bottom: 4px;
padding-top: 4px;
width: 100%;
}
& .react-period-select__indicators {
display: none;
}
& .react-period {
width: 100%;
max-width: 86px;
}
& .react-period-select__single-value {
color: #c2c6dc;
margin-left: 0;
margin-right: 0;
}
& .react-period-select__value-container {
padding-left: 0;
padding-right: 0;
}
& .react-period-select__control {
border: 1px solid rgba(0, 0, 0, 0.2);
min-height: 30px;
border-color: rgb(65, 69, 97);
background: #262c49;
box-shadow: 0 0 0 0 rgb(0 0 0 / 15%);
color: #c2c6dc;
padding-right: 12px;
padding-left: 12px;
padding-bottom: 4px;
padding-top: 4px;
width: 100%;
position: relative;
border-radius: 5px;
transition: all 0.3s ease;
font-size: 13px;
line-height: 20px;
padding: 0 12px;
}
`;
export const ActionClock = styled(Clock)`
align-self: center;
fill: ${props => props.theme.colors.primary};
fill: ${(props) => props.theme.colors.primary};
margin: 0 8px;
flex: 0 0 auto;
`;
export const ActionBell = styled(Bell)`
align-self: center;
fill: ${(props) => props.theme.colors.primary};
margin: 0 8px;
flex: 0 0 auto;
`;
@ -222,7 +265,7 @@ export const ActionLabel = styled.div`
line-height: 14px;
`;
export const ActionIcon = styled.div`
export const ActionIcon = styled.div<{ disabled?: boolean }>`
height: 36px;
min-height: 36px;
min-width: 36px;
@ -232,17 +275,25 @@ export const ActionIcon = styled.div`
cursor: pointer;
margin-right: 8px;
svg {
fill: ${props => props.theme.colors.text.primary};
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};
fill: ${(props) => props.theme.colors.text.secondary};
}
${(props) =>
props.disabled &&
css`
opacity: 0.8;
cursor: not-allowed;
`}
align-items: center;
display: inline-flex;
justify-content: center;
position: relative;
`;
export const ClearButton = styled.div`
@ -260,8 +311,38 @@ export const ClearButton = styled.div`
justify-content: center;
transition-duration: 0.2s;
transition-property: background, border, box-shadow, color, fill;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
&:hover {
color: ${props => props.theme.colors.text.secondary};
color: ${(props) => props.theme.colors.text.secondary};
}
`;
export const ControlWrapper = styled.div`
display: flex;
align-items: center;
margin-top: 8px;
`;
export const RightWrapper = styled.div`
flex: 1 1 50%;
display: flex;
align-items: center;
flex-direction: row-reverse;
`;
export const LeftWrapper = styled.div`
flex: 1 1 50%;
display: flex;
align-items: center;
`;
export const SaveButton = styled(Button)`
padding: 6px 12px;
justify-content: center;
margin-right: 4px;
`;
export const RemoveButton = styled.div`
width: 100%;
justify-content: center;
`;

View File

@ -3,14 +3,21 @@ import dayjs from 'dayjs';
import styled from 'styled-components';
import DatePicker from 'react-datepicker';
import _ from 'lodash';
import { colourStyles } from 'shared/components/Select';
import produce from 'immer';
import Select from 'react-select';
import 'react-datepicker/dist/react-datepicker.css';
import { getYear, getMonth } from 'date-fns';
import { useForm, Controller } from 'react-hook-form';
import NOOP from 'shared/utils/noop';
import { Bell, Clock, Cross, Plus, Trash } from 'shared/icons';
import {
Wrapper,
RemoveDueDate,
SaveButton,
RightWrapper,
LeftWrapper,
DueDateInput,
DueDatePickerWrapper,
ConfirmAddDueDate,
@ -22,13 +29,19 @@ import {
ActionsSeparator,
ActionClock,
ActionLabel,
ControlWrapper,
RemoveButton,
ActionBell,
} from './Styles';
import { Clock, Cross } from 'shared/icons';
import Select from 'react-select/src/Select';
type DueDateManagerProps = {
task: Task;
onDueDateChange: (task: Task, newDueDate: Date, hasTime: boolean) => void;
onDueDateChange: (
task: Task,
newDueDate: Date,
hasTime: boolean,
notifications: { current: Array<NotificationInternal>; removed: Array<string> },
) => void;
onRemoveDueDate: (task: Task) => void;
onCancel: () => void;
};
@ -41,6 +54,39 @@ const FormField = styled.div`
width: 50%;
display: inline-block;
`;
const NotificationCount = styled.input``;
const ActionPlus = styled(Plus)`
position: absolute;
fill: ${(props) => props.theme.colors.bg.primary} !important;
stroke: ${(props) => props.theme.colors.bg.primary};
`;
const ActionInput = styled.input`
border: 1px solid rgba(0, 0, 0, 0.2);
margin-left: auto;
margin-right: 4px;
border-color: rgb(65, 69, 97);
background: #262c49;
box-shadow: 0 0 0 0 rgb(0 0 0 / 15%);
color: #c2c6dc;
position: relative;
border-radius: 5px;
transition: all 0.3s ease;
font-size: 13px;
line-height: 20px;
padding: 0 12px;
padding-bottom: 4px;
padding-top: 4px;
width: 100%;
max-width: 48px;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
`;
const HeaderSelectLabel = styled.div`
display: inline-block;
position: relative;
@ -59,7 +105,7 @@ const HeaderSelectLabel = styled.div`
color: #c2c6dc;
&:hover {
background: ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.primary};
color: #c2c6dc;
}
`;
@ -78,12 +124,12 @@ const HeaderSelect = styled.select`
& option {
color: #c2c6dc;
background: ${props => props.theme.colors.bg.primary};
background: ${(props) => props.theme.colors.bg.primary};
}
& option:hover {
background: ${props => props.theme.colors.bg.secondary};
border: 1px solid ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.bg.secondary};
border: 1px solid ${(props) => props.theme.colors.primary};
outline: none !important;
box-shadow: none;
color: #c2c6dc;
@ -115,7 +161,7 @@ const HeaderButton = styled.button`
border: none;
border-radius: 3px;
&:hover {
background: ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.primary};
color: #fff;
}
`;
@ -131,35 +177,82 @@ const HeaderActions = styled.div`
}
`;
const notificationPeriodOptions = [
{ value: 'minute', label: 'Minutes' },
{ value: 'hour', label: 'Hours' },
{ value: 'day', label: 'Days' },
{ value: 'week', label: 'Weeks' },
];
type NotificationInternal = {
internalId: string;
externalId: string | null;
period: number;
duration: { value: string; label: string };
};
type NotificationEntryProps = {
notification: NotificationInternal;
onChange: (period: number, duration: { value: string; label: string }) => void;
onRemove: () => void;
};
const NotificationEntry: React.FC<NotificationEntryProps> = ({ notification, onChange, onRemove }) => {
return (
<>
<ActionBell width={16} height={16} />
<ActionLabel>Notification</ActionLabel>
<ActionInput
value={notification.period}
onChange={(e) => {
onChange(parseInt(e.currentTarget.value, 10), notification.duration);
}}
onKeyPress={(e) => {
const isNumber = /^[0-9]$/i.test(e.key);
if (!isNumber && e.key !== 'Backspace') {
e.preventDefault();
}
}}
dir="ltr"
autoComplete="off"
min="0"
type="number"
/>
<Select
menuPlacement="top"
className="react-period"
classNamePrefix="react-period-select"
styles={colourStyles}
isSearchable={false}
defaultValue={notification.duration}
options={notificationPeriodOptions}
onChange={(e) => {
if (e !== null) {
onChange(notification.period, e);
}
}}
/>
<ActionIcon onClick={() => onRemove()}>
<Cross width={16} height={16} />
</ActionIcon>
</>
);
};
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
const currentDueDate = task.dueDate ? dayjs(task.dueDate).toDate() : null;
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
const currentDueDate = task.dueDate.at ? dayjs(task.dueDate.at).toDate() : null;
const {
register,
handleSubmit,
setValue,
setError,
formState: { errors },
control,
} = useForm<DueDateFormData>();
const [startDate, setStartDate] = useState<Date | null>(currentDueDate);
const [endDate, setEndDate] = useState<Date | null>(currentDueDate);
const [hasTime, enableTime] = useState(task.hasTime ?? false);
const firstRun = useRef<boolean>(true);
const debouncedFunctionRef = useRef((newDate: Date | null, nowHasTime: boolean) => {
if (!firstRun.current) {
if (newDate) {
onDueDateChange(task, newDate, nowHasTime);
} else {
onRemoveDueDate(task);
enableTime(false);
}
} else {
firstRun.current = false;
}
});
const debouncedChange = useCallback(
_.debounce((newDate, nowHasTime) => debouncedFunctionRef.current(newDate, nowHasTime), 500),
[],
);
useEffect(() => {
debouncedChange(startDate, hasTime);
}, [startDate, hasTime]);
const years = _.range(2010, getYear(new Date()) + 10, 1);
const months = [
'January',
@ -176,45 +269,46 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
'December',
];
const onChange = (dates: any) => {
const [start, end] = dates;
setStartDate(start);
setEndDate(end);
};
const [isRange, setIsRange] = useState(false);
const CustomTimeInput = forwardRef(({ value, onClick, onChange, onBlur, onFocus }: any, $ref: any) => {
return (
<DueDateInput
id="endTime"
value={value}
name="endTime"
onChange={onChange}
width="100%"
variant="alternate"
label="Time"
onClick={onClick}
/>
const [notDuration, setNotDuration] = useState(10);
const [removedNotifications, setRemovedNotifications] = useState<Array<string>>([]);
const [notifications, setNotifications] = useState<Array<NotificationInternal>>(
task.dueDate.notifications
? task.dueDate.notifications.map((c, idx) => {
const duration =
notificationPeriodOptions.find((o) => o.value === c.duration.toLowerCase()) ?? notificationPeriodOptions[0];
return {
internalId: `n${idx}`,
externalId: c.id,
period: c.period,
duration,
};
})
: [],
);
});
return (
<Wrapper>
<DateRangeInputs>
<DatePicker
selected={startDate}
onChange={date => setStartDate(date)}
onChange={(date) => {
if (!Array.isArray(date) && date !== null) {
setStartDate(date);
}
}}
popperClassName="picker-hidden"
dateFormat="yyyy-MM-dd"
disabledKeyboardNavigation
isClearable
placeholderText="Select due date"
/>
{isRange ? (
<DatePicker
selected={startDate}
isClearable
onChange={date => setStartDate(date)}
onChange={(date) => {
if (!Array.isArray(date)) {
setStartDate(date);
}
}}
popperClassName="picker-hidden"
dateFormat="yyyy-MM-dd"
placeholderText="Select from date"
@ -225,7 +319,11 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
</DateRangeInputs>
<DatePicker
selected={startDate}
onChange={date => setStartDate(date)}
onChange={(date) => {
if (!Array.isArray(date)) {
setStartDate(date);
}
}}
startDate={startDate}
useWeekdaysShort
renderCustomHeader={({
@ -247,7 +345,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
value={months[getMonth(date)]}
onChange={({ target: { value } }) => changeMonth(months.indexOf(value))}
>
{months.map(option => (
{months.map((option) => (
<option key={option} value={option}>
{option}
</option>
@ -257,7 +355,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
<HeaderSelectLabel>
{date.getFullYear()}
<HeaderSelect value={getYear(date)} onChange={({ target: { value } }) => changeYear(parseInt(value, 10))}>
{years.map(option => (
{years.map((option) => (
<option key={option} value={option}>
{option}
</option>
@ -279,8 +377,10 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
<ActionLabel>Due Time</ActionLabel>
<DatePicker
selected={startDate}
onChange={date => {
onChange={(date) => {
if (!Array.isArray(date)) {
setStartDate(date);
}
}}
showTimeSelect
showTimeSelectOnly
@ -293,7 +393,75 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
</ActionIcon>
</ActionsWrapper>
)}
<ActionsWrapper>
{notifications.map((n, idx) => (
<ActionsWrapper key={n.internalId}>
<NotificationEntry
notification={n}
onChange={(period, duration) => {
setNotifications((prev) =>
produce(prev, (draft) => {
draft[idx].duration = duration;
draft[idx].period = period;
}),
);
}}
onRemove={() => {
setNotifications((prev) =>
produce(prev, (draft) => {
draft.splice(idx, 1);
if (n.externalId !== null) {
setRemovedNotifications((prev) => {
if (n.externalId !== null) {
return [...prev, n.externalId];
}
return prev;
});
}
}),
);
}}
/>
</ActionsWrapper>
))}
<ControlWrapper>
<LeftWrapper>
<SaveButton
onClick={() => {
if (startDate && notifications.findIndex((n) => Number.isNaN(n.period)) === -1) {
onDueDateChange(task, startDate, hasTime, { current: notifications, removed: removedNotifications });
}
}}
>
Save
</SaveButton>
{currentDueDate !== null && (
<ActionIcon
onClick={() => {
onRemoveDueDate(task);
}}
>
<Trash width={16} height={16} />
</ActionIcon>
)}
</LeftWrapper>
<RightWrapper>
<ActionIcon
disabled={notifications.length === 3}
onClick={() => {
setNotifications((prev) => [
...prev,
{
externalId: null,
internalId: `n${prev.length + 1}`,
duration: notificationPeriodOptions[0],
period: 10,
},
]);
}}
>
<Bell width={16} height={16} />
<ActionPlus width={8} height={8} />
</ActionIcon>
{!hasTime && (
<ActionIcon
onClick={() => {
@ -308,8 +476,8 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
<Clock width={16} height={16} />
</ActionIcon>
)}
<ClearButton onClick={() => setStartDate(null)}>{hasTime ? 'Clear all' : 'Clear'}</ClearButton>
</ActionsWrapper>
</RightWrapper>
</ControlWrapper>
</Wrapper>
);
};

View File

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

View File

@ -72,6 +72,9 @@ export const HeaderName = styled(TextareaAutosize)`
box-shadow: none;
font-weight: 600;
margin: -4px 0;
&:disabled {
opacity: 1;
}
letter-spacing: normal;
word-spacing: normal;

View File

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

View File

@ -4,6 +4,7 @@ import List, { ListCards } from 'shared/components/List';
import Card from 'shared/components/Card';
import CardComposer from 'shared/components/CardComposer';
import AddList from 'shared/components/AddList';
import log from 'loglevel';
import {
isPositionChanged,
getSortedDraggables,
@ -111,24 +112,16 @@ function shouldStatusFilter(task: Task, filter: TaskStatusFilter) {
const TODAY = REFERENCE.clone().startOf('day');
return completedAt.isSame(TODAY, 'd');
case TaskSince.YESTERDAY:
const YESTERDAY = REFERENCE.clone()
.subtract(1, 'day')
.startOf('day');
const YESTERDAY = REFERENCE.clone().subtract(1, 'day').startOf('day');
return completedAt.isSameOrAfter(YESTERDAY, 'd');
case TaskSince.ONE_WEEK:
const ONE_WEEK = REFERENCE.clone()
.subtract(7, 'day')
.startOf('day');
const ONE_WEEK = REFERENCE.clone().subtract(7, 'day').startOf('day');
return completedAt.isSameOrAfter(ONE_WEEK, 'd');
case TaskSince.TWO_WEEKS:
const TWO_WEEKS = REFERENCE.clone()
.subtract(14, 'day')
.startOf('day');
const TWO_WEEKS = REFERENCE.clone().subtract(14, 'day').startOf('day');
return completedAt.isSameOrAfter(TWO_WEEKS, 'd');
case TaskSince.THREE_WEEKS:
const THREE_WEEKS = REFERENCE.clone()
.subtract(21, 'day')
.startOf('day');
const THREE_WEEKS = REFERENCE.clone().subtract(21, 'day').startOf('day');
return completedAt.isSameOrAfter(THREE_WEEKS, 'd');
default:
return true;
@ -151,6 +144,7 @@ interface SimpleProps {
onCardMemberClick: OnCardMemberClick;
onCardLabelClick: () => void;
cardLabelVariant: CardLabelVariant;
isPublic?: boolean;
taskStatusFilter?: TaskStatusFilter;
taskMetaFilters?: TaskMetaFilters;
taskSorting?: TaskSorting;
@ -188,6 +182,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
onExtraMenuOpen,
onCardMemberClick,
taskStatusFilter = initTaskStatusFilter,
isPublic = false,
taskMetaFilters = initTaskMetaFilters,
taskSorting = initTaskSorting,
}) => {
@ -201,14 +196,14 @@ const SimpleLists: React.FC<SimpleProps> = ({
let beforeDropDraggables: Array<DraggableElement> | null = null;
if (isList) {
const droppedGroup = taskGroups.find(taskGroup => taskGroup.id === draggableId);
const droppedGroup = taskGroups.find((taskGroup) => taskGroup.id === draggableId);
if (droppedGroup) {
droppedDraggable = {
id: draggableId,
position: droppedGroup.position,
};
beforeDropDraggables = getSortedDraggables(
taskGroups.map(taskGroup => {
taskGroups.map((taskGroup) => {
return { id: taskGroup.id, position: taskGroup.position };
}),
);
@ -232,13 +227,13 @@ const SimpleLists: React.FC<SimpleProps> = ({
}
} else {
const curTaskGroup = taskGroups.findIndex(
taskGroup => taskGroup.tasks.findIndex(task => task.id === draggableId) !== -1,
(taskGroup) => taskGroup.tasks.findIndex((task) => task.id === draggableId) !== -1,
);
let targetTaskGroup = curTaskGroup;
if (!isSameList) {
targetTaskGroup = taskGroups.findIndex(taskGroup => taskGroup.id === destination.droppableId);
targetTaskGroup = taskGroups.findIndex((taskGroup) => taskGroup.id === destination.droppableId);
}
const droppedTask = taskGroups[curTaskGroup].tasks.find(task => task.id === draggableId);
const droppedTask = taskGroups[curTaskGroup].tasks.find((task) => task.id === draggableId);
if (droppedTask) {
droppedDraggable = {
@ -246,7 +241,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
position: droppedTask.position,
};
beforeDropDraggables = getSortedDraggables(
taskGroups[targetTaskGroup].tasks.map(task => {
taskGroups[targetTaskGroup].tasks.map((task) => {
return { id: task.id, position: task.position };
}),
);
@ -268,6 +263,9 @@ const SimpleLists: React.FC<SimpleProps> = ({
id: destination.droppableId,
},
};
log.debug(
`action=move taskId=${droppedTask.id} source=${source.droppableId} dest=${destination.droppableId} oldPos=${droppedTask.position} newPos=${newPosition}`,
);
onTaskDrop(newTask, droppedTask.taskGroup.id);
}
}
@ -284,7 +282,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
<BoardWrapper>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable direction="horizontal" type="column" droppableId="root">
{provided => (
{(provided) => (
<Container {...provided.droppableProps} ref={provided.innerRef}>
{taskGroups
.slice()
@ -292,14 +290,15 @@ const SimpleLists: React.FC<SimpleProps> = ({
.map((taskGroup: TaskGroup, index: number) => {
return (
<Draggable draggableId={taskGroup.id} key={taskGroup.id} index={index}>
{columnDragProvided => (
{(columnDragProvided) => (
<Droppable type="tasks" droppableId={taskGroup.id}>
{(columnDropProvided, snapshot) => (
<List
name={taskGroup.name}
onOpenComposer={id => setCurrentComposer(id)}
onOpenComposer={(id) => setCurrentComposer(id)}
isComposerOpen={currentComposer === taskGroup.id}
onSaveName={name => onChangeTaskGroupName(taskGroup.id, name)}
onSaveName={(name) => onChangeTaskGroupName(taskGroup.id, name)}
isPublic={isPublic}
ref={columnDragProvided.innerRef}
wrapperProps={columnDragProvided.draggableProps}
headerProps={columnDragProvided.dragHandleProps}
@ -311,8 +310,8 @@ const SimpleLists: React.FC<SimpleProps> = ({
<ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}>
{taskGroup.tasks
.slice()
.filter(t => shouldStatusFilter(t, taskStatusFilter))
.filter(t => shouldMetaFilter(t, taskMetaFilters))
.filter((t) => shouldStatusFilter(t, taskStatusFilter))
.filter((t) => shouldMetaFilter(t, taskMetaFilters))
.sort((a: any, b: any) => a.position - b.position)
.sort((a: any, b: any) => sortTasks(a, b, taskSorting))
.map((task: Task, taskIndex: any) => {
@ -323,12 +322,14 @@ const SimpleLists: React.FC<SimpleProps> = ({
index={taskIndex}
isDragDisabled={taskSorting.type !== TaskSortingType.NONE}
>
{taskProvided => {
{(taskProvided) => {
return (
<Card
toggleDirection={toggleDirection}
toggleLabels={toggleLabels}
isPublic={isPublic}
labelVariant={cardLabelVariant}
watched={task.watched}
wrapperProps={{
...taskProvided.draggableProps,
...taskProvided.dragHandleProps,
@ -348,12 +349,12 @@ const SimpleLists: React.FC<SimpleProps> = ({
complete={task.complete ?? false}
taskGroupID={taskGroup.id}
description=""
labels={task.labels.map(label => label.projectLabel)}
labels={task.labels.map((label) => label.projectLabel)}
dueDate={
task.dueDate
task.dueDate.at
? {
isPastDue: false,
formattedDate: dayjs(task.dueDate).format('MMM D, YYYY'),
formattedDate: dayjs(task.dueDate.at).format('MMM D, YYYY'),
}
: undefined
}
@ -363,6 +364,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
onTaskClick(task);
}}
checklists={task.badges && task.badges.checklist}
comments={task.badges && task.badges.comments}
onCardMemberClick={onCardMemberClick}
onContextMenu={onQuickEditorOpen}
/>
@ -377,7 +379,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
onClose={() => {
setCurrentComposer('');
}}
onCreateCard={name => {
onCreateCard={(name) => {
onCreateTask(taskGroup.id, name);
}}
isOpen
@ -396,11 +398,13 @@ const SimpleLists: React.FC<SimpleProps> = ({
)}
</Droppable>
</DragDropContext>
{!isPublic && (
<AddList
onSave={listName => {
onSave={(listName) => {
onCreateTaskGroup(listName);
}}
/>
)}
</BoardWrapper>
</BoardContainer>
);

View File

@ -24,7 +24,7 @@ export default function shouldMetaFilter(task: Task, filters: TaskMetaFilters) {
isFiltered = shouldFilter(!(task.dueDate && task.dueDate !== null));
}
if (task.dueDate) {
const taskDueDate = dayjs(task.dueDate);
const taskDueDate = dayjs(task.dueDate.at);
const today = dayjs();
let start;
let end;
@ -36,61 +36,31 @@ export default function shouldMetaFilter(task: Task, filters: TaskMetaFilters) {
isFiltered = shouldFilter(taskDueDate.isSame(today, 'day'));
break;
case DueDateFilterType.TOMORROW:
isFiltered = shouldFilter(
taskDueDate.isBefore(
today
.clone()
.add(1, 'day')
.endOf('day'),
),
);
isFiltered = shouldFilter(taskDueDate.isBefore(today.clone().add(1, 'day').endOf('day')));
break;
case DueDateFilterType.THIS_WEEK:
start = today
.clone()
.weekday(0)
.startOf('day');
end = today
.clone()
.weekday(6)
.endOf('day');
start = today.clone().weekday(0).startOf('day');
end = today.clone().weekday(6).endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
case DueDateFilterType.NEXT_WEEK:
start = today
.clone()
.weekday(0)
.add(7, 'day')
.startOf('day');
end = today
.clone()
.weekday(6)
.add(7, 'day')
.endOf('day');
start = today.clone().weekday(0).add(7, 'day').startOf('day');
end = today.clone().weekday(6).add(7, 'day').endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
case DueDateFilterType.ONE_WEEK:
start = today.clone().startOf('day');
end = today
.clone()
.add(7, 'day')
.endOf('day');
end = today.clone().add(7, 'day').endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
case DueDateFilterType.TWO_WEEKS:
start = today.clone().startOf('day');
end = today
.clone()
.add(14, 'day')
.endOf('day');
end = today.clone().add(14, 'day').endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
case DueDateFilterType.THREE_WEEKS:
start = today.clone().startOf('day');
end = today
.clone()
.add(21, 'day')
.endOf('day');
end = today.clone().add(21, 'day').endOf('day');
isFiltered = shouldFilter(taskDueDate.isBetween(start, end));
break;
default:
@ -104,7 +74,7 @@ export default function shouldMetaFilter(task: Task, filters: TaskMetaFilters) {
}
for (const member of filters.members) {
if (task.assigned) {
if (task.assigned.findIndex(m => m.id === member.id) !== -1) {
if (task.assigned.findIndex((m) => m.id === member.id) !== -1) {
isFiltered = ShouldFilter.VALID;
}
}
@ -116,7 +86,7 @@ export default function shouldMetaFilter(task: Task, filters: TaskMetaFilters) {
}
for (const label of filters.labels) {
if (task.labels) {
if (task.labels.findIndex(m => m.projectLabel.id === label.id) !== -1) {
if (task.labels.findIndex((m) => m.projectLabel.id === label.id) !== -1) {
isFiltered = ShouldFilter.VALID;
}
}

View File

@ -1,6 +1,7 @@
import styled from 'styled-components';
import Button from 'shared/components/Button';
import { mixin } from 'shared/utils/styles';
import AccessAccount from 'shared/undraw/AccessAccount';
export const Wrapper = styled.div`
background: #eff2f7;
@ -15,6 +16,12 @@ export const Column = styled.div`
display: flex;
justify-content: center;
align-items: center;
@media (max-width: 600px) {
svg {
display: none;
}
position: absolute;
}
`;
export const LoginFormWrapper = styled.div`
@ -25,18 +32,47 @@ export const LoginFormWrapper = styled.div`
export const LoginFormContainer = styled.div`
min-height: 505px;
padding: 2rem;
@media (max-width: 600px) {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
border: solid black 1px;
width: 600px;
height: 1100px;
box-shadow: 20px 20px 50px black;
}
@media (min-height: 641px) and (max-height: 653px) {
margin-top: 25%;
}
@media (min-height: 654px) and (max-height: 823px) and (max-width: 500px) {
margin-top: -20%;
}
@media (min-height: 480px) and (max-height: 639px) {
margin-top: 20%;
}
`;
export const Title = styled.h1`
color: #ebeefd;
font-size: 18px;
margin-bottom: 14px;
@media (max-width: 600px) {
font-size: 38px;
margin-top: 50px;
text-align: center;
}
`;
export const SubTitle = styled.h2`
color: #c2c6dc;
font-size: 14px;
margin-bottom: 14px;
@media (max-width: 600px) {
margin-top: 30px;
font-size: 24.5px;
margin-bottom: 90px;
text-align: center;
}
`;
export const Form = styled.form`
display: flex;
@ -48,6 +84,10 @@ export const FormLabel = styled.label`
font-size: 12px;
position: relative;
margin-top: 14px;
@media (max-width: 600px) {
font-size: 35px;
font-weight: bold;
}
`;
export const FormTextInput = styled.input`
@ -59,28 +99,92 @@ export const FormTextInput = styled.input`
font-size: 1rem;
color: #c2c6dc;
border-radius: 5px;
@media (max-width: 600px) {
border: 5px solid rgba(0, 0, 0, 0.2);
border-radius: 5%;
font-size: 30px;
background-color: #353D64;
color: black;
padding: 0.7rem 1rem 1rem 3rem;
text-align: center;
&::placeholder {
visibility: hidden;
}
&:not(:placeholder-shown) {
background-color: white;
}
}
`;
export const FormIcon = styled.div`
top: 30px;
left: 16px;
position: absolute;
@media (max-width: 600px) {
svg {
width: 40px;
height: 40px;
display: inline;
position: absolute;
top: 30px;
left: -5px;
}
}
`;
export const FormError = styled.span`
font-size: 0.875rem;
color: ${props => props.theme.colors.danger};
color: ${(props) => props.theme.colors.danger};
@media (max-width: 600px) {
font-size: 1.8rem;
}
`;
export const LoginButton = styled(Button)``;
export const LoginButton = styled(Button)`
@media (max-width: 600px) {
span {
font-size: 40px;
text-align: center;
width: 100%;
}
align-self: center;
position: absolute;
right: 0px;
margin-top: 40%;
width: 100%;
&:hover {
box-shadow: 5px 5px 20px white;
}
}
`;
export const ActionButtons = styled.div`
margin-top: 17.5px;
display: flex;
justify-content: space-between;
@media (max-width: 600px) {
width: 150px;
align-content: center;
font-size: 50px;
}
`;
export const RegisterButton = styled(Button)``;
export const RegisterButton = styled(Button)`
@media (max-width: 600px) {
span {
font-size: 40px;
text-align: center;
width: 100%;
}
width: 100%;
position: absolute;
left: 0px;
margin-top: 29%;
}
`;
export const LogoTitle = styled.div`
font-size: 24px;
@ -88,6 +192,9 @@ export const LogoTitle = styled.div`
margin-left: 12px;
transition: visibility, opacity, transform 0.25s ease;
color: #7367f0;
@media (max-width: 600px) {
font-size: 60px;
}
`;
export const LogoWrapper = styled.div`
@ -100,5 +207,16 @@ export const LogoWrapper = styled.div`
padding-bottom: 16px;
margin-bottom: 24px;
color: rgb(222, 235, 255);
border-bottom: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0.65)};
border-bottom: 1px solid ${(props) => mixin.rgba(props.theme.colors.alternate, 0.65)};
@media (max-width: 600px) {
svg {
display: inline;
width: 80px;
height: 80px;
}
}
`;

View File

@ -1,8 +1,8 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import AccessAccount from 'shared/undraw/AccessAccount';
import { User, Lock, Taskcafe } from 'shared/icons';
import { useForm } from 'react-hook-form';
import { useHistory } from 'react-router';
import LoadingSpinner from 'shared/components/LoadingSpinner';
import {
Form,
@ -25,11 +25,28 @@ import {
const Login = ({ onSubmit }: LoginProps) => {
const [isComplete, setComplete] = useState(true);
const { register, handleSubmit, errors, setError, formState } = useForm<LoginFormData>();
const [showRegistration, setShowRegistration] = useState(false);
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<LoginFormData>();
const loginSubmit = (data: LoginFormData) => {
setComplete(false);
onSubmit(data, setComplete, setError);
};
const history = useHistory();
useEffect(() => {
fetch('/settings').then(async (x) => {
const { isConfigured, allowPublicRegistration } = await x.json();
if (!isConfigured) {
history.push('/register');
} else if (allowPublicRegistration) {
setShowRegistration(true);
}
});
}, []);
return (
<Wrapper>
<Column>
@ -48,10 +65,9 @@ const Login = ({ onSubmit }: LoginProps) => {
<FormLabel htmlFor="username">
Username
<FormTextInput
placeholder="Username"
type="text"
id="username"
name="username"
ref={register({ required: 'Username is required' })}
{...register('username', { required: 'Username is required' })}
/>
<FormIcon>
<User width={20} height={20} />
@ -61,10 +77,9 @@ const Login = ({ onSubmit }: LoginProps) => {
<FormLabel htmlFor="password">
Password
<FormTextInput
placeholder="Password"
type="password"
id="password"
name="password"
ref={register({ required: 'Password is required' })}
{...register('password', { required: 'Password is required' })}
/>
<FormIcon>
<Lock width={20} height={20} />
@ -73,7 +88,7 @@ const Login = ({ onSubmit }: LoginProps) => {
{errors.password && <FormError>{errors.password.message}</FormError>}
<ActionButtons>
<RegisterButton variant="outline">Register</RegisterButton>
{showRegistration ? <RegisterButton variant="outline">Register</RegisterButton> : <div />}
{!isComplete && <LoadingSpinner size="32px" thickness="2px" borderSize="48px" />}
<LoginButton type="submit" disabled={!isComplete}>
Login

View File

@ -98,8 +98,8 @@ const ProjectName = styled.input`
font-weight: 400;
&:focus {
background: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.15)};
box-shadow: ${props => props.theme.colors.primary} 0px 0px 0px 1px;
background: ${(props) => mixin.darken(props.theme.colors.bg.secondary, 0.15)};
box-shadow: ${(props) => props.theme.colors.primary} 0px 0px 0px 1px;
}
`;
const ProjectNameLabel = styled.label`
@ -210,8 +210,8 @@ const CreateButton = styled.button`
&:hover {
color: #fff;
background: ${props => props.theme.colors.primary};
border-color: ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.primary};
border-color: ${(props) => props.theme.colors.primary};
}
`;
type NewProjectProps = {
@ -224,7 +224,7 @@ type NewProjectProps = {
const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose, onCreateProject }) => {
const [projectName, setProjectName] = useState('');
const [team, setTeam] = useState<null | string>(initialTeamID);
const options = [{ label: 'No team', value: 'no-team' }, ...teams.map(t => ({ label: t.name, value: t.id }))];
const options = [{ label: 'No team', value: 'no-team' }, ...teams.map((t) => ({ label: t.name, value: t.id }))];
return (
<Overlay>
<Content>
@ -234,7 +234,7 @@ const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose,
onClose();
}}
>
<ArrowLeft color="#c2c6dc" />
<ArrowLeft width={16} height={16} color="#c2c6dc" />
</HeaderLeft>
<HeaderRight
onClick={() => {
@ -263,7 +263,7 @@ const NewProject: React.FC<NewProjectProps> = ({ initialTeamID, teams, onClose,
onChange={(e: any) => {
setTeam(e.value);
}}
value={options.find(d => d.value === team)}
value={options.find((d) => d.value === team)}
styles={colourStyles}
classNamePrefix="teamSelect"
options={options}

View File

@ -1,8 +1,37 @@
import React from 'react';
import styled from 'styled-components';
import React, { useRef, useState } from 'react';
import styled, { css } from 'styled-components';
import TimeAgo from 'react-timeago';
import { Link } from 'react-router-dom';
import { mixin } from 'shared/utils/styles';
import {
useNotificationMarkAllReadMutation,
useNotificationsQuery,
NotificationFilter,
ActionType,
useNotificationAddedSubscription,
useNotificationToggleReadMutation,
} from 'shared/generated/graphql';
import dayjs from 'dayjs';
import { Popup } from 'shared/components/PopupMenu';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import { Bell, CheckCircleOutline, Circle, Ellipsis, UserCircle } from 'shared/icons';
import produce from 'immer';
import { useLocalStorage } from 'shared/hooks/useStateWithLocalStorage';
import localStorage from 'shared/utils/localStorage';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
function getFilterMessage(filter: NotificationFilter) {
switch (filter) {
case NotificationFilter.Unread:
return 'no unread';
case NotificationFilter.Assigned:
return 'no assigned';
case NotificationFilter.Mentioned:
return 'no mentioned';
default:
return 'no';
}
}
const ItemWrapper = styled.div`
cursor: pointer;
@ -37,7 +66,7 @@ const ItemTextContainer = styled.div`
const ItemTextTitle = styled.span`
font-weight: 500;
display: block;
color: ${props => props.theme.colors.primary};
color: ${(props) => props.theme.colors.primary};
font-size: 14px;
`;
const ItemTextDesc = styled.span`
@ -72,38 +101,578 @@ export const NotificationItem: React.FC<NotificationItemProps> = ({ title, descr
};
const NotificationHeader = styled.div`
padding: 0.75rem;
padding: 20px 28px;
text-align: center;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
background: ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.primary};
`;
const NotificationHeaderTitle = styled.span`
font-size: 14px;
color: ${props => props.theme.colors.text.secondary};
color: ${(props) => props.theme.colors.text.secondary};
`;
const EmptyMessage = styled.div`
display: flex;
justify-content: center;
align-items: center;
font-size: 14px;
height: 448px;
`;
const EmptyMessageLabel = styled.span`
margin-bottom: 80px;
`;
const Notifications = styled.div`
border-right: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-color: #414561;
height: 448px;
overflow-y: scroll;
user-select: none;
`;
const NotificationFooter = styled.div`
cursor: pointer;
padding: 0.5rem;
text-align: center;
color: ${props => props.theme.colors.primary};
color: ${(props) => props.theme.colors.primary};
&:hover {
background: ${props => props.theme.colors.bg.primary};
background: ${(props) => props.theme.colors.bg.primary};
}
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
border-right: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
border-color: #414561;
`;
const NotificationPopup: React.FC = ({ children }) => {
const NotificationTabs = styled.div`
align-items: flex-end;
align-self: stretch;
display: flex;
flex: 1 0 auto;
justify-content: flex-start;
max-width: 100%;
padding-top: 4px;
border-right: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
border-color: #414561;
`;
const NotificationTab = styled.div<{ active: boolean }>`
font-size: 80%;
color: ${(props) => props.theme.colors.text.primary};
font-size: 15px;
cursor: pointer;
display: flex;
user-select: none;
justify-content: center;
line-height: normal;
min-width: 1px;
transition-duration: 0.2s;
transition-property: box-shadow, color;
white-space: nowrap;
flex: 0 1 auto;
padding: 12px 16px;
&:first-child {
margin-left: 12px;
}
&:hover {
box-shadow: inset 0 -2px ${(props) => props.theme.colors.text.secondary};
color: ${(props) => props.theme.colors.text.secondary};
}
&:not(:last-child) {
margin-right: 12px;
}
${(props) =>
props.active &&
css`
box-shadow: inset 0 -2px ${props.theme.colors.secondary};
color: ${props.theme.colors.secondary};
&:hover {
box-shadow: inset 0 -2px ${props.theme.colors.secondary};
color: ${props.theme.colors.secondary};
}
`}
`;
const NotificationLink = styled(Link)`
display: flex;
text-decoration: none;
padding: 16px 8px;
width: 100%;
`;
const NotificationControls = styled.div`
width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
visibility: hidden;
padding: 4px;
`;
const NotificationButtons = styled.div`
display: flex;
align-self: flex-end;
align-items: center;
margin-top: auto;
margin-bottom: 6px;
`;
const NotificationButton = styled.div`
padding: 4px 15px;
cursor: pointer;
&:hover svg {
fill: rgb(216, 93, 216);
stroke: rgb(216, 93, 216);
}
`;
const NotificationWrapper = styled.li<{ read: boolean }>`
min-height: 80px;
display: flex;
font-size: 14px;
transition: background-color 0.1s ease-in-out;
margin: 2px 8px;
border-radius: 8px;
justify-content: space-between;
position: relative;
&:hover {
background: ${(props) => mixin.rgba(props.theme.colors.primary, 0.5)};
}
&:hover ${NotificationLink} {
color: #fff;
}
&:hover ${NotificationControls} {
visibility: visible;
}
${(props) =>
!props.read &&
css`
background: ${(props) => mixin.rgba(props.theme.colors.primary, 0.5)};
&:hover {
background: ${(props) => mixin.rgba(props.theme.colors.primary, 0.6)};
}
`}
`;
const NotificationContentFooter = styled.div`
margin-top: 10px;
display: flex;
align-items: center;
color: ${(props) => props.theme.colors.text.primary};
`;
const NotificationCausedBy = styled.div`
height: 48px;
width: 48px;
min-height: 48px;
min-width: 48px;
`;
const NotificationCausedByInitials = styled.div`
position: relative;
display: flex;
align-items: center;
text: #fff;
font-size: 18px;
justify-content: center;
border-radius: 50%;
flex-shrink: 0;
height: 100%;
width: 100%;
border: none;
background: #7367f0;
`;
const NotificationCausedByImage = styled.img`
position: relative;
display: flex;
border-radius: 50%;
flex-shrink: 0;
height: 100%;
width: 100%;
border: none;
background: #7367f0;
`;
const NotificationContent = styled.div`
display: flex;
overflow: hidden;
flex-direction: column;
margin-left: 16px;
`;
const NotificationContentHeader = styled.div`
font-weight: bold;
font-size: 14px;
color: #fff;
svg {
margin-left: 8px;
fill: rgb(216, 93, 216);
stroke: rgb(216, 93, 216);
}
`;
const NotificationBody = styled.div`
display: flex;
align-items: center;
color: #fff;
svg {
fill: rgb(216, 93, 216);
stroke: rgb(216, 93, 216);
}
`;
const NotificationPrefix = styled.span`
color: rgb(216, 93, 216);
margin: 0 4px;
`;
const NotificationSeparator = styled.span`
margin: 0 6px;
`;
type NotificationProps = {
causedBy?: { fullname: string; username: string; id: string } | null;
createdAt: string;
read: boolean;
data: Array<{ key: string; value: string }>;
actionType: ActionType;
onToggleRead: () => void;
};
const Notification: React.FC<NotificationProps> = ({ causedBy, createdAt, data, actionType, read, onToggleRead }) => {
const prefix: any = [];
const { hidePopup } = usePopup();
const dataMap = new Map<string, string>();
data.forEach((d) => dataMap.set(d.key, d.value));
let link = '#';
switch (actionType) {
case ActionType.TaskAssigned:
prefix.push(<UserCircle key="profile" width={14} height={16} />);
prefix.push(
<NotificationPrefix key="prefix">
<span style={{ fontWeight: 'bold' }}>{causedBy ? causedBy.fullname : 'Removed user'}</span>
</NotificationPrefix>,
);
prefix.push(<span key="content">assigned you to the task &quote;{dataMap.get('TaskName')}&quote;</span>);
link = `/p/${dataMap.get('ProjectID')}/board/c/${dataMap.get('TaskID')}`;
break;
case ActionType.DueDateReminder:
prefix.push(<Bell key="profile" width={14} height={16} />);
prefix.push(<NotificationPrefix key="prefix">{dataMap.get('TaskName')}</NotificationPrefix>);
const now = dayjs();
if (dayjs(dataMap.get('DueDate')).isBefore(dayjs())) {
prefix.push(
<span key="content">is due {dayjs.duration(now.diff(dayjs(dataMap.get('DueAt')))).humanize(true)}</span>,
);
} else {
prefix.push(
<span key="content">
has passed the due date {dayjs.duration(dayjs(dataMap.get('DueAt')).diff(now)).humanize(true)}
</span>,
);
}
link = `/p/${dataMap.get('ProjectID')}/board/c/${dataMap.get('TaskID')}`;
break;
default:
throw new Error('unknown action type');
}
return (
<NotificationWrapper read={read}>
<NotificationLink to={link} onClick={hidePopup}>
<NotificationCausedBy>
<NotificationCausedByInitials>
{causedBy
? causedBy.fullname
.split(' ')
.map((n) => n[0])
.join('.')
: 'RU'}
</NotificationCausedByInitials>
</NotificationCausedBy>
<NotificationContent>
<NotificationBody>{prefix}</NotificationBody>
<NotificationContentFooter>
<span>{dayjs.duration(dayjs(createdAt).diff(dayjs())).humanize(true)}</span>
<NotificationSeparator></NotificationSeparator>
<span>{dataMap.get('ProjectName')}</span>
</NotificationContentFooter>
</NotificationContent>
</NotificationLink>
<NotificationControls>
<NotificationButtons>
<NotificationButton onClick={() => onToggleRead()}>
{read ? <Circle width={18} height={18} /> : <CheckCircleOutline width={18} height={18} />}
</NotificationButton>
</NotificationButtons>
</NotificationControls>
</NotificationWrapper>
);
};
const PopupContent = styled.div`
display: flex;
flex-direction: column;
border-right: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding-bottom: 10px;
border-color: #414561;
`;
const tabs = [
{ label: 'All', key: NotificationFilter.All },
{ label: 'Unread', key: NotificationFilter.Unread },
{ label: 'I was mentioned', key: NotificationFilter.Mentioned },
{ label: 'Assigned to me', key: NotificationFilter.Assigned },
];
type NotificationEntry = {
id: string;
read: boolean;
readAt?: string | undefined | null;
notification: {
id: string;
data: Array<{ key: string; value: string }>;
actionType: ActionType;
causedBy?: { id: string; username: string; fullname: string } | undefined | null;
createdAt: string;
};
};
type NotificationPopupProps = {
onToggleRead: () => void;
};
const NotificationHeaderMenu = styled.div`
position: absolute;
right: 16px;
top: 16px;
`;
const NotificationHeaderMenuIcon = styled.div`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
svg {
fill: #fff;
stroke: #fff;
}
`;
const NotificationHeaderMenuContent = styled.div<{ show: boolean }>`
min-width: 130px;
position: absolute;
top: 16px;
background: #fff;
border-radius: 6px;
height: 50px;
visibility: ${(props) => (props.show ? 'visible' : 'hidden')};
border: 1px solid rgba(0, 0, 0, 0.1);
border-color: #414561;
background: #262c49;
padding: 6px;
display: flex;
flex-direction: column;
`;
const NotificationHeaderMenuButton = styled.div`
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 NotificationPopup: React.FC<NotificationPopupProps> = ({ onToggleRead }) => {
const [filter, setFilter] = useLocalStorage<NotificationFilter>(
localStorage.NOTIFICATIONS_FILTER,
NotificationFilter.Unread,
);
const [data, setData] = useState<{ nodes: Array<NotificationEntry>; hasNextPage: boolean; cursor: string }>({
nodes: [],
hasNextPage: false,
cursor: '',
});
const [toggleRead] = useNotificationToggleReadMutation({
onCompleted: (data) => {
setData((prev) => {
return produce(prev, (draft) => {
const idx = draft.nodes.findIndex((n) => n.id === data.notificationToggleRead.id);
if (idx !== -1) {
draft.nodes[idx].read = data.notificationToggleRead.read;
draft.nodes[idx].readAt = data.notificationToggleRead.readAt;
}
});
});
onToggleRead();
},
});
const { fetchMore } = useNotificationsQuery({
variables: { limit: 8, filter },
fetchPolicy: 'network-only',
onCompleted: (d) => {
setData((prev) => ({
hasNextPage: d.notified.pageInfo.hasNextPage,
cursor: d.notified.pageInfo.endCursor ?? '',
nodes: [...prev.nodes, ...d.notified.notified],
}));
},
});
useNotificationAddedSubscription({
onSubscriptionData: (d) => {
setData((n) => {
if (d.subscriptionData.data) {
return {
...n,
nodes: [d.subscriptionData.data.notificationAdded, ...n.nodes],
};
}
return n;
});
},
});
const [toggleAllRead] = useNotificationMarkAllReadMutation();
const [showHeaderMenu, setShowHeaderMenu] = useState(false);
const $menuContent = useRef<HTMLDivElement>(null);
useOnOutsideClick($menuContent, true, () => setShowHeaderMenu(false), null);
return (
<Popup title={null} tab={0} borders={false} padding={false}>
<PopupContent>
<NotificationHeader>
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
<NotificationHeaderMenu>
<NotificationHeaderMenuIcon onClick={() => setShowHeaderMenu(true)}>
<Ellipsis size={18} color="#fff" vertical={false} />
<NotificationHeaderMenuContent ref={$menuContent} show={showHeaderMenu}>
<NotificationHeaderMenuButton
onClick={(e) => {
e.stopPropagation();
setShowHeaderMenu(() => false);
toggleAllRead().then(() => {
setData((prev) =>
produce(prev, (draftData) => {
draftData.nodes = draftData.nodes.map((node) => ({ ...node, read: true }));
}),
);
onToggleRead();
});
}}
>
Mark all as read
</NotificationHeaderMenuButton>
</NotificationHeaderMenuContent>
</NotificationHeaderMenuIcon>
</NotificationHeaderMenu>
</NotificationHeader>
<ul>{children}</ul>
<NotificationFooter>View All</NotificationFooter>
<NotificationTabs>
{tabs.map((tab) => (
<NotificationTab
key={tab.key}
onClick={() => {
if (filter !== tab.key) {
setData({ cursor: '', hasNextPage: false, nodes: [] });
setFilter(tab.key);
}
}}
active={tab.key === filter}
>
{tab.label}
</NotificationTab>
))}
</NotificationTabs>
{data.nodes.length !== 0 ? (
<Notifications
onScroll={({ currentTarget }) => {
if (Math.ceil(currentTarget.scrollTop + currentTarget.clientHeight) >= currentTarget.scrollHeight) {
if (data.hasNextPage) {
console.log(`fetching more = ${data.cursor} - ${data.hasNextPage}`);
fetchMore({
variables: {
limit: 8,
filter,
cursor: data.cursor,
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
setData((d) => ({
cursor: fetchMoreResult.notified.pageInfo.endCursor ?? '',
hasNextPage: fetchMoreResult.notified.pageInfo.hasNextPage,
nodes: [...d.nodes, ...fetchMoreResult.notified.notified],
}));
return {
...prev,
notified: {
...prev.notified,
pageInfo: {
...fetchMoreResult.notified.pageInfo,
},
notified: [...prev.notified.notified, ...fetchMoreResult.notified.notified],
},
};
},
});
}
}
}}
>
{data.nodes.map((n) => (
<Notification
key={n.id}
read={n.read}
actionType={n.notification.actionType}
data={n.notification.data}
createdAt={n.notification.createdAt}
causedBy={n.notification.causedBy}
onToggleRead={() =>
toggleRead({
variables: { notifiedID: n.id },
optimisticResponse: {
__typename: 'Mutation',
notificationToggleRead: {
__typename: 'Notified',
id: n.id,
read: !n.read,
readAt: new Date().toUTCString(),
},
},
}).then(() => {
onToggleRead();
})
}
/>
))}
</Notifications>
) : (
<EmptyMessage>
<EmptyMessageLabel>You have {getFilterMessage(filter)} notifications</EmptyMessageLabel>
</EmptyMessage>
)}
</PopupContent>
</Popup>
);
};

View File

@ -218,7 +218,7 @@ export const PopupProvider: React.FC = ({ children }) => {
const setTab = (newTab: number, options?: PopupOptions) => {
setState((prevState: PopupState) =>
produce(prevState, draftState => {
produce(prevState, (draftState) => {
draftState.previousTab = currentState.currentTab;
draftState.currentTab = newTab;
if (options) {
@ -296,7 +296,7 @@ const PopupMenu: React.FC<Props> = ({ width, title, top, left, onClose, noHeader
<Wrapper padding borders>
{onPrevious && (
<PreviousButton onClick={onPrevious}>
<AngleLeft color="#c2c6dc" />
<AngleLeft size={16} color="#c2c6dc" />
</PreviousButton>
)}
{noHeader ? (
@ -332,7 +332,7 @@ export const Popup: React.FC<PopupProps> = ({ borders = true, padding = true, ti
setTab(0);
}}
>
<AngleLeft color="#c2c6dc" />
<AngleLeft size={16} color="#c2c6dc" />
</PreviousButton>
)}
{title && (

View File

@ -2,15 +2,15 @@ import React, { useRef } from 'react';
import styled from 'styled-components';
export const Container = styled.div<{ size: number | string; bgColor: string | null; backgroundURL: string | null }>`
width: ${props => props.size}px;
height: ${props => props.size}px;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)};
background: ${(props) => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)};
background-position: center;
background-size: contain;
`;
@ -22,6 +22,10 @@ type ProfileIconProps = {
};
const ProfileIcon: React.FC<ProfileIconProps> = ({ user, onProfileClick, size }) => {
let realSize = size;
if (size === null) {
realSize = 28;
}
const $profileRef = useRef<HTMLDivElement>(null);
return (
<Container
@ -29,7 +33,7 @@ const ProfileIcon: React.FC<ProfileIconProps> = ({ user, onProfileClick, size })
onClick={() => {
onProfileClick($profileRef, user);
}}
size={size}
size={realSize}
backgroundURL={user.profileIcon.url ?? null}
bgColor={user.profileIcon.bgColor ?? null}
>
@ -38,8 +42,4 @@ const ProfileIcon: React.FC<ProfileIconProps> = ({ user, onProfileClick, size })
);
};
ProfileIcon.defaultProps = {
size: 28,
};
export default ProfileIcon;

View File

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

View File

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

View File

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

View File

@ -9,14 +9,22 @@ type ActivityMessageProps = {
};
function getVariable(data: Array<TaskActivityData>, name: string) {
const target = data.find(d => d.name === name);
const target = data.find((d) => d.name === name);
return target ? target.value : null;
}
function renderDate(timestamp: string | null) {
function getVariableBool(data: Array<TaskActivityData>, name: string, defaultValue = false) {
const target = data.find((d) => d.name === name);
return target ? target.value === 'true' : defaultValue;
}
function renderDate(timestamp: string | null, hasTime: boolean) {
if (timestamp) {
if (hasTime) {
return dayjs(timestamp).format('MMM D [at] h:mm A');
}
return dayjs(timestamp).format('MMM D');
}
return null;
}
@ -30,13 +38,19 @@ const ActivityMessage: React.FC<ActivityMessageProps> = ({ type, data }) => {
message = `moved this task from ${getVariable(data, 'PrevTaskGroup')} to ${getVariable(data, 'CurTaskGroup')}`;
break;
case ActivityType.TaskDueDateAdded:
message = `set this task to be due ${renderDate(getVariable(data, 'DueDate'))}`;
message = `set this task to be due ${renderDate(
getVariable(data, 'DueDate'),
getVariableBool(data, 'HasTime', true),
)}`;
break;
case ActivityType.TaskDueDateRemoved:
message = `removed the due date from this task`;
break;
case ActivityType.TaskDueDateChanged:
message = `changed the due date of this task to ${renderDate(getVariable(data, 'CurDueDate'))}`;
message = `changed the due date of this task to ${renderDate(
getVariable(data, 'CurDueDate'),
getVariableBool(data, 'HasTime', true),
)}`;
break;
case ActivityType.TaskMarkedComplete:
message = `marked this task complete`;

View File

@ -69,7 +69,7 @@ const CommentCreator: React.FC<CommentCreatorProps> = ({
)}
<CommentEditorContainer>
<CommentTextArea
showCommentActions={showCommentActions}
$showCommentActions={showCommentActions}
placeholder="Write a comment..."
ref={$comment}
disabled={disabled}

View File

@ -13,6 +13,7 @@ import {
Smile,
} from 'shared/icons';
import { toArray } from 'react-emoji-render';
import { useCurrentUser } from 'App/context';
import DOMPurify from 'dompurify';
import TaskAssignee from 'shared/components/TaskAssignee';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
@ -81,29 +82,29 @@ import {
ActivityItemComment,
} from './Styles';
type TaskDetailsProps = {};
const TaskDetailsLoading: React.FC<TaskDetailsProps> = () => {
const TaskDetailsLoading: React.FC = () => {
const { user } = useCurrentUser();
return (
<Container>
<LeftSidebar>
<LeftSidebarContent>
<LeftSidebarSection>
<SidebarTitle>TASK GROUP</SidebarTitle>
<SidebarButton loading>
<SidebarButton $loading>
<SidebarSkeleton />
</SidebarButton>
<DueDateTitle>DUE DATE</DueDateTitle>
<SidebarButton loading>
<SidebarButton $loading>
<SidebarSkeleton />
</SidebarButton>
</LeftSidebarSection>
<AssignedUsersSection>
<DueDateTitle>MEMBERS</DueDateTitle>
<SidebarButton loading>
<SidebarButton $loading>
<SidebarSkeleton />
</SidebarButton>
</AssignedUsersSection>
{user && (
<ExtraActionsSection>
<DueDateTitle>ACTIONS</DueDateTitle>
<ActionButton disabled icon={<Tags width={12} height={12} />}>
@ -114,6 +115,7 @@ const TaskDetailsLoading: React.FC<TaskDetailsProps> = () => {
</ActionButton>
<ActionButton disabled>Cover</ActionButton>
</ExtraActionsSection>
)}
</LeftSidebarContent>
</LeftSidebar>
<ContentContainer>
@ -125,6 +127,7 @@ const TaskDetailsLoading: React.FC<TaskDetailsProps> = () => {
<span>Mark complete</span>
</MarkCompleteButton>
</HeaderLeft>
{user && (
<HeaderRight>
<HeaderActionIcon>
<Paperclip width={16} height={16} />
@ -139,9 +142,10 @@ const TaskDetailsLoading: React.FC<TaskDetailsProps> = () => {
<Trash width={16} height={16} />
</HeaderActionIcon>
</HeaderRight>
)}
</HeaderInnerContainer>
<TaskDetailsTitleWrapper loading>
<TaskDetailsTitle value="" disabled loading />
<TaskDetailsTitleWrapper $loading>
<TaskDetailsTitle value="" disabled $loading />
</TaskDetailsTitleWrapper>
</HeaderContainer>
<InnerContentContainer>
@ -151,9 +155,11 @@ const TaskDetailsLoading: React.FC<TaskDetailsProps> = () => {
</TabBarSection>
<ActivitySection />
</InnerContentContainer>
{user && (
<CommentContainer>
<CommentCreator disabled onCreateComment={() => null} onMemberProfile={() => null} />
</CommentContainer>
)}
</ContentContainer>
</Container>
);

View File

@ -4,6 +4,7 @@ import { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button';
import TaskAssignee from 'shared/components/TaskAssignee';
import theme from 'App/ThemeStyles';
import { Checkmark } from 'shared/icons';
export const Container = styled.div`
display: flex;
@ -22,14 +23,14 @@ export const MarkCompleteButton = styled.button<{ invert: boolean; disabled?: bo
position: relative;
border: none;
cursor: pointer;
border-radius: ${props => props.theme.borderRadius.alternate};
border-radius: ${(props) => props.theme.borderRadius.alternate};
display: flex;
align-items: center;
background: transparent;
& span {
margin-left: 4px;
}
${props =>
${(props) =>
props.invert
? css`
background: ${props.theme.colors.success};
@ -63,7 +64,7 @@ export const MarkCompleteButton = styled.button<{ invert: boolean; disabled?: bo
color: ${props.theme.colors.success};
}
`}
${props =>
${(props) =>
props.invert &&
css`
opacity: 0.6;
@ -89,7 +90,7 @@ export const SidebarTitle = styled.div`
font-size: 12px;
min-height: 24px;
margin-left: 8px;
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.75)};
color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.75)};
padding-top: 4px;
letter-spacing: 0.5px;
text-transform: uppercase;
@ -108,15 +109,15 @@ export const skeletonKeyframes = keyframes`
}
`;
export const SidebarButton = styled.div<{ loading?: boolean }>`
export const SidebarButton = styled.div<{ $loading?: boolean }>`
font-size: 14px;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
min-height: 32px;
width: 100%;
border-radius: 6px;
${props =>
props.loading
${(props) =>
props.$loading
? css`
background: ${props.theme.colors.bg.primary};
`
@ -178,15 +179,15 @@ export const HeaderLeft = styled.div`
justify-content: flex-start;
`;
export const TaskDetailsTitleWrapper = styled.div<{ loading?: boolean }>`
export const TaskDetailsTitleWrapper = styled.div<{ $loading?: boolean }>`
width: 100%;
margin: 8px 0 4px 0;
display: flex;
border-radius: 6px;
${props => props.loading && `background: ${props.theme.colors.bg.primary};`}
${(props) => props.$loading && `background: ${props.theme.colors.bg.primary};`}
`;
export const TaskDetailsTitle = styled(TextareaAutosize)<{ loading?: boolean }>`
export const TaskDetailsTitle = styled(TextareaAutosize)<{ $loading?: boolean }>`
padding: 9px 8px 7px 8px;
border-color: transparent;
border-radius: 6px;
@ -198,8 +199,11 @@ export const TaskDetailsTitle = styled(TextareaAutosize)<{ loading?: boolean }>`
font-weight: 700;
background: none;
${props =>
props.loading
&:disabled {
opacity: 1;
}
${(props) =>
props.$loading
? css`
background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor});
background-size: 200px 100%;
@ -207,7 +211,7 @@ export const TaskDetailsTitle = styled(TextareaAutosize)<{ loading?: boolean }>`
animation: ${skeletonKeyframes} 1.2s ease-in-out infinite;
`
: css`
&:hover {
&:not(:disabled):hover {
border-color: #414561;
border-width: 1px;
border-style: solid;
@ -223,7 +227,7 @@ export const DueDateTitle = styled.div`
font-size: 12px;
min-height: 24px;
margin-left: 8px;
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.75)};
color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.75)};
padding-top: 8px;
letter-spacing: 0.5px;
text-transform: uppercase;
@ -234,7 +238,7 @@ export const AssignedUsersSection = styled.div`
padding-right: 32px;
padding-top: 24px;
padding-bottom: 24px;
border-bottom: 1px solid ${props => props.theme.colors.alternate};
border-bottom: 1px solid ${(props) => props.theme.colors.alternate};
display: flex;
flex-direction: column;
`;
@ -252,10 +256,10 @@ export const AssignUserIcon = styled.div`
justify-content: center;
align-items: center;
&:hover {
border: 1px solid ${props => mixin.rgba(props.theme.colors.text.secondary, 0.75)};
border: 1px solid ${(props) => mixin.rgba(props.theme.colors.text.secondary, 0.75)};
}
&:hover svg {
fill: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.75)};
fill: ${(props) => mixin.rgba(props.theme.colors.text.secondary, 0.75)};
}
`;
@ -270,17 +274,17 @@ export const AssignUsersButton = styled.div`
align-items: center;
border: 1px solid transparent;
&:hover {
border: 1px solid ${props => mixin.darken(props.theme.colors.alternate, 0.15)};
border: 1px solid ${(props) => mixin.darken(props.theme.colors.alternate, 0.15)};
}
&:hover ${AssignUserIcon} {
border: 1px solid ${props => props.theme.colors.alternate};
border: 1px solid ${(props) => props.theme.colors.alternate};
}
`;
export const AssignUserLabel = styled.span`
flex: 1 1 auto;
line-height: 15px;
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.75)};
color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.75)};
`;
export const ExtraActionsSection = styled.div`
@ -292,7 +296,7 @@ export const ExtraActionsSection = styled.div`
`;
export const ActionButtonsTitle = styled.h3`
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
@ -302,16 +306,17 @@ export const ActionButton = styled(Button)`
margin-top: 8px;
margin-left: -10px;
padding: 8px 16px;
background: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.5)};
background: ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.5)};
text-align: left;
transition: transform 0.2s ease;
& span {
position: unset;
justify-content: flex-start;
}
&:hover {
box-shadow: none;
transform: translateX(4px);
background: ${props => mixin.rgba(props.theme.colors.bg.primary, 0.75)};
background: ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.75)};
}
`;
@ -330,10 +335,10 @@ export const HeaderActionIcon = styled.div`
cursor: pointer;
svg {
fill: ${props => mixin.rgba(props.theme.colors.text.primary, 0.75)};
fill: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.75)};
}
&:hover svg {
fill: ${props => mixin.rgba(props.theme.colors.primary, 0.75)});
fill: ${(props) => mixin.rgba(props.theme.colors.primary, 0.75)});
}
`;
@ -390,7 +395,7 @@ export const MetaDetail = styled.div`
`;
export const MetaDetailTitle = styled.h3`
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
@ -409,7 +414,7 @@ export const MetaDetailContent = styled.div`
`;
export const TaskDetailsAddLabel = styled.div`
border-radius: 3px;
background: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.15)};
background: ${(props) => mixin.darken(props.theme.colors.bg.secondary, 0.15)};
cursor: pointer;
&:hover {
opacity: 0.8;
@ -424,7 +429,7 @@ export const TaskDetailsAddLabelIcon = styled.div`
align-items: center;
justify-content: center;
border-radius: 3px;
background: ${props => mixin.darken(props.theme.colors.bg.secondary, 0.15)};
background: ${(props) => mixin.darken(props.theme.colors.bg.secondary, 0.15)};
cursor: pointer;
&:hover {
opacity: 0.8;
@ -440,7 +445,7 @@ export const TaskDetailLabel = styled.div<{ color: string }>`
&:hover {
opacity: 0.8;
}
background-color: ${props => props.color};
background-color: ${(props) => props.color};
color: #fff;
cursor: pointer;
display: flex;
@ -493,17 +498,22 @@ export const TabBarSection = styled.div`
margin-top: 2px;
padding-left: 23px;
display: flex;
justify-content: space-between;
text-transform: uppercase;
min-height: 35px;
border-bottom: 1px solid #414561;
`;
export const TabBarItem = styled.div`
box-shadow: inset 0 -2px ${props => props.theme.colors.primary};
box-shadow: inset 0 -2px ${(props) => props.theme.colors.primary};
padding: 12px 7px 14px 7px;
margin-bottom: -1px;
margin-right: 36px;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
`;
export const TabBarButton = styled(Button)`
padding: 6px 12px;
`;
export const CommentContainer = styled.div`
@ -534,19 +544,19 @@ export const CommentProfile = styled(TaskAssignee)`
align-items: normal;
`;
export const CommentTextArea = styled(TextareaAutosize)<{ showCommentActions: boolean }>`
export const CommentTextArea = styled(TextareaAutosize)<{ $showCommentActions: boolean }>`
width: 100%;
line-height: 28px;
padding: 4px 6px;
border-radius: 6px;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
background: #1f243e;
border: none;
transition: max-height 200ms, height 200ms, min-height 200ms;
min-height: 36px;
max-height: 36px;
${props =>
props.showCommentActions
${(props) =>
props.$showCommentActions
? css`
min-height: 80px;
max-height: none;
@ -558,7 +568,7 @@ export const CommentTextArea = styled(TextareaAutosize)<{ showCommentActions: bo
`;
export const CommentEditorActions = styled.div<{ visible: boolean }>`
display: ${props => (props.visible ? 'flex' : 'none')};
display: ${(props) => (props.visible ? 'flex' : 'none')};
align-items: center;
padding: 5px 5px 5px 9px;
border-top: 1px solid #414561;
@ -591,7 +601,7 @@ export const ActivityItemCommentAction = styled.div`
justify-content: center;
cursor: pointer;
svg {
fill: ${props => props.theme.colors.text.primary} !important;
fill: ${(props) => props.theme.colors.text.primary} !important;
}
`;
@ -611,7 +621,7 @@ export const ActivityItemHeader = styled.div<{ editable?: boolean }>`
display: flex;
flex-direction: column;
padding-left: 8px;
${props => props.editable && 'width: 100%;'}
${(props) => props.editable && 'width: 100%;'}
`;
export const ActivityItemHeaderUser = styled(TaskAssignee)`
align-items: start;
@ -620,7 +630,7 @@ export const ActivityItemHeaderUser = styled(TaskAssignee)`
export const ActivityItemHeaderTitle = styled.div`
display: flex;
align-items: center;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
padding-bottom: 2px;
`;
@ -631,8 +641,8 @@ export const ActivityItemHeaderTitleName = styled.span`
export const ActivityItemTimestamp = styled.span<{ margin: number }>`
font-size: 12px;
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.65)};
margin-left: ${props => props.margin}px;
color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.65)};
margin-left: ${(props) => props.margin}px;
`;
export const ActivityItemDetails = styled.div`
@ -646,11 +656,11 @@ export const ActivityItemComment = styled.div<{ editable: boolean }>`
border-radius: 3px;
${mixin.boxShadowCard}
position: relative;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
padding: 8px 12px;
margin: 4px 0;
background-color: ${props => mixin.darken(props.theme.colors.alternate, 0.1)};
${props => props.editable && 'width: 100%;'}
background-color: ${(props) => mixin.darken(props.theme.colors.alternate, 0.1)};
${(props) => props.editable && 'width: 100%;'}
& span {
display: inline-flex;
@ -680,7 +690,7 @@ export const ActivityItemCommentActions = styled.div`
export const ActivityItemLog = styled.span`
margin-left: 2px;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
`;
export const ViewRawButton = styled.button`
@ -691,9 +701,9 @@ export const ViewRawButton = styled.button`
right: 4px;
bottom: -24px;
cursor: pointer;
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.25)};
color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.25)};
&:hover {
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
}
`;
@ -709,3 +719,8 @@ export const TaskDetailsEditor = styled(TextareaAutosize)`
outline: none;
border: none;
`;
export const WatchedCheckmark = styled(Checkmark)`
position: absolute;
right: 16px;
`;

View File

@ -1,4 +1,5 @@
import React, { useState, useRef } from 'react';
import { useCurrentUser } from 'App/context';
import {
Plus,
User,
@ -11,6 +12,7 @@ import {
CheckSquareOutline,
At,
Smile,
Eye,
} from 'shared/icons';
import { toArray } from 'react-emoji-render';
import DOMPurify from 'dompurify';
@ -78,10 +80,12 @@ import {
ActivityItemHeaderTitle,
ActivityItemHeaderTitleName,
ActivityItemComment,
TabBarButton,
WatchedCheckmark,
} from './Styles';
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
import onDragEnd from './onDragEnd';
import { plugin as em } from './remark';
import plugin from './remark';
import ActivityMessage from './ActivityMessage';
const parseEmojis = (value: string) => {
@ -135,7 +139,7 @@ const StreamComment: React.FC<StreamCommentProps> = ({
onCreateComment={onUpdateComment}
/>
) : (
<ReactMarkdown escapeHtml={false} plugins={[em]}>
<ReactMarkdown skipHtml plugins={[plugin]}>
{DOMPurify.sanitize(comment.message, { FORBID_TAGS: ['style', 'img'] })}
</ReactMarkdown>
)}
@ -235,6 +239,7 @@ type TaskDetailsProps = {
onToggleChecklistItem: (itemID: string, complete: boolean) => void;
onOpenAddMemberPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onToggleTaskWatch: (task: Task, watched: boolean) => void;
onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onCreateComment: (task: Task, message: string) => void;
@ -256,6 +261,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
task,
editableComment = null,
onDeleteChecklist,
onToggleTaskWatch,
onTaskNameChange,
onCommentShowActions,
onOpenAddChecklistPopup,
@ -277,6 +283,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
onToggleChecklistItem,
onMemberProfile,
}) => {
const { user } = useCurrentUser();
const [taskName, setTaskName] = useState(task.name);
const [editTaskDescription, setEditTaskDescription] = useState(() => {
if (task.description) {
@ -298,7 +305,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
const activityStream: Array<{ id: string; data: { time: string; type: 'comment' | 'activity' } }> = [];
if (task.activity) {
task.activity.forEach(activity => {
task.activity.forEach((activity) => {
activityStream.push({
id: activity.id,
data: {
@ -310,7 +317,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
}
if (task.comments) {
task.comments.forEach(comment => {
task.comments.forEach((comment) => {
activityStream.push({
id: comment.id,
data: {
@ -338,12 +345,14 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<SidebarButton
ref={$dueDateBtn}
onClick={() => {
if (user) {
onOpenDueDatePopop(task, $dueDateBtn);
}
}}
>
{task.dueDate ? (
{task.dueDate.at ? (
<SidebarButtonText>
{dayjs(task.dueDate).format(task.hasTime ? 'MMM D [at] h:mm A' : 'MMMM D')}
{dayjs(task.dueDate.at).format(task.hasTime ? 'MMM D [at] h:mm A' : 'MMMM D')}
</SidebarButtonText>
) : (
<SidebarButtonText>No due date</SidebarButtonText>
@ -354,20 +363,24 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<DueDateTitle>MEMBERS</DueDateTitle>
{task.assigned && task.assigned.length !== 0 ? (
<MemberList>
{task.assigned.map(m => (
{task.assigned.map((m) => (
<TaskMember
key={m.id}
member={m}
size={32}
onMemberProfile={$target => {
onMemberProfile={($target) => {
if (user) {
onMemberProfile($target, m.id);
}
}}
/>
))}
<AssignUserIcon
ref={$addMemberBtn}
onClick={() => {
if (user) {
onOpenAddMemberPopup(task, $addMemberBtn);
}
}}
>
<Plus width={16} height={16} />
@ -377,7 +390,9 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<AssignUsersButton
ref={$noMemberBtn}
onClick={() => {
if (user) {
onOpenAddMemberPopup(task, $noMemberBtn);
}
}}
>
<AssignUserIcon>
@ -387,10 +402,11 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
</AssignUsersButton>
)}
</AssignedUsersSection>
{user && (
<ExtraActionsSection>
<DueDateTitle>ACTIONS</DueDateTitle>
<ActionButton
onClick={$target => {
onClick={($target) => {
onOpenAddLabelPopup(task, $target);
}}
icon={<Tags width={12} height={12} />}
@ -398,7 +414,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
Labels
</ActionButton>
<ActionButton
onClick={$target => {
onClick={($target) => {
onOpenAddChecklistPopup(task, $target);
}}
icon={<CheckSquareOutline width={12} height={12} />}
@ -406,7 +422,16 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
Checklist
</ActionButton>
<ActionButton>Cover</ActionButton>
<ActionButton
onClick={() => {
onToggleTaskWatch(task, !task.watched);
}}
icon={<Eye width={12} height={12} />}
>
Watch {task.watched && <WatchedCheckmark width={18} height={18} />}
</ActionButton>
</ExtraActionsSection>
)}
</LeftSidebarContent>
</LeftSidebar>
<ContentContainer>
@ -414,15 +439,19 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<HeaderInnerContainer>
<HeaderLeft>
<MarkCompleteButton
disabled={user === null}
invert={task.complete ?? false}
onClick={() => {
if (user) {
onToggleTaskComplete(task);
}
}}
>
<Checkmark width={8} height={8} />
<span>{task.complete ? 'Completed' : 'Mark complete'}</span>
</MarkCompleteButton>
</HeaderLeft>
{user && (
<HeaderRight>
<HeaderActionIcon>
<Paperclip width={16} height={16} />
@ -437,12 +466,14 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<Trash width={16} height={16} />
</HeaderActionIcon>
</HeaderRight>
)}
</HeaderInnerContainer>
<TaskDetailsTitleWrapper>
<TaskDetailsTitle
value={taskName}
ref={$detailsTitle}
onKeyDown={e => {
disabled={user === null}
onKeyDown={(e) => {
if (e.keyCode === 13) {
e.preventDefault();
if ($detailsTitle && $detailsTitle.current) {
@ -450,7 +481,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
}
}
}}
onChange={e => {
onChange={(e) => {
setTaskName(e.currentTarget.value);
}}
onBlur={() => {
@ -463,12 +494,12 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<Labels>
{task.labels.length !== 0 && (
<MetaDetailContent>
{task.labels.map(label => {
{task.labels.map((label) => {
return (
<TaskLabelItem
key={label.projectLabel.id}
label={label}
onClick={$target => {
onClick={($target) => {
onOpenAddLabelPopup(task, $target);
}}
/>
@ -487,7 +518,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<TaskDetailsEditor value={taskDescriptionRef.current} />
) : (
<EditorContainer
onClick={e => {
onClick={(e) => {
if (!editTaskDescription) {
setEditTaskDescription(true);
}
@ -495,10 +526,10 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
>
<Editor
defaultValue={task.description ?? ''}
readOnly={user === null || !editTaskDescription}
theme={dark}
readOnly={!editTaskDescription}
autoFocus
onChange={value => {
onChange={(value) => {
setSaveTimeout(() => {
clearTimeout(saveTimeout);
return setTimeout(saveDescription, 2000);
@ -513,9 +544,9 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<ViewRawButton onClick={() => setShowRaw(!showRaw)}>{showRaw ? 'Show editor' : 'Show raw'}</ViewRawButton>
</DescriptionContainer>
<ChecklistSection>
<DragDropContext onDragEnd={result => onDragEnd(result, task, onChecklistDrop, onChecklistItemDrop)}>
<DragDropContext onDragEnd={(result) => onDragEnd(result, task, onChecklistDrop, onChecklistItemDrop)}>
<Droppable direction="vertical" type="checklist" droppableId="root">
{dropProvided => (
{(dropProvided) => (
<ChecklistContainer {...dropProvided.droppableProps} ref={dropProvided.innerRef}>
{task.checklists &&
task.checklists
@ -523,7 +554,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
.sort((a, b) => a.position - b.position)
.map((checklist, idx) => (
<Draggable key={checklist.id} draggableId={checklist.id} index={idx}>
{provided => (
{(provided) => (
<Checklist
ref={provided.innerRef}
wrapperProps={provided.draggableProps}
@ -533,10 +564,10 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
checklistID={checklist.id}
items={checklist.items}
onDeleteChecklist={onDeleteChecklist}
onChangeName={newName => onChangeChecklistName(checklist.id, newName)}
onChangeName={(newName) => onChangeChecklistName(checklist.id, newName)}
onToggleItem={onToggleChecklistItem}
onDeleteItem={onDeleteItem}
onAddItem={n => {
onAddItem={(n) => {
if (task.checklists) {
let position = 65535;
const [lastItem] = checklist.items
@ -551,7 +582,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
onChangeItemName={onChangeItemName}
>
<Droppable direction="vertical" type="checklistItem" droppableId={checklist.id}>
{checklistDrop => (
{(checklistDrop) => (
<>
<ChecklistItems ref={checklistDrop.innerRef} {...checklistDrop.droppableProps}>
{checklist.items
@ -559,7 +590,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
.sort((a, b) => a.position - b.position)
.map((item, itemIdx) => (
<Draggable key={item.id} draggableId={item.id} index={itemIdx}>
{itemDrop => (
{(itemDrop) => (
<ChecklistItem
key={item.id}
itemID={item.id}
@ -597,30 +628,34 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<TabBarItem>Activity</TabBarItem>
</TabBarSection>
<ActivitySection>
{activityStream.map(stream =>
{activityStream.map((stream) =>
stream.data.type === 'comment' ? (
<StreamComment
key={stream.id}
onExtraActions={onCommentShowActions}
onCancelCommentEdit={onCancelCommentEdit}
onUpdateComment={message => onUpdateComment(stream.id, message)}
onUpdateComment={(message) => onUpdateComment(stream.id, message)}
editable={stream.id === editableComment}
comment={task.comments && task.comments.find(comment => comment.id === stream.id)}
comment={task.comments && task.comments.find((comment) => comment.id === stream.id)}
/>
) : (
<StreamActivity activity={task.activity && task.activity.find(activity => activity.id === stream.id)} />
<StreamActivity
key={stream.id}
activity={task.activity && task.activity.find((activity) => activity.id === stream.id)}
/>
),
)}
</ActivitySection>
</InnerContentContainer>
<CommentContainer>
{me && (
<CommentContainer>
<CommentCreator
me={me}
onCreateComment={message => onCreateComment(task, message)}
onCreateComment={(message) => onCreateComment(task, message)}
onMemberProfile={onMemberProfile}
/>
)}
</CommentContainer>
)}
</ContentContainer>
</Container>
);

View File

@ -1,6 +1,6 @@
import visit from 'unist-util-visit';
import { visit } from 'unist-util-visit';
import emoji from 'node-emoji';
import emoticon from 'emoticon';
import { emoticon } from 'emoticon';
import { Emoji } from 'emoji-mart';
import React from 'react';
@ -15,34 +15,31 @@ const DEFAULT_SETTINGS = {
};
function plugin(options) {
const settings = Object.assign({}, DEFAULT_SETTINGS, options);
const settings = { ...DEFAULT_SETTINGS, ...options };
const pad = !!settings.padSpaceAfter;
const emoticonEnable = !!settings.emoticon;
function getEmojiByShortCode(match) {
// find emoji by shortcode - full match or with-out last char as it could be from text e.g. :-),
const iconFull = emoticon.find(e => e.emoticons.includes(match)); // full match
const iconPart = emoticon.find(e => e.emoticons.includes(match.slice(0, -1))); // second search pattern
const iconFull = emoticon.find((e) => e.emoticons.includes(match)); // full match
const iconPart = emoticon.find((e) => e.emoticons.includes(match.slice(0, -1))); // second search pattern
const trimmedChar = iconPart ? match.slice(-1) : '';
const addPad = pad ? ' ' : '';
let icon = iconFull ? iconFull.emoji + addPad : iconPart && iconPart.emoji + addPad + trimmedChar;
const icon = iconFull ? iconFull.emoji + addPad : iconPart && iconPart.emoji + addPad + trimmedChar;
return icon || match;
}
function getEmoji(match) {
console.log(match);
const got = emoji.get(match);
if (pad && got !== match) {
return got + ' ';
return `${got} `;
}
console.log(got);
return ReactDOMServer.renderToStaticMarkup(<Emoji set="google" emoji={match} size={16} />);
}
function transformer(tree) {
visit(tree, 'paragraph', function (node) {
console.log(tree);
// node.value = node.value.replace(RE_EMOJI, getEmoji);
// jnode.type = 'html';
// jnode.tagName = 'div';
@ -58,11 +55,10 @@ function plugin(options) {
if (emoticonEnable) {
// node.value = node.value.replace(RE_SHORT, getEmojiByShortCode);
}
console.log(node);
});
}
return transformer;
}
export { plugin };
export default plugin;

View File

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

View File

@ -6,11 +6,11 @@ import { NavLink, Link } from 'react-router-dom';
import TaskAssignee from 'shared/components/TaskAssignee';
export const ProjectMember = styled(TaskAssignee)<{ zIndex: number }>`
z-index: ${props => props.zIndex};
z-index: ${(props) => props.zIndex};
position: relative;
box-shadow: 0 0 0 2px ${props => props.theme.colors.bg.primary},
inset 0 0 0 1px ${props => mixin.rgba(props.theme.colors.bg.primary, 0.07)};
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.bg.primary},
inset 0 0 0 1px ${(props) => mixin.rgba(props.theme.colors.bg.primary, 0.07)};
`;
export const NavbarWrapper = styled.div`
@ -27,9 +27,9 @@ export const NavbarHeader = styled.header`
display: flex;
align-items: center;
justify-content: space-between;
background: ${props => props.theme.colors.bg.primary};
background: ${(props) => props.theme.colors.bg.primary};
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.05);
border-bottom: 1px solid ${props => mixin.rgba(props.theme.colors.alternate, 0.65)};
border-bottom: 1px solid ${(props) => mixin.rgba(props.theme.colors.alternate, 0.65)};
`;
export const Breadcrumbs = styled.div`
color: rgb(94, 108, 132);
@ -59,7 +59,7 @@ export const ProjectSwitchInner = styled.div`
flex-direction: column;
justify-content: center;
background-color: ${props => props.theme.colors.primary};
background-color: ${(props) => props.theme.colors.primary};
`;
export const ProjectSwitch = styled.div`
@ -109,10 +109,27 @@ export const NavbarLink = styled(Link)`
cursor: pointer;
`;
export const NotificationCount = styled.div`
position: absolute;
top: -6px;
right: -6px;
background: #7367f0;
border-radius: 50%;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid rgb(16, 22, 58);
color: #fff;
font-size: 14px;
`;
export const IconContainerWrapper = styled.div<{ disabled?: boolean }>`
margin-right: 20px;
position: relative;
cursor: pointer;
${props =>
${(props) =>
props.disabled &&
css`
opacity: 0.5;
@ -142,15 +159,15 @@ export const ProfileIcon = styled.div<{
justify-content: center;
color: #fff;
font-weight: 700;
background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)};
background: ${(props) => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)};
background-position: center;
background-size: contain;
`;
export const ProjectMeta = styled.div<{ nameOnly?: boolean }>`
display: flex;
${props => !props.nameOnly && 'padding-top: 9px;'}
margin-left: -14px;
${(props) => !props.nameOnly && 'padding-top: 9px;'}
margin-left: -6px;
align-items: center;
max-width: 100%;
min-height: 51px;
@ -167,7 +184,7 @@ export const ProjectTabs = styled.div`
export const ProjectTab = styled(NavLink)`
font-size: 80%;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
font-size: 15px;
cursor: pointer;
display: flex;
@ -184,22 +201,22 @@ export const ProjectTab = styled(NavLink)`
}
&:hover {
box-shadow: inset 0 -2px ${props => props.theme.colors.text.secondary};
color: ${props => props.theme.colors.text.secondary};
box-shadow: inset 0 -2px ${(props) => props.theme.colors.text.secondary};
color: ${(props) => props.theme.colors.text.secondary};
}
&.active {
box-shadow: inset 0 -2px ${props => props.theme.colors.secondary};
color: ${props => props.theme.colors.secondary};
box-shadow: inset 0 -2px ${(props) => props.theme.colors.secondary};
color: ${(props) => props.theme.colors.secondary};
}
&.active:hover {
box-shadow: inset 0 -2px ${props => props.theme.colors.secondary};
color: ${props => props.theme.colors.secondary};
box-shadow: inset 0 -2px ${(props) => props.theme.colors.secondary};
color: ${(props) => props.theme.colors.secondary};
}
`;
export const ProjectName = styled.h1`
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
font-weight: 600;
font-size: 20px;
padding: 3px 10px 3px 8px;
@ -241,7 +258,7 @@ export const ProjectNameTextarea = styled.input`
font-size: 20px;
padding: 3px 10px 3px 8px;
&:focus {
box-shadow: ${props => props.theme.colors.primary} 0px 0px 0px 1px;
box-shadow: ${(props) => props.theme.colors.primary} 0px 0px 0px 1px;
}
`;
@ -259,7 +276,7 @@ export const ProjectSwitcher = styled.button`
color: #c2c6dc;
cursor: pointer;
&:hover {
background: ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.primary};
}
`;
@ -283,7 +300,7 @@ export const ProjectSettingsButton = styled.button`
justify-content: center;
cursor: pointer;
&:hover {
background: ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.primary};
}
`;
@ -297,9 +314,19 @@ export const ProjectFinder = styled(Button)`
padding: 6px 12px;
`;
export const SignUp = styled(Button)`
margin-right: 8px;
padding: 6px 12px;
`;
export const SignIn = styled(Button)`
margin-right: 20px;
padding: 6px 12px;
`;
export const NavSeparator = styled.div`
width: 1px;
background: ${props => props.theme.colors.border};
background: ${(props) => props.theme.colors.border};
height: 34px;
margin: 0 20px;
`;
@ -316,11 +343,11 @@ export const LogoContainer = styled(Link)`
export const TaskcafeTitle = styled.h2`
margin-left: 5px;
color: ${props => props.theme.colors.text.primary};
color: ${(props) => props.theme.colors.text.primary};
font-size: 20px;
`;
export const TaskcafeLogo = styled(Taskcafe)`
fill: ${props => props.theme.colors.text.primary};
stroke: ${props => props.theme.colors.text.primary};
fill: ${(props) => props.theme.colors.text.primary};
stroke: ${(props) => props.theme.colors.text.primary};
`;

View File

@ -36,6 +36,7 @@ import {
ProjectMember,
ProjectMembers,
ProjectSwitchInner,
NotificationCount,
} from './Styles';
type IconContainerProps = {
@ -144,7 +145,7 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
</ProjectSettingsButton>
{onFavorite && (
<ProjectSettingsButton onClick={() => onFavorite()}>
<Star width={16} height={16} color="#c2c6dc" />
<Star filled width={16} height={16} color="#c2c6dc" />
</ProjectSettingsButton>
)}
</>
@ -185,6 +186,7 @@ type NavBarProps = {
projectMembers?: Array<TaskUser> | null;
projectInvitedMembers?: Array<InvitedUser> | null;
hasUnread: boolean;
onRemoveFromBoard?: (userID: string) => void;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
onInvitedMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, email: string) => void;
@ -203,6 +205,7 @@ const NavBar: React.FC<NavBarProps> = ({
onOpenProjectFinder,
onFavorite,
onSetTab,
hasUnread,
projectInvitedMembers,
onChangeRole,
name,
@ -228,7 +231,7 @@ const NavBar: React.FC<NavBarProps> = ({
<NavbarWrapper>
<NavbarHeader>
<ProjectActions>
<ProjectSwitch ref={$finder} onClick={e => onOpenProjectFinder($finder)}>
<ProjectSwitch ref={$finder} onClick={(e) => onOpenProjectFinder($finder)}>
<ProjectSwitchInner>
<TaskcafeLogo innerColor="#9f46e4" outerColor="#000" width={32} height={32} />
</ProjectSwitchInner>
@ -304,7 +307,7 @@ const NavBar: React.FC<NavBarProps> = ({
))}
{canInviteUser && (
<InviteButton
onClick={$target => {
onClick={($target) => {
if (onInviteUser) {
onInviteUser($target);
}
@ -330,8 +333,9 @@ const NavBar: React.FC<NavBarProps> = ({
<IconContainer disabled onClick={NOOP}>
<ListUnordered width={20} height={20} />
</IconContainer>
<IconContainer disabled onClick={onNotificationClick}>
<Bell color="#c2c6dc" size={20} />
<IconContainer onClick={onNotificationClick}>
<Bell width={20} height={20} />
{hasUnread && <NotificationCount />}
</IconContainer>
<IconContainer disabled onClick={NOOP}>
<BarChart width={20} height={20} />

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
mutation createProject($teamID: UUID, $name: String!) {
createProject(input: {teamID: $teamID, name: $name}) {
id
shortId
name
team {
id

View File

@ -2,9 +2,11 @@ import gql from 'graphql-tag';
import TASK_FRAGMENT from './fragments/task';
const FIND_PROJECT_QUERY = gql`
query findProject($projectID: UUID!) {
findProject(input: { projectID: $projectID }) {
query findProject($projectID: String!) {
findProject(input: { projectShortID: $projectID }) {
id
name
publicOn
team {
id
}

View File

@ -1,9 +1,18 @@
query findTask($taskID: UUID!) {
findTask(input: {taskID: $taskID}) {
query findTask($taskID: String!) {
findTask(input: {taskShortID: $taskID}) {
id
shortId
name
watched
description
dueDate
dueDate {
at
notifications {
id
period
duration
}
}
position
complete
hasTime

View File

@ -3,11 +3,15 @@ import gql from 'graphql-tag';
const TASK_FRAGMENT = gql`
fragment TaskFields on Task {
id
shortId
name
description
dueDate
dueDate {
at
}
hasTime
complete
watched
completedAt
position
badges {
@ -15,6 +19,10 @@ const TASK_FRAGMENT = gql`
complete
total
}
comments {
unread
total
}
}
taskGroup {
id

View File

@ -10,6 +10,7 @@ query getProjects {
}
projects {
id
shortId
name
team {
id

View File

@ -0,0 +1,26 @@
import gql from 'graphql-tag';
import TASK_FRAGMENT from './fragments/task';
const FIND_PROJECT_QUERY = gql`
query labels($projectID: UUID!) {
findProject(input: { projectID: $projectID }) {
labels {
id
createdDate
name
labelColor {
id
name
colorHex
position
}
}
}
labelColors {
id
position
colorHex
name
}
}
`;

View File

@ -6,12 +6,15 @@ query myTasks($status: MyTasksStatus!, $sort: MyTasksSort!) {
myTasks(input: { status: $status, sort: $sort }) {
tasks {
id
shortId
taskGroup {
id
name
}
name
dueDate
dueDate {
at
}
hasTime
complete
completedAt

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag';
const CREATE_TASK_MUTATION = gql`
mutation notificationToggleRead($notifiedID: UUID!) {
notificationToggleRead(input: { notifiedID: $notifiedID }) {
id
read
readAt
}
}
`;
export default CREATE_TASK_MUTATION;

View File

@ -0,0 +1,34 @@
import gql from 'graphql-tag';
export const TOP_NAVBAR_QUERY = gql`
query notifications($limit: Int!, $cursor: String, $filter: NotificationFilter!) {
notified(input: { limit: $limit, cursor: $cursor, filter: $filter }) {
totalCount
pageInfo {
endCursor
hasNextPage
}
notified {
id
read
readAt
notification {
id
actionType
data {
key
value
}
causedBy {
username
fullname
id
}
createdAt
}
}
}
}
`;
export default TOP_NAVBAR_QUERY;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
const CREATE_TASK_MUTATION = gql`
mutation notificationMarkAllRead {
notificationMarkAllRead {
success
}
}
`;
export default CREATE_TASK_MUTATION;

View File

@ -0,0 +1,26 @@
import gql from 'graphql-tag';
import TASK_FRAGMENT from './fragments/task';
const FIND_PROJECT_QUERY = gql`
subscription notificationAdded {
notificationAdded {
id
read
readAt
notification {
id
actionType
data {
key
value
}
causedBy {
username
fullname
id
}
createdAt
}
}
}
`;

View File

@ -0,0 +1,12 @@
import gql from 'graphql-tag';
const CREATE_TASK_MUTATION = gql`
mutation toggleTaskWatch($taskID: UUID!) {
toggleTaskWatch(input: { taskID: $taskID }) {
id
watched
}
}
`;
export default CREATE_TASK_MUTATION;

View File

@ -0,0 +1,14 @@
import gql from 'graphql-tag';
export const DELETE_PROJECT_MUTATION = gql`
mutation toggleProjectVisibility($projectID: UUID!, $isPublic: Boolean!) {
toggleProjectVisibility(input: { projectID: $projectID, isPublic: $isPublic }) {
project {
id
publicOn
}
}
}
`;
export default DELETE_PROJECT_MUTATION;

View File

@ -3,20 +3,19 @@ import gql from 'graphql-tag';
export const TOP_NAVBAR_QUERY = gql`
query topNavbar {
notifications {
createdAt
id
read
readAt
notification {
id
entity {
id
type
name
}
actor {
id
type
name
}
actionType
causedBy {
username
fullname
id
}
createdAt
}
}
me {
user {

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