Compare commits

..

33 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
173 changed files with 16732 additions and 8985 deletions

View File

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

View File

@ -4,7 +4,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.0] - 2021-09-04 ## UNRELEASED
### Added
- On login page, redirects to `/register` if no users exist (to help streamline initial setup)
### Fixed
- Fixes new user popup form so that it can now be submitted
## [0.3.5] - 2021-09-04
### Added ### Added
- Project visibility can now be set to public - meaning anyone can view the project board - Project visibility can now be set to public - meaning anyone can view the project board

View File

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

View File

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

View File

@ -25,6 +25,7 @@
], ],
"rules": { "rules": {
"prettier/prettier": "warn", "prettier/prettier": "warn",
"no-shadow": "off",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"], "no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
@ -34,6 +35,7 @@
"no-case-declarations": "off", "no-case-declarations": "off",
"no-plusplus": "off", "no-plusplus": "off",
"react/prop-types": 0, "react/prop-types": 0,
"react/no-unused-prop-types": "off",
"no-continue": "off", "no-continue": "off",
"react/jsx-props-no-spreading": "off", "react/jsx-props-no-spreading": "off",
"no-param-reassign": "off", "no-param-reassign": "off",

View File

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

View File

@ -13,10 +13,10 @@
"@types/jest": "^26.0.23", "@types/jest": "^26.0.23",
"@types/lodash": "^4.14.168", "@types/lodash": "^4.14.168",
"@types/node": "^15.0.1", "@types/node": "^15.0.1",
"@types/react": "^17.0.4", "@types/react": "^17.0.20",
"@types/react-beautiful-dnd": "^13.0.0", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react-datepicker": "^3.1.8", "@types/react-datepicker": "^3.1.8",
"@types/react-dom": "^17.0.3", "@types/react-dom": "^17.0.9",
"@types/react-router": "^5.1.13", "@types/react-router": "^5.1.13",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"@types/react-select": "^4.0.15", "@types/react-select": "^4.0.15",
@ -43,6 +43,8 @@
"immer": "^9.0.2", "immer": "^9.0.2",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"loglevel": "^1.7.1",
"loglevel-plugin-remote": "^0.6.8",
"node-emoji": "^1.10.0", "node-emoji": "^1.10.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"query-string": "^7.0.0", "query-string": "^7.0.0",
@ -62,7 +64,8 @@
"react-toastify": "^7.0.4", "react-toastify": "^7.0.4",
"rich-markdown-editor": "^11.17.4-0", "rich-markdown-editor": "^11.17.4-0",
"styled-components": "^5.2.3", "styled-components": "^5.2.3",
"typescript": "~4.2.4" "typescript": "~4.2.4",
"unist-util-visit": "^4.0.0"
}, },
"proxy": "http://localhost:3333", "proxy": "http://localhost:3333",
"scripts": { "scripts": {

View File

@ -12,7 +12,7 @@ import {
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import styled from 'styled-components'; import styled from 'styled-components';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller, UseFormSetError } from 'react-hook-form';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer'; import produce from 'immer';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
@ -20,6 +20,7 @@ import { useCurrentUser } from 'App/context';
import { Redirect } from 'react-router'; import { Redirect } from 'react-router';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import ControlledInput from 'shared/components/ControlledInput'; import ControlledInput from 'shared/components/ControlledInput';
import FormInput from 'shared/components/FormInput';
const DeleteUserWrapper = styled.div` const DeleteUserWrapper = styled.div`
display: flex; display: flex;
@ -77,7 +78,7 @@ const CreateUserButton = styled(Button)`
width: 100%; width: 100%;
`; `;
const AddUserInput = styled(ControlledInput)` const AddUserInput = styled(FormInput)`
margin-bottom: 8px; margin-bottom: 8px;
`; `;
@ -87,7 +88,7 @@ const InputError = styled.span`
`; `;
type AddUserPopupProps = { type AddUserPopupProps = {
onAddUser: (user: CreateUserData) => void; onAddUser: (user: CreateUserData, setError: UseFormSetError<CreateUserData>) => void;
}; };
const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => { const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
@ -95,16 +96,16 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
setError,
control, control,
} = useForm<CreateUserData>(); } = useForm<CreateUserData>();
const createUser = (data: CreateUserData) => { const createUser = (data: CreateUserData) => {
onAddUser(data); onAddUser(data, setError);
}; };
return ( return (
<CreateUserForm onSubmit={handleSubmit(createUser)}> <CreateUserForm onSubmit={handleSubmit(createUser)}>
<AddUserInput <AddUserInput
floatingLabel
width="100%" width="100%"
label="Full Name" label="Full Name"
variant="alternate" variant="alternate"
@ -118,6 +119,7 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
variant="alternate" variant="alternate"
{...register('email', { required: 'Email is required' })} {...register('email', { required: 'Email is required' })}
/> />
{errors.email && <InputError>{errors.email.message}</InputError>}
<Controller <Controller
control={control} control={control}
name="roleCode" name="roleCode"
@ -223,7 +225,7 @@ TODO: add permision check
users={data.users} users={data.users}
invitedUsers={data.invitedUsers} invitedUsers={data.invitedUsers}
// canInviteUser={user.roles.org === 'admin'} TODO: add permision check // canInviteUser={user.roles.org === 'admin'} TODO: add permision check
canInviteUser={true} canInviteUser
onInviteUser={NOOP} onInviteUser={NOOP}
onUpdateUserPassword={() => { onUpdateUserPassword={() => {
hidePopup(); hidePopup();
@ -241,10 +243,15 @@ TODO: add permision check
$target, $target,
<Popup tab={0} title="Add member" onClose={() => hidePopup()}> <Popup tab={0} title="Add member" onClose={() => hidePopup()}>
<AddUserPopup <AddUserPopup
onAddUser={(u) => { onAddUser={(u, setError) => {
const { roleCode, ...userData } = u; const { roleCode, ...userData } = u;
createUser({ variables: { ...userData, roleCode: roleCode.value } }); createUser({
hidePopup(); variables: { ...userData, roleCode: roleCode.value },
})
.then(() => hidePopup())
.catch((e) => {
setError('email', { type: 'validate', message: e.message });
});
}} }}
/> />
</Popup>, </Popup>,

View File

@ -32,7 +32,6 @@ type ValidateTokenResponse = {
const UserRequiredRoute: React.FC<any> = ({ children }) => { const UserRequiredRoute: React.FC<any> = ({ children }) => {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const location = useLocation(); const location = useLocation();
console.log('user required', user);
if (user) { if (user) {
return children; return children;
} }
@ -62,7 +61,6 @@ const Routes: React.FC = () => {
setLoading(false); setLoading(false);
}); });
}, []); }, []);
console.log('loading', loading);
if (loading) return null; if (loading) return null;
return ( return (
<Switch> <Switch>
@ -71,11 +69,10 @@ const Routes: React.FC = () => {
<Route exact path="/confirm" component={Confirm} /> <Route exact path="/confirm" component={Confirm} />
<Switch> <Switch>
<MainContent> <MainContent>
<Route path="/projects/:projectID" component={Project} /> <Route path="/p/:projectID" component={Project} />
<UserRequiredRoute> <UserRequiredRoute>
<Route exact path="/" component={Dashboard} /> <Route exact path="/" component={Projects} />
<Route exact path="/projects" component={Projects} />
<Route path="/teams/:teamID" component={Teams} /> <Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} /> <Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} /> <Route path="/admin" component={Admin} />

View File

@ -1,10 +1,16 @@
import React from 'react'; import React, { useState } from 'react';
import TopNavbar, { MenuItem } from 'shared/components/TopNavbar'; import TopNavbar, { MenuItem } from 'shared/components/TopNavbar';
import LoggedOutNavbar from 'shared/components/TopNavbar/LoggedOut'; import LoggedOutNavbar from 'shared/components/TopNavbar/LoggedOut';
import { ProfileMenu } from 'shared/components/DropdownMenu'; import { ProfileMenu } from 'shared/components/DropdownMenu';
import polling from 'shared/utils/polling';
import { useHistory, useRouteMatch } from 'react-router'; import { useHistory, useRouteMatch } from 'react-router';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import { RoleCode, useTopNavbarQuery } from 'shared/generated/graphql'; import {
RoleCode,
useTopNavbarQuery,
useNotificationAddedSubscription,
useHasUnreadNotificationsQuery,
} from 'shared/generated/graphql';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import MiniProfile from 'shared/components/MiniProfile'; import MiniProfile from 'shared/components/MiniProfile';
import cache from 'App/cache'; import cache from 'App/cache';
@ -49,15 +55,33 @@ const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
onRemoveInvitedFromBoard, onRemoveInvitedFromBoard,
onRemoveFromBoard, onRemoveFromBoard,
}) => { }) => {
const { data } = useTopNavbarQuery(); 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 { showPopup, hidePopup } = usePopup();
const { setUser } = useCurrentUser(); const { setUser } = useCurrentUser();
const { data: unreadData, refetch: refetchHasUnread } = useHasUnreadNotificationsQuery({
pollInterval: polling.UNREAD_NOTIFICATIONS,
});
const history = useHistory(); const history = useHistory();
const onLogout = () => { const onLogout = () => {
fetch('/auth/logout', { fetch('/auth/logout', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}).then(async x => { }).then(async (x) => {
const { status } = x; const { status } = x;
if (status === 200) { if (status === 200) {
cache.reset(); cache.reset();
@ -94,29 +118,20 @@ const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
} }
}; };
// TODO: rewrite popup to contain subscription and notification fetch
const onNotificationClick = ($target: React.RefObject<HTMLElement>) => { const onNotificationClick = ($target: React.RefObject<HTMLElement>) => {
if (data) { showPopup($target, <NotificationPopup onToggleRead={() => refetchHasUnread()} />, {
showPopup( width: 605,
$target, borders: false,
<NotificationPopup> diamondColor: theme.colors.primary,
{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 },
);
}
}; };
// TODO: readd permision check // TODO: readd permision check
// const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID); // const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
const userIsTeamOrProjectAdmin = true; const userIsTeamOrProjectAdmin = true;
const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => { 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) { if (member) {
showPopup( showPopup(
$targetRef, $targetRef,
@ -144,7 +159,7 @@ const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
}; };
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => { 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 = const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.'; 'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
if (member) { if (member) {
@ -153,7 +168,7 @@ const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
<MiniProfile <MiniProfile
warning={member.role && member.role.code === 'owner' ? warning : null} warning={member.role && member.role.code === 'owner' ? warning : null}
canChangeRole={userIsTeamOrProjectAdmin} canChangeRole={userIsTeamOrProjectAdmin}
onChangeRole={roleCode => { onChangeRole={(roleCode) => {
if (onChangeRole) { if (onChangeRole) {
onChangeRole(member.id, roleCode); onChangeRole(member.id, roleCode);
} }
@ -179,9 +194,10 @@ const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
return ( return (
<> <>
<TopNavbar <TopNavbar
hasUnread={unreadData ? unreadData.hasUnreadNotifications.unread : false}
name={name} name={name}
menuType={menuType} menuType={menuType}
onOpenProjectFinder={$target => { onOpenProjectFinder={($target) => {
showPopup( showPopup(
$target, $target,
<Popup tab={0} title={null}> <Popup tab={0} title={null}>

View File

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

View File

@ -9,7 +9,6 @@ const Auth = () => {
const history = useHistory(); const history = useHistory();
const location = useLocation<{ redirect: string } | undefined>(); const location = useLocation<{ redirect: string } | undefined>();
const { setUser } = useContext(UserContext); const { setUser } = useContext(UserContext);
console.log('auth');
const login = ( const login = (
data: LoginFormData, data: LoginFormData,
setComplete: (val: boolean) => void, setComplete: (val: boolean) => void,

View File

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

View File

@ -26,10 +26,10 @@ import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import DueDateManager from 'shared/components/DueDateManager'; import DueDateManager from 'shared/components/DueDateManager';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import useStickyState from 'shared/hooks/useStickyState'; import useStickyState from 'shared/hooks/useStickyState';
import { StaticContext } from 'react-router';
import MyTasksSortPopup from './MyTasksSort'; import MyTasksSortPopup from './MyTasksSort';
import MyTasksStatusPopup from './MyTasksStatus'; import MyTasksStatusPopup from './MyTasksStatus';
import TaskEntry from './TaskEntry'; import TaskEntry from './TaskEntry';
import { StaticContext } from 'react-router';
type TaskRouteProps = { type TaskRouteProps = {
taskID: string; taskID: string;
@ -562,13 +562,36 @@ const Projects = () => {
onCancel={() => null} onCancel={() => null}
onDueDateChange={(task, dueDate, hasTime) => { onDueDateChange={(task, dueDate, hasTime) => {
if (dateEditor.task) { if (dateEditor.task) {
updateTaskDueDate({ variables: { taskID: dateEditor.task.id, dueDate, hasTime } }); hidePopup();
setDateEditor((prev) => ({ ...prev, task: { ...task, dueDate: dueDate.toISOString(), hasTime } })); 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) { if (dateEditor.task) {
updateTaskDueDate({ variables: { taskID: dateEditor.task.id, dueDate: null, hasTime: false } }); hidePopup();
updateTaskDueDate({
variables: {
taskID: dateEditor.task.id,
dueDate: null,
hasTime: false,
deleteNotifications: [],
updateNotifications: [],
createNotifications: [],
},
});
setDateEditor((prev) => ({ ...prev, task: { ...task, hasTime: false } })); setDateEditor((prev) => ({ ...prev, task: { ...task, hasTime: false } }));
} }
}} }}
@ -655,8 +678,8 @@ const Projects = () => {
if (a.dueDate === null && b.dueDate === null) return 0; 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;
if (a.dueDate !== null && b.dueDate === null) return -1; if (a.dueDate !== null && b.dueDate === null) return -1;
const first = dayjs(a.dueDate); const first = dayjs(a.dueDate.at);
const second = dayjs(b.dueDate); const second = dayjs(b.dueDate.at);
if (first.isSame(second, 'minute')) return 0; if (first.isSame(second, 'minute')) return 0;
if (first.isAfter(second)) return -1; if (first.isAfter(second)) return -1;
return 1; return 1;
@ -792,10 +815,19 @@ const Projects = () => {
history.push(`${match.url}/c/${task.id}`); history.push(`${match.url}/c/${task.id}`);
}} }}
onRemoveDueDate={() => { 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'} project={projectName ?? 'none'}
dueDate={task.dueDate} dueDate={task.dueDate.at}
hasTime={task.hasTime ?? false} hasTime={task.hasTime ?? false}
name={task.name} name={task.name}
onEditName={(name) => updateTaskName({ variables: { taskID: task.id, name } })} onEditName={(name) => updateTaskName({ variables: { taskID: task.id, name } })}
@ -821,7 +853,9 @@ const Projects = () => {
<EditorCell width={120}> <EditorCell width={120}>
<DueDateEditorLabel> <DueDateEditorLabel>
{dateEditor.task.dueDate {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> </DueDateEditorLabel>
</EditorCell> </EditorCell>

View File

@ -44,7 +44,7 @@ const Projects = () => {
name="file" name="file"
style={{ display: 'none' }} style={{ display: 'none' }}
ref={$fileUpload} ref={$fileUpload}
onChange={e => { onChange={(e) => {
if (e.target.files) { if (e.target.files) {
const fileData = new FormData(); const fileData = new FormData();
fileData.append('file', e.target.files[0]); fileData.append('file', e.target.files[0]);
@ -52,7 +52,7 @@ const Projects = () => {
.post('/users/me/avatar', fileData, { .post('/users/me/avatar', fileData, {
withCredentials: true, withCredentials: true,
}) })
.then(res => { .then((res) => {
if ($fileUpload && $fileUpload.current) { if ($fileUpload && $fileUpload.current) {
$fileUpload.current.value = ''; $fileUpload.current.value = '';
refetch(); refetch();
@ -77,7 +77,7 @@ const Projects = () => {
}} }}
onChangeUserInfo={(d, done) => { onChangeUserInfo={(d, done) => {
updateUserInfo({ 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!'); toast('User info was saved!');
done(); done();

View File

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

View File

@ -30,7 +30,7 @@ export const ActionItem = styled.li`
align-items: center; align-items: center;
font-size: 14px; font-size: 14px;
&:hover { &:hover {
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
} }
&:hover ${ActionExtraMenuContainer} { &:hover ${ActionExtraMenuContainer} {
visibility: visible; visibility: visible;
@ -69,11 +69,11 @@ export const ActionExtraMenuItem = styled.li`
align-items: center; align-items: center;
font-size: 14px; font-size: 14px;
&:hover { &:hover {
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
} }
`; `;
const ActionExtraMenuSeparator = styled.li` const ActionExtraMenuSeparator = styled.li`
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
font-size: 12px; font-size: 12px;
padding-left: 4px; padding-left: 4px;
padding-right: 4px; padding-right: 4px;
@ -85,12 +85,12 @@ const ActiveIcon = styled(Checkmark)`
position: absolute; position: absolute;
`; `;
type FilterStatusProps = { type ControlStatusProps = {
filter: TaskStatusFilter; filter: TaskStatusFilter;
onChangeTaskStatusFilter: (filter: TaskStatusFilter) => void; onChangeTaskStatusFilter: (filter: TaskStatusFilter) => void;
}; };
const FilterStatus: React.FC<FilterStatusProps> = ({ filter, onChangeTaskStatusFilter }) => { const ControlStatus: React.FC<ControlStatusProps> = ({ filter, onChangeTaskStatusFilter }) => {
const [currentFilter, setFilter] = useState(filter); const [currentFilter, setFilter] = useState(filter);
const handleFilterChange = (f: TaskStatusFilter) => { const handleFilterChange = (f: TaskStatusFilter) => {
setFilter(f); setFilter(f);
@ -146,4 +146,4 @@ const 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 Chip from 'shared/components/Chip';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import FilterStatus from './FilterStatus'; import ControlStatus from './ControlStatus';
import FilterMeta from './FilterMeta'; import ControlFilter from './ControlFilter';
import SortPopup from './SortPopup'; import ControlSort from './ControlSort';
const FilterChip = styled(Chip)` const FilterChip = styled(Chip)`
margin-right: 4px; margin-right: 4px;
@ -60,19 +60,20 @@ const FilterChip = styled(Chip)`
type MetaFilterCloseFn = (meta: TaskMeta, key: string) => void; type MetaFilterCloseFn = (meta: TaskMeta, key: string) => void;
const renderTaskSortingLabel = (sorting: TaskSorting) => { const renderTaskSortingLabel = (sorting: TaskSorting) => {
if (sorting.type === TaskSortingType.TASK_TITLE) { switch (sorting.type) {
return 'Sort: Card title'; case TaskSortingType.TASK_TITLE:
return 'Sort: Task Title';
case TaskSortingType.MEMBERS:
return 'Sort: Members';
case TaskSortingType.DUE_DATE:
return 'Sort: Due Date';
case TaskSortingType.LABELS:
return 'Sort: Labels';
case TaskSortingType.COMPLETE:
return 'Sort: Complete';
default:
return 'Sort';
} }
if (sorting.type === TaskSortingType.MEMBERS) {
return 'Sort: Members';
}
if (sorting.type === TaskSortingType.DUE_DATE) {
return 'Sort: Due Date';
}
if (sorting.type === TaskSortingType.LABELS) {
return 'Sort: Labels';
}
return 'Sort';
}; };
const renderMetaFilters = (filters: TaskMetaFilters, onClose: MetaFilterCloseFn) => { const renderMetaFilters = (filters: TaskMetaFilters, onClose: MetaFilterCloseFn) => {
@ -136,16 +137,16 @@ const ProjectActionWrapper = styled.div<{ disabled?: boolean }>`
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 15px; font-size: 15px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
&:not(:last-of-type) { &:not(:last-of-type) {
margin-right: 16px; margin-right: 16px;
} }
&:hover { &:hover {
color: ${props => props.theme.colors.text.secondary}; color: ${(props) => props.theme.colors.text.secondary};
} }
${props => ${(props) =>
props.disabled && props.disabled &&
css` css`
opacity: 0.5; opacity: 0.5;
@ -280,8 +281,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter( draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter(
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data?.deleteTaskGroup.taskGroup.id, (taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data?.deleteTaskGroup.taskGroup.id,
); );
@ -296,10 +297,10 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
const { taskGroups } = cache.findProject; const { taskGroups } = cache.findProject;
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data?.createTask.taskGroup.id); const idx = taskGroups.findIndex((taskGroup) => taskGroup.id === newTaskData.data?.createTask.taskGroup.id);
if (idx !== -1) { if (idx !== -1) {
if (newTaskData.data) { if (newTaskData.data) {
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask }); draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
@ -316,8 +317,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (newTaskGroupData.data) { if (newTaskGroupData.data) {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] }); draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
} }
@ -336,10 +337,10 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
const idx = cache.findProject.taskGroups.findIndex( const idx = cache.findProject.taskGroups.findIndex(
t => t.id === resp.data?.deleteTaskGroupTasks.taskGroupID, (t) => t.id === resp.data?.deleteTaskGroupTasks.taskGroupID,
); );
if (idx !== -1) { if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks = []; draftCache.findProject.taskGroups[idx].tasks = [];
@ -353,8 +354,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (resp.data) { if (resp.data) {
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup); draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
} }
@ -371,8 +372,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
if (newTask.data) { if (newTask.data) {
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation; const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
if (previousTaskGroupID !== task.taskGroup.id) { if (previousTaskGroupID !== task.taskGroup.id) {
@ -380,7 +381,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID); const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id); const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) { if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
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( draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
(t: Task) => t.id !== task.id, (t: Task) => t.id !== task.id,
); );
@ -401,14 +404,14 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [deleteTask] = useDeleteTaskMutation(); const [deleteTask] = useDeleteTaskMutation();
const [toggleTaskLabel] = useToggleTaskLabelMutation({ const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => { onCompleted: (newTaskLabel) => {
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels; taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
}, },
}); });
const onCreateTask = (taskGroupID: string, name: string) => { const onCreateTask = (taskGroupID: string, name: string) => {
if (data) { if (data) {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID); const taskGroup = data.findProject.taskGroups.find((t) => t.id === taskGroupID);
if (taskGroup) { if (taskGroup) {
let position = 65535; let position = 65535;
if (taskGroup.tasks.length !== 0) { if (taskGroup.tasks.length !== 0) {
@ -426,7 +429,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
createTask: { createTask: {
__typename: 'Task', __typename: 'Task',
id: `${Math.round(Math.random() * -1000000)}`, id: `${Math.round(Math.random() * -1000000)}`,
shortId: '',
name, name,
watched: false,
complete: false, complete: false,
completedAt: null, completedAt: null,
hasTime: false, hasTime: false,
@ -441,7 +446,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
checklist: null, checklist: null,
}, },
position, position,
dueDate: null, dueDate: { at: null },
description: null, description: null,
labels: [], labels: [],
assigned: [], assigned: [],
@ -472,12 +477,13 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
} }
return 'All Tasks'; return 'All Tasks';
}; };
if (data) { if (data) {
labelsRef.current = data.findProject.labels; labelsRef.current = data.findProject.labels;
membersRef.current = data.findProject.members; membersRef.current = data.findProject.members;
const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => { const onQuickEditorOpen = ($target: React.RefObject<HTMLElement>, taskID: string, taskGroupID: string) => {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID); const taskGroup = data.findProject.taskGroups.find((t) => t.id === taskGroupID);
const currentTask = taskGroup ? taskGroup.tasks.find(t => t.id === taskID) : null; const currentTask = taskGroup ? taskGroup.tasks.find((t) => t.id === taskID) : null;
if (currentTask) { if (currentTask) {
setQuickCardEditor({ setQuickCardEditor({
target: $target, target: $target,
@ -489,9 +495,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
}; };
let currentQuickTask = null; let currentQuickTask = null;
if (quickCardEditor.taskID && quickCardEditor.taskGroupID) { if (quickCardEditor.taskID && quickCardEditor.taskGroupID) {
const targetGroup = data.findProject.taskGroups.find(t => t.id === quickCardEditor.taskGroupID); const targetGroup = data.findProject.taskGroups.find((t) => t.id === quickCardEditor.taskGroupID);
if (targetGroup) { if (targetGroup) {
currentQuickTask = targetGroup.tasks.find(t => t.id === quickCardEditor.taskID); currentQuickTask = targetGroup.tasks.find((t) => t.id === quickCardEditor.taskID);
} }
} }
return ( return (
@ -499,13 +505,13 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<ProjectBar> <ProjectBar>
<ProjectActions> <ProjectActions>
<ProjectAction <ProjectAction
onClick={target => { onClick={(target) => {
showPopup( showPopup(
target, target,
<Popup tab={0} title={null}> <Popup tab={0} title={null}>
<FilterStatus <ControlStatus
filter={taskStatusFilter} filter={taskStatusFilter}
onChangeTaskStatusFilter={filter => { onChangeTaskStatusFilter={(filter) => {
setTaskStatusFilter(filter); setTaskStatusFilter(filter);
hidePopup(); hidePopup();
}} }}
@ -519,13 +525,13 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<ProjectActionText>{getTaskStatusFilterLabel(taskStatusFilter)}</ProjectActionText> <ProjectActionText>{getTaskStatusFilterLabel(taskStatusFilter)}</ProjectActionText>
</ProjectAction> </ProjectAction>
<ProjectAction <ProjectAction
onClick={target => { onClick={(target) => {
showPopup( showPopup(
target, target,
<Popup tab={0} title={null}> <Popup tab={0} title={null}>
<SortPopup <ControlSort
sorting={taskSorting} sorting={taskSorting}
onChangeTaskSorting={sorting => { onChangeTaskSorting={(sorting) => {
setTaskSorting(sorting); setTaskSorting(sorting);
}} }}
/> />
@ -538,16 +544,16 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<ProjectActionText>{renderTaskSortingLabel(taskSorting)}</ProjectActionText> <ProjectActionText>{renderTaskSortingLabel(taskSorting)}</ProjectActionText>
</ProjectAction> </ProjectAction>
<ProjectAction <ProjectAction
onClick={target => { onClick={(target) => {
showPopup( showPopup(
target, target,
<FilterMeta <ControlFilter
filters={taskMetaFilters} filters={taskMetaFilters}
onChangeTaskMetaFilter={filter => { onChangeTaskMetaFilter={(filter) => {
setTaskMetaFilters(filter); setTaskMetaFilters(filter);
}} }}
userID={user ?? ''} userID={user ?? ''}
labels={labelsRef} projectID={projectID}
members={membersRef} members={membersRef}
/>, />,
{ width: 200 }, { width: 200 },
@ -559,11 +565,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
</ProjectAction> </ProjectAction>
{renderMetaFilters(taskMetaFilters, (meta, id) => { {renderMetaFilters(taskMetaFilters, (meta, id) => {
setTaskMetaFilters( setTaskMetaFilters(
produce(taskMetaFilters, draftFilters => { produce(taskMetaFilters, (draftFilters) => {
if (meta === TaskMeta.MEMBER) { if (meta === TaskMeta.MEMBER) {
draftFilters.members = draftFilters.members.filter(m => m.id !== id); draftFilters.members = draftFilters.members.filter((m) => m.id !== id);
} else if (meta === TaskMeta.LABEL) { } else if (meta === TaskMeta.LABEL) {
draftFilters.labels = draftFilters.labels.filter(m => m.id !== id); draftFilters.labels = draftFilters.labels.filter((m) => m.id !== id);
} else if (meta === TaskMeta.TITLE) { } else if (meta === TaskMeta.TITLE) {
draftFilters.taskName = null; draftFilters.taskName = null;
} else if (meta === TaskMeta.DUE_DATE) { } else if (meta === TaskMeta.DUE_DATE) {
@ -576,15 +582,10 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
{user && ( {user && (
<ProjectActions> <ProjectActions>
<ProjectAction <ProjectAction
onClick={$labelsRef => { onClick={($labelsRef) => {
showPopup( showPopup(
$labelsRef, $labelsRef,
<LabelManagerEditor <LabelManagerEditor taskLabels={null} labelColors={data.labelColors} projectID={projectID ?? ''} />,
taskLabels={null}
labelColors={data.labelColors}
labels={labelsRef}
projectID={projectID ?? ''}
/>,
); );
}} }}
> >
@ -604,8 +605,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
</ProjectBar> </ProjectBar>
<SimpleLists <SimpleLists
isPublic={user === null} isPublic={user === null}
onTaskClick={task => { onTaskClick={(task) => {
history.push(`${match.url}/c/${task.id}`); history.push(`${match.url}/c/${task.shortId}`);
}} }}
onCardLabelClick={onCardLabelClick ?? NOOP} onCardLabelClick={onCardLabelClick ?? NOOP}
cardLabelVariant={cardLabelVariant ?? 'large'} cardLabelVariant={cardLabelVariant ?? 'large'}
@ -637,7 +638,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
}, },
}); });
}} }}
onTaskGroupDrop={droppedTaskGroup => { onTaskGroupDrop={(droppedTaskGroup) => {
updateTaskGroupLocation({ updateTaskGroupLocation({
variables: { taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position }, variables: { taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position },
optimisticResponse: { optimisticResponse: {
@ -657,7 +658,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onCreateTask={onCreateTask} onCreateTask={onCreateTask}
onCreateTaskGroup={onCreateList} onCreateTaskGroup={onCreateList}
onCardMemberClick={($targetRef, _taskID, memberID) => { onCardMemberClick={($targetRef, _taskID, memberID) => {
const member = data.findProject.members.find(m => m.id === memberID); const member = data.findProject.members.find((m) => m.id === memberID);
if (member) { if (member) {
showPopup( showPopup(
$targetRef, $targetRef,
@ -684,8 +685,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
deleteTaskGroupTasks({ variables: { taskGroupID } }); deleteTaskGroupTasks({ variables: { taskGroupID } });
hidePopup(); hidePopup();
}} }}
onSortTaskGroup={taskSort => { onSortTaskGroup={(taskSort) => {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID); const taskGroup = data.findProject.taskGroups.find((t) => t.id === taskGroupID);
if (taskGroup) { if (taskGroup) {
const tasks: Array<{ taskID: string; position: number }> = taskGroup.tasks const tasks: Array<{ taskID: string; position: number }> = taskGroup.tasks
.sort((a, b) => sortTasks(a, b, taskSort)) .sort((a, b) => sortTasks(a, b, taskSort))
@ -697,8 +698,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
hidePopup(); hidePopup();
} }
}} }}
onDuplicateTaskGroup={newName => { onDuplicateTaskGroup={(newName) => {
const idx = data.findProject.taskGroups.findIndex(t => t.id === taskGroupID); const idx = data.findProject.taskGroups.findIndex((t) => t.id === taskGroupID);
if (idx !== -1) { if (idx !== -1) {
const taskGroups = data.findProject.taskGroups.sort((a, b) => a.position - b.position); const taskGroups = data.findProject.taskGroups.sort((a, b) => a.position - b.position);
const prevPos = taskGroups[idx].position; const prevPos = taskGroups[idx].position;
@ -711,7 +712,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
hidePopup(); hidePopup();
} }
}} }}
onArchiveTaskGroup={tgID => { onArchiveTaskGroup={(tgID) => {
deleteTaskGroup({ variables: { taskGroupID: tgID } }); deleteTaskGroup({ variables: { taskGroupID: tgID } });
hidePopup(); hidePopup();
}} }}
@ -745,7 +746,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
); );
}} }}
onCardMemberClick={($targetRef, _taskID, memberID) => { onCardMemberClick={($targetRef, _taskID, memberID) => {
const member = data.findProject.members.find(m => m.id === memberID); const member = data.findProject.members.find((m) => m.id === memberID);
if (member) { if (member) {
showPopup( showPopup(
$targetRef, $targetRef,
@ -764,12 +765,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
showPopup( showPopup(
$targetRef, $targetRef,
<LabelManagerEditor <LabelManagerEditor
onLabelToggle={labelID => { onLabelToggle={(labelID) => {
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } }); toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
}} }}
taskID={task.id} taskID={task.id}
labelColors={data.labelColors} labelColors={data.labelColors}
labels={labelsRef}
taskLabels={taskLabelsRef} taskLabels={taskLabelsRef}
projectID={projectID ?? ''} projectID={projectID ?? ''}
/>, />,
@ -778,15 +778,15 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onArchiveCard={(_listId: string, cardId: string) => { onArchiveCard={(_listId: string, cardId: string) => {
return deleteTask({ return deleteTask({
variables: { taskID: cardId }, variables: { taskID: cardId },
update: client => { update: (client) => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
client, client,
FindProjectDocument, FindProjectDocument,
cache => (cache) =>
produce(cache, draftCache => { produce(cache, (draftCache) => {
draftCache.findProject.taskGroups = cache.findProject.taskGroups.map(taskGroup => ({ draftCache.findProject.taskGroups = cache.findProject.taskGroups.map((taskGroup) => ({
...taskGroup, ...taskGroup,
tasks: taskGroup.tasks.filter(t => t.id !== cardId), tasks: taskGroup.tasks.filter((t) => t.id !== cardId),
})); }));
}), }),
{ projectID }, { projectID },
@ -800,20 +800,38 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
<Popup title="Change Due Date" tab={0} onClose={() => hidePopup()}> <Popup title="Change Due Date" tab={0} onClose={() => hidePopup()}>
<DueDateManager <DueDateManager
task={task} task={task}
onRemoveDueDate={t => { onRemoveDueDate={(t) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } }); hidePopup();
// hidePopup(); updateTaskDueDate({
variables: {
taskID: t.id,
dueDate: null,
hasTime: false,
deleteNotifications: [],
updateNotifications: [],
createNotifications: [],
},
});
}} }}
onDueDateChange={(t, newDueDate, hasTime) => { 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} onCancel={NOOP}
/> />
</Popup>, </Popup>,
); );
}} }}
onToggleComplete={task => { onToggleComplete={(task) => {
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } }); setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
}} }}
target={quickCardEditor.target} target={quickCardEditor.target}

View File

@ -7,10 +7,12 @@ import MemberManager from 'shared/components/MemberManager';
import { useRouteMatch, useHistory, useParams } from 'react-router'; import { useRouteMatch, useHistory, useParams } from 'react-router';
import { import {
useDeleteTaskChecklistMutation, useDeleteTaskChecklistMutation,
useToggleTaskWatchMutation,
useUpdateTaskChecklistNameMutation, useUpdateTaskChecklistNameMutation,
useUpdateTaskChecklistItemLocationMutation, useUpdateTaskChecklistItemLocationMutation,
useCreateTaskChecklistMutation, useCreateTaskChecklistMutation,
useFindTaskQuery, useFindTaskQuery,
DueDateNotificationDuration,
useUpdateTaskDueDateMutation, useUpdateTaskDueDateMutation,
useSetTaskCompleteMutation, useSetTaskCompleteMutation,
useAssignTaskMutation, useAssignTaskMutation,
@ -216,6 +218,7 @@ const Details: React.FC<DetailsProps> = ({
); );
}, },
}); });
const [toggleTaskWatch] = useToggleTaskWatchMutation();
const [createTaskComment] = useCreateTaskCommentMutation({ const [createTaskComment] = useCreateTaskCommentMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<FindTaskQuery>( updateApolloCache<FindTaskQuery>(
@ -440,6 +443,19 @@ const Details: React.FC<DetailsProps> = ({
); );
}} }}
task={data.findTask} task={data.findTask}
onToggleTaskWatch={(task, watched) => {
toggleTaskWatch({
variables: { taskID: task.id },
optimisticResponse: {
__typename: 'Mutation',
toggleTaskWatch: {
id: task.id,
__typename: 'Task',
watched,
},
},
});
}}
onCreateComment={(task, message) => { onCreateComment={(task, message) => {
createTaskComment({ variables: { taskID: task.id, message } }); createTaskComment({ variables: { taskID: task.id, message } });
}} }}
@ -540,7 +556,8 @@ const Details: React.FC<DetailsProps> = ({
bio="None" bio="None"
onRemoveFromTask={() => { onRemoveFromTask={() => {
if (user) { if (user) {
unassignTask({ variables: { taskID: data.findTask.id, userID: user ?? '' } }); unassignTask({ variables: { taskID: data.findTask.id, userID: member.id ?? '' } });
hidePopup();
} }
}} }}
/> />
@ -631,12 +648,79 @@ const Details: React.FC<DetailsProps> = ({
<DueDateManager <DueDateManager
task={task} task={task}
onRemoveDueDate={(t) => { onRemoveDueDate={(t) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null, hasTime: false } }); updateTaskDueDate({
// hidePopup(); 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) => { onDueDateChange={(t, newDueDate, hasTime, notifications) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate, hasTime } }); const updatedNotifications = notifications.current
// hidePopup(); .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} onCancel={NOOP}
/> />

View File

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

View File

@ -31,11 +31,11 @@ import produce from 'immer';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import useStateWithLocalStorage from 'shared/hooks/useStateWithLocalStorage'; import useStateWithLocalStorage from 'shared/hooks/useStateWithLocalStorage';
import localStorage from 'shared/utils/localStorage'; import localStorage from 'shared/utils/localStorage';
import polling from 'shared/utils/polling';
import Board, { BoardLoading } from './Board'; import Board, { BoardLoading } from './Board';
import Details from './Details'; import Details from './Details';
import LabelManagerEditor from './LabelManagerEditor'; import LabelManagerEditor from './LabelManagerEditor';
import UserManagementPopup from './UserManagementPopup'; import UserManagementPopup from './UserManagementPopup';
import polling from 'shared/utils/polling';
type TaskRouteProps = { type TaskRouteProps = {
taskID: string; taskID: string;
@ -87,7 +87,7 @@ const Project = () => {
} }
} }
}), }),
{ projectID }, { projectID: data ? data.findProject.id : '' },
), ),
}); });
@ -100,7 +100,7 @@ const Project = () => {
produce(cache, (draftCache) => { produce(cache, (draftCache) => {
draftCache.findProject.name = newName.data?.updateProjectName.name ?? ''; draftCache.findProject.name = newName.data?.updateProjectName.name ?? '';
}), }),
{ projectID }, { projectID: data ? data.findProject.id : '' },
); );
}, },
}); });
@ -123,7 +123,7 @@ const Project = () => {
]; ];
} }
}), }),
{ projectID }, { projectID: data ? data.findProject.id : '' },
); );
}, },
}); });
@ -138,7 +138,7 @@ const Project = () => {
(m) => m.email !== response.data?.deleteInvitedProjectMember.invitedMember.email ?? '', (m) => m.email !== response.data?.deleteInvitedProjectMember.invitedMember.email ?? '',
); );
}), }),
{ projectID }, { projectID: data ? data.findProject.id : '' },
); );
}, },
}); });
@ -153,7 +153,7 @@ const Project = () => {
(m) => m.id !== response.data?.deleteProjectMember.member.id, (m) => m.id !== response.data?.deleteProjectMember.member.id,
); );
}), }),
{ projectID }, { projectID: data ? data.findProject.id : '' },
); );
}, },
}); });
@ -171,29 +171,29 @@ const Project = () => {
<> <>
<GlobalTopNavbar <GlobalTopNavbar
onChangeRole={(userID, roleCode) => { onChangeRole={(userID, roleCode) => {
updateProjectMemberRole({ variables: { userID, roleCode, projectID } }); updateProjectMemberRole({ variables: { userID, roleCode, projectID: data ? data.findProject.id : '' } });
}} }}
onChangeProjectOwner={() => { onChangeProjectOwner={() => {
hidePopup(); hidePopup();
}} }}
onRemoveFromBoard={(userID) => { onRemoveFromBoard={(userID) => {
deleteProjectMember({ variables: { userID, projectID } }); deleteProjectMember({ variables: { userID, projectID: data ? data.findProject.id : '' } });
hidePopup(); hidePopup();
}} }}
onRemoveInvitedFromBoard={(email) => { onRemoveInvitedFromBoard={(email) => {
deleteInvitedProjectMember({ variables: { projectID, email } }); deleteInvitedProjectMember({ variables: { projectID: data ? data.findProject.id : '', email } });
hidePopup(); hidePopup();
}} }}
onSaveProjectName={(projectName) => { onSaveProjectName={(projectName) => {
updateProjectName({ variables: { projectID, name: projectName } }); updateProjectName({ variables: { projectID: data ? data.findProject.id : '', name: projectName } });
}} }}
onInviteUser={($target) => { onInviteUser={($target) => {
showPopup( showPopup(
$target, $target,
<UserManagementPopup <UserManagementPopup
projectID={projectID} projectID={data ? data.findProject.id : ''}
onInviteProjectMembers={(members) => { onInviteProjectMembers={(members) => {
inviteProjectMembers({ variables: { projectID, members } }); inviteProjectMembers({ variables: { projectID: data ? data.findProject.id : '', members } });
hidePopup(); hidePopup();
}} }}
users={data.users} users={data.users}
@ -269,7 +269,6 @@ const Project = () => {
}} }}
taskID={task.id} taskID={task.id}
labelColors={data.labelColors} labelColors={data.labelColors}
labels={labelsRef}
taskLabels={taskLabelsRef} taskLabels={taskLabelsRef}
projectID={projectID} projectID={projectID}
/>, />,

View File

@ -9,6 +9,7 @@ import {
GetProjectsDocument, GetProjectsDocument,
GetProjectsQuery, GetProjectsQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import FormInput from 'shared/components/FormInput';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import NewProject from 'shared/components/NewProject'; import NewProject from 'shared/components/NewProject';
@ -52,7 +53,7 @@ const CreateTeamForm: React.FC<CreateTeamFormProps> = ({ onCreateTeam }) => {
return ( return (
<CreateTeamFormContainer onSubmit={handleSubmit(createTeam)}> <CreateTeamFormContainer onSubmit={handleSubmit(createTeam)}>
{errors.name && <ErrorText>{errors.name.message}</ErrorText>} {errors.name && <ErrorText>{errors.name.message}</ErrorText>}
<ControlledInput width="100%" label="Team name" variant="alternate" {...register('name')} /> <FormInput width="100%" label="Team name" variant="alternate" {...register('name')} />
<CreateTeamButton type="submit">Create</CreateTeamButton> <CreateTeamButton type="submit">Create</CreateTeamButton>
</CreateTeamFormContainer> </CreateTeamFormContainer>
); );
@ -306,7 +307,7 @@ const Projects = () => {
<ProjectList> <ProjectList>
{personalProjects.map((project, idx) => ( {personalProjects.map((project, idx) => (
<ProjectListItem key={project.id}> <ProjectListItem key={project.id}>
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}> <ProjectTile color={colors[idx % 5]} to={`/p/${project.shortId}`}>
<ProjectTileFade /> <ProjectTileFade />
<ProjectTileDetails> <ProjectTileDetails>
<ProjectTileName>{project.name}</ProjectTileName> <ProjectTileName>{project.name}</ProjectTileName>
@ -350,7 +351,7 @@ const Projects = () => {
<ProjectList> <ProjectList>
{team.projects.map((project, idx) => ( {team.projects.map((project, idx) => (
<ProjectListItem key={project.id}> <ProjectListItem key={project.id}>
<ProjectTile color={colors[idx % 5]} to={`/projects/${project.id}`}> <ProjectTile color={colors[idx % 5]} to={`/p/${project.shortId}`}>
<ProjectTileFade /> <ProjectTileFade />
<ProjectTileDetails> <ProjectTileDetails>
<ProjectTileName>{project.name}</ProjectTileName> <ProjectTileName>{project.name}</ProjectTileName>

View File

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

View File

@ -524,7 +524,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
members={data.findTeam.members} members={data.findTeam.members}
warning={member.role && member.role.code === 'owner' ? warning : null} warning={member.role && member.role.code === 'owner' ? warning : null}
// canChangeRole={user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)} TODO: add permission check // canChangeRole={user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)} TODO: add permission check
canChangeRole={true} canChangeRole
onChangeRole={(roleCode) => { onChangeRole={(roleCode) => {
updateTeamMemberRole({ variables: { userID: member.id, teamID, roleCode } }); updateTeamMemberRole({ variables: { userID: member.id, teamID, roleCode } });
}} }}

View File

@ -10,10 +10,26 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween'; import isBetween from 'dayjs/plugin/isBetween';
import weekday from 'dayjs/plugin/weekday'; import weekday from 'dayjs/plugin/weekday';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import log from 'loglevel';
import remote from 'loglevel-plugin-remote';
import cache from './App/cache'; import cache from './App/cache';
import App from './App'; import App from './App';
// 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(); enableMapSet();
@ -22,6 +38,8 @@ dayjs.extend(weekday);
dayjs.extend(isBetween); dayjs.extend(isBetween);
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
dayjs.extend(updateLocale); dayjs.extend(updateLocale);
dayjs.extend(duration);
dayjs.extend(relativeTime);
dayjs.updateLocale('en', { dayjs.updateLocale('en', {
week: { week: {
dow: 1, // First day of week is Monday dow: 1, // First day of week is Monday
@ -30,7 +48,6 @@ dayjs.updateLocale('en', {
}); });
const client = new ApolloClient({ uri: '/graphql', cache }); const client = new ApolloClient({ uri: '/graphql', cache });
console.log('cloient', client);
ReactDOM.render( ReactDOM.render(
<ApolloProvider client={client}> <ApolloProvider client={client}>

View File

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

View File

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

View File

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

View File

@ -23,6 +23,8 @@ import {
CardTitle, CardTitle,
CardMembers, CardMembers,
CardTitleText, CardTitleText,
CommentsIcon,
CommentsBadge,
} from './Styles'; } from './Styles';
type DueDate = { type DueDate = {
@ -47,6 +49,7 @@ type Props = {
dueDate?: DueDate; dueDate?: DueDate;
checklists?: Checklist | null; checklists?: Checklist | null;
labels?: Array<ProjectLabel>; labels?: Array<ProjectLabel>;
comments?: { unread: boolean; total: number } | null;
watched?: boolean; watched?: boolean;
wrapperProps?: any; wrapperProps?: any;
members?: Array<TaskUser> | null; members?: Array<TaskUser> | null;
@ -72,6 +75,7 @@ const Card = React.forwardRef(
taskGroupID, taskGroupID,
complete, complete,
toggleLabels = false, toggleLabels = false,
comments,
toggleDirection = 'shrink', toggleDirection = 'shrink',
setToggleLabels, setToggleLabels,
onClick, onClick,
@ -138,7 +142,7 @@ const Card = React.forwardRef(
onMouseEnter={() => setActive(true)} onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)} onMouseLeave={() => setActive(false)}
ref={$cardRef} ref={$cardRef}
onClick={e => { onClick={(e) => {
if (onClick) { if (onClick) {
onClick(e); onClick(e);
} }
@ -151,7 +155,7 @@ const Card = React.forwardRef(
<ListCardInnerContainer ref={$innerCardRef}> <ListCardInnerContainer ref={$innerCardRef}>
{!isPublic && isActive && !editable && ( {!isPublic && isActive && !editable && (
<ListCardOperation <ListCardOperation
onClick={e => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onContextMenu) { if (onContextMenu) {
onContextMenu($innerCardRef, taskID, taskGroupID); onContextMenu($innerCardRef, taskID, taskGroupID);
@ -167,7 +171,7 @@ const Card = React.forwardRef(
<ListCardLabels <ListCardLabels
toggleLabels={toggleLabels} toggleLabels={toggleLabels}
toggleDirection={toggleDirection} toggleDirection={toggleDirection}
onClick={e => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onCardLabelClick) { if (onCardLabelClick) {
onCardLabelClick(); onCardLabelClick();
@ -177,7 +181,7 @@ const Card = React.forwardRef(
{labels {labels
.slice() .slice()
.sort((a, b) => a.labelColor.position - b.labelColor.position) .sort((a, b) => a.labelColor.position - b.labelColor.position)
.map(label => ( .map((label) => (
<ListCardLabel <ListCardLabel
onAnimationEnd={() => { onAnimationEnd={() => {
if (setToggleLabels) { if (setToggleLabels) {
@ -198,13 +202,13 @@ const Card = React.forwardRef(
<EditorContent> <EditorContent>
{complete && <CompleteIcon width={16} height={16} />} {complete && <CompleteIcon width={16} height={16} />}
<EditorTextarea <EditorTextarea
onChange={e => { onChange={(e) => {
setCardTitle(e.currentTarget.value); setCardTitle(e.currentTarget.value);
if (onCardTitleChange) { if (onCardTitleChange) {
onCardTitleChange(e.currentTarget.value); onCardTitleChange(e.currentTarget.value);
} }
}} }}
onClick={e => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@ -221,7 +225,7 @@ const Card = React.forwardRef(
<ListCardBadges> <ListCardBadges>
{watched && ( {watched && (
<ListCardBadge> <ListCardBadge>
<Eye width={8} height={8} /> <Eye width={12} height={12} />
</ListCardBadge> </ListCardBadge>
)} )}
{dueDate && ( {dueDate && (
@ -235,6 +239,12 @@ const Card = React.forwardRef(
<List width={8} height={8} /> <List width={8} height={8} />
</DescriptionBadge> </DescriptionBadge>
)} )}
{comments && (
<CommentsBadge>
<CommentsIcon color={comments.unread ? 'success' : 'normal'} width={8} height={8} />
<ListCardBadgeText color={comments.unread ? 'success' : 'normal'}>{comments.total}</ListCardBadgeText>
</CommentsBadge>
)}
{checklists && ( {checklists && (
<ListCardBadge> <ListCardBadge>
<ChecklistIcon <ChecklistIcon
@ -256,7 +266,7 @@ const Card = React.forwardRef(
size={28} size={28}
zIndex={members.length - idx} zIndex={members.length - idx}
member={member} member={member}
onMemberProfile={$target => { onMemberProfile={($target) => {
if (onCardMemberClick) { if (onCardMemberClick) {
onCardMemberClick($target, taskID, member.id); onCardMemberClick($target, taskID, member.id);
} }

View File

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

View File

@ -1,9 +1,8 @@
import styled from 'styled-components'; import styled, { css } from 'styled-components';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
import Input from 'shared/components/Input';
import ControlledInput from 'shared/components/ControlledInput'; import ControlledInput from 'shared/components/ControlledInput';
import { Clock } from 'shared/icons'; import { Bell, Clock } from 'shared/icons';
export const Wrapper = styled.div` export const Wrapper = styled.div`
display: flex display: flex
@ -22,27 +21,27 @@ display: flex
& .react-datepicker__close-icon::after { & .react-datepicker__close-icon::after {
background: none; background: none;
font-size: 16px; font-size: 16px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
} }
& .react-datepicker-time__header { & .react-datepicker-time__header {
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
} }
& .react-datepicker__time-list-item { & .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-container .react-datepicker__time
.react-datepicker__time-box ul.react-datepicker__time-list .react-datepicker__time-box ul.react-datepicker__time-list
li.react-datepicker__time-list-item:hover { li.react-datepicker__time-list-item:hover {
color: ${props => props.theme.colors.text.secondary}; color: ${(props) => props.theme.colors.text.secondary};
background: ${props => props.theme.colors.bg.secondary}; background: ${(props) => props.theme.colors.bg.secondary};
} }
& .react-datepicker__time-container .react-datepicker__time { & .react-datepicker__time-container .react-datepicker__time {
background: ${props => props.theme.colors.bg.primary}; background: ${(props) => props.theme.colors.bg.primary};
} }
& .react-datepicker--time-only { & .react-datepicker--time-only {
background: ${props => props.theme.colors.bg.primary}; background: ${(props) => props.theme.colors.bg.primary};
border: 1px solid ${props => props.theme.colors.border}; border: 1px solid ${(props) => props.theme.colors.border};
} }
& .react-datepicker * { & .react-datepicker * {
@ -82,12 +81,12 @@ display: flex
} }
& .react-datepicker__day--selected { & .react-datepicker__day--selected {
border-radius: 50%; border-radius: 50%;
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
color: #fff; color: #fff;
} }
& .react-datepicker__day--selected:hover { & .react-datepicker__day--selected:hover {
border-radius: 50%; border-radius: 50%;
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
color: #fff; color: #fff;
} }
& .react-datepicker__header { & .react-datepicker__header {
@ -95,12 +94,12 @@ display: flex
border: none; border: none;
} }
& .react-datepicker__header--time { & .react-datepicker__header--time {
border-bottom: 1px solid ${props => props.theme.colors.border}; border-bottom: 1px solid ${(props) => props.theme.colors.border};
} }
& .react-datepicker__input-container input { & .react-datepicker__input-container input {
border: 1px solid rgba(0, 0, 0, 0.2); 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; background: #262c49;
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15); box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.15);
padding: 0.7rem; padding: 0.7rem;
@ -114,7 +113,7 @@ padding: 0.7rem;
&:focus { &:focus {
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15); box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
border: 1px solid rgba(115, 103, 240); border: 1px solid rgba(115, 103, 240);
background: ${props => props.theme.colors.bg.primary}; background: ${(props) => props.theme.colors.bg.primary};
} }
`; `;
@ -142,9 +141,9 @@ export const AddDateRange = styled.div`
width: 100%; width: 100%;
font-size: 12px; font-size: 12px;
line-height: 16px; line-height: 16px;
color: ${props => mixin.rgba(props.theme.colors.primary, 0.8)}; color: ${(props) => mixin.rgba(props.theme.colors.primary, 0.8)};
&:hover { &:hover {
color: ${props => mixin.rgba(props.theme.colors.primary, 1)}; color: ${(props) => mixin.rgba(props.theme.colors.primary, 1)};
text-decoration: underline; text-decoration: underline;
} }
`; `;
@ -201,18 +200,62 @@ export const ActionsWrapper = styled.div`
align-items: center; align-items: center;
& .react-datepicker-wrapper { & .react-datepicker-wrapper {
margin-left: auto; margin-left: auto;
width: 82px; width: 86px;
} }
& .react-datepicker__input-container input { & .react-datepicker__input-container input {
padding-bottom: 4px; padding-bottom: 4px;
padding-top: 4px; padding-top: 4px;
width: 100%; 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)` export const ActionClock = styled(Clock)`
align-self: center; 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; margin: 0 8px;
flex: 0 0 auto; flex: 0 0 auto;
`; `;
@ -222,7 +265,7 @@ export const ActionLabel = styled.div`
line-height: 14px; line-height: 14px;
`; `;
export const ActionIcon = styled.div` export const ActionIcon = styled.div<{ disabled?: boolean }>`
height: 36px; height: 36px;
min-height: 36px; min-height: 36px;
min-width: 36px; min-width: 36px;
@ -232,17 +275,25 @@ export const ActionIcon = styled.div`
cursor: pointer; cursor: pointer;
margin-right: 8px; margin-right: 8px;
svg { svg {
fill: ${props => props.theme.colors.text.primary}; fill: ${(props) => props.theme.colors.text.primary};
transition-duration: 0.2s; transition-duration: 0.2s;
transition-property: background, border, box-shadow, fill; transition-property: background, border, box-shadow, fill;
} }
&:hover svg { &: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; align-items: center;
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
position: relative;
`; `;
export const ClearButton = styled.div` export const ClearButton = styled.div`
@ -260,8 +311,38 @@ export const ClearButton = styled.div`
justify-content: center; justify-content: center;
transition-duration: 0.2s; transition-duration: 0.2s;
transition-property: background, border, box-shadow, color, fill; transition-property: background, border, box-shadow, color, fill;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
&:hover { &: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 styled from 'styled-components';
import DatePicker from 'react-datepicker'; import DatePicker from 'react-datepicker';
import _ from 'lodash'; import _ from 'lodash';
import { colourStyles } from 'shared/components/Select';
import produce from 'immer';
import Select from 'react-select';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { getYear, getMonth } from 'date-fns'; import { getYear, getMonth } from 'date-fns';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import { Bell, Clock, Cross, Plus, Trash } from 'shared/icons';
import { import {
Wrapper, Wrapper,
RemoveDueDate, RemoveDueDate,
SaveButton,
RightWrapper,
LeftWrapper,
DueDateInput, DueDateInput,
DueDatePickerWrapper, DueDatePickerWrapper,
ConfirmAddDueDate, ConfirmAddDueDate,
@ -22,13 +29,19 @@ import {
ActionsSeparator, ActionsSeparator,
ActionClock, ActionClock,
ActionLabel, ActionLabel,
ControlWrapper,
RemoveButton,
ActionBell,
} from './Styles'; } from './Styles';
import { Clock, Cross } from 'shared/icons';
import Select from 'react-select/src/Select';
type DueDateManagerProps = { type DueDateManagerProps = {
task: Task; task: Task;
onDueDateChange: (task: Task, newDueDate: Date, hasTime: boolean) => void; onDueDateChange: (
task: Task,
newDueDate: Date,
hasTime: boolean,
notifications: { current: Array<NotificationInternal>; removed: Array<string> },
) => void;
onRemoveDueDate: (task: Task) => void; onRemoveDueDate: (task: Task) => void;
onCancel: () => void; onCancel: () => void;
}; };
@ -41,6 +54,39 @@ const FormField = styled.div`
width: 50%; width: 50%;
display: inline-block; display: inline-block;
`; `;
const NotificationCount = styled.input``;
const ActionPlus = styled(Plus)`
position: absolute;
fill: ${(props) => props.theme.colors.bg.primary} !important;
stroke: ${(props) => props.theme.colors.bg.primary};
`;
const ActionInput = styled.input`
border: 1px solid rgba(0, 0, 0, 0.2);
margin-left: auto;
margin-right: 4px;
border-color: rgb(65, 69, 97);
background: #262c49;
box-shadow: 0 0 0 0 rgb(0 0 0 / 15%);
color: #c2c6dc;
position: relative;
border-radius: 5px;
transition: all 0.3s ease;
font-size: 13px;
line-height: 20px;
padding: 0 12px;
padding-bottom: 4px;
padding-top: 4px;
width: 100%;
max-width: 48px;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
`;
const HeaderSelectLabel = styled.div` const HeaderSelectLabel = styled.div`
display: inline-block; display: inline-block;
position: relative; position: relative;
@ -131,8 +177,69 @@ const HeaderActions = styled.div`
} }
`; `;
const notificationPeriodOptions = [
{ value: 'minute', label: 'Minutes' },
{ value: 'hour', label: 'Hours' },
{ value: 'day', label: 'Days' },
{ value: 'week', label: 'Weeks' },
];
type NotificationInternal = {
internalId: string;
externalId: string | null;
period: number;
duration: { value: string; label: string };
};
type NotificationEntryProps = {
notification: NotificationInternal;
onChange: (period: number, duration: { value: string; label: string }) => void;
onRemove: () => void;
};
const NotificationEntry: React.FC<NotificationEntryProps> = ({ notification, onChange, onRemove }) => {
return (
<>
<ActionBell width={16} height={16} />
<ActionLabel>Notification</ActionLabel>
<ActionInput
value={notification.period}
onChange={(e) => {
onChange(parseInt(e.currentTarget.value, 10), notification.duration);
}}
onKeyPress={(e) => {
const isNumber = /^[0-9]$/i.test(e.key);
if (!isNumber && e.key !== 'Backspace') {
e.preventDefault();
}
}}
dir="ltr"
autoComplete="off"
min="0"
type="number"
/>
<Select
menuPlacement="top"
className="react-period"
classNamePrefix="react-period-select"
styles={colourStyles}
isSearchable={false}
defaultValue={notification.duration}
options={notificationPeriodOptions}
onChange={(e) => {
if (e !== null) {
onChange(notification.period, e);
}
}}
/>
<ActionIcon onClick={() => onRemove()}>
<Cross width={16} height={16} />
</ActionIcon>
</>
);
};
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => { const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onRemoveDueDate, onCancel }) => {
const currentDueDate = task.dueDate ? dayjs(task.dueDate).toDate() : null; const currentDueDate = task.dueDate.at ? dayjs(task.dueDate.at).toDate() : null;
const { const {
register, register,
handleSubmit, handleSubmit,
@ -145,28 +252,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
const [startDate, setStartDate] = useState<Date | null>(currentDueDate); const [startDate, setStartDate] = useState<Date | null>(currentDueDate);
const [endDate, setEndDate] = useState<Date | null>(currentDueDate); const [endDate, setEndDate] = useState<Date | null>(currentDueDate);
const [hasTime, enableTime] = useState(task.hasTime ?? false); 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 years = _.range(2010, getYear(new Date()) + 10, 1);
const months = [ const months = [
'January', 'January',
@ -183,48 +269,41 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
'December', 'December',
]; ];
const onChange = (dates: any) => {
const [start, end] = dates;
setStartDate(start);
setEndDate(end);
};
const [isRange, setIsRange] = useState(false); const [isRange, setIsRange] = useState(false);
const [notDuration, setNotDuration] = useState(10);
const CustomTimeInput = forwardRef(({ value, onClick, onChange, onBlur, onFocus }: any, $ref: any) => { const [removedNotifications, setRemovedNotifications] = useState<Array<string>>([]);
return ( const [notifications, setNotifications] = useState<Array<NotificationInternal>>(
<DueDateInput task.dueDate.notifications
id="endTime" ? task.dueDate.notifications.map((c, idx) => {
value={value} const duration =
name="endTime" notificationPeriodOptions.find((o) => o.value === c.duration.toLowerCase()) ?? notificationPeriodOptions[0];
onChange={onChange} return {
width="100%" internalId: `n${idx}`,
variant="alternate" externalId: c.id,
label="Time" period: c.period,
onClick={onClick} duration,
/> };
); })
}); : [],
);
return ( return (
<Wrapper> <Wrapper>
<DateRangeInputs> <DateRangeInputs>
<DatePicker <DatePicker
selected={startDate} selected={startDate}
onChange={(date) => { onChange={(date) => {
if (!Array.isArray(date)) { if (!Array.isArray(date) && date !== null) {
setStartDate(date); setStartDate(date);
} }
}} }}
popperClassName="picker-hidden" popperClassName="picker-hidden"
dateFormat="yyyy-MM-dd" dateFormat="yyyy-MM-dd"
disabledKeyboardNavigation disabledKeyboardNavigation
isClearable
placeholderText="Select due date" placeholderText="Select due date"
/> />
{isRange ? ( {isRange ? (
<DatePicker <DatePicker
selected={startDate} selected={startDate}
isClearable
onChange={(date) => { onChange={(date) => {
if (!Array.isArray(date)) { if (!Array.isArray(date)) {
setStartDate(date); setStartDate(date);
@ -314,23 +393,91 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
</ActionIcon> </ActionIcon>
</ActionsWrapper> </ActionsWrapper>
)} )}
<ActionsWrapper> {notifications.map((n, idx) => (
{!hasTime && ( <ActionsWrapper key={n.internalId}>
<ActionIcon <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={() => { onClick={() => {
if (startDate === null) { if (startDate && notifications.findIndex((n) => Number.isNaN(n.period)) === -1) {
const today = new Date(); onDueDateChange(task, startDate, hasTime, { current: notifications, removed: removedNotifications });
today.setHours(12, 30, 0);
setStartDate(today);
} }
enableTime(true);
}} }}
> >
<Clock width={16} height={16} /> 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> </ActionIcon>
)} {!hasTime && (
<ClearButton onClick={() => setStartDate(null)}>{hasTime ? 'Clear all' : 'Clear'}</ClearButton> <ActionIcon
</ActionsWrapper> onClick={() => {
if (startDate === null) {
const today = new Date();
today.setHours(12, 30, 0);
setStartDate(today);
}
enableTime(true);
}}
>
<Clock width={16} height={16} />
</ActionIcon>
)}
</RightWrapper>
</ControlWrapper>
</Wrapper> </Wrapper>
); );
}; };

View File

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

@ -100,7 +100,7 @@ const List = React.forwardRef(
/> />
{!isPublic && ( {!isPublic && (
<ListExtraMenuButtonWrapper ref={$extraActionsRef} onClick={handleExtraMenuOpen}> <ListExtraMenuButtonWrapper ref={$extraActionsRef} onClick={handleExtraMenuOpen}>
<Ellipsis size={16} color="#c2c6dc" /> <Ellipsis vertical={false} size={16} color="#c2c6dc" />
</ListExtraMenuButtonWrapper> </ListExtraMenuButtonWrapper>
)} )}
</Header> </Header>

View File

@ -4,6 +4,7 @@ import List, { ListCards } from 'shared/components/List';
import Card from 'shared/components/Card'; import Card from 'shared/components/Card';
import CardComposer from 'shared/components/CardComposer'; import CardComposer from 'shared/components/CardComposer';
import AddList from 'shared/components/AddList'; import AddList from 'shared/components/AddList';
import log from 'loglevel';
import { import {
isPositionChanged, isPositionChanged,
getSortedDraggables, getSortedDraggables,
@ -111,24 +112,16 @@ function shouldStatusFilter(task: Task, filter: TaskStatusFilter) {
const TODAY = REFERENCE.clone().startOf('day'); const TODAY = REFERENCE.clone().startOf('day');
return completedAt.isSame(TODAY, 'd'); return completedAt.isSame(TODAY, 'd');
case TaskSince.YESTERDAY: case TaskSince.YESTERDAY:
const YESTERDAY = REFERENCE.clone() const YESTERDAY = REFERENCE.clone().subtract(1, 'day').startOf('day');
.subtract(1, 'day')
.startOf('day');
return completedAt.isSameOrAfter(YESTERDAY, 'd'); return completedAt.isSameOrAfter(YESTERDAY, 'd');
case TaskSince.ONE_WEEK: case TaskSince.ONE_WEEK:
const ONE_WEEK = REFERENCE.clone() const ONE_WEEK = REFERENCE.clone().subtract(7, 'day').startOf('day');
.subtract(7, 'day')
.startOf('day');
return completedAt.isSameOrAfter(ONE_WEEK, 'd'); return completedAt.isSameOrAfter(ONE_WEEK, 'd');
case TaskSince.TWO_WEEKS: case TaskSince.TWO_WEEKS:
const TWO_WEEKS = REFERENCE.clone() const TWO_WEEKS = REFERENCE.clone().subtract(14, 'day').startOf('day');
.subtract(14, 'day')
.startOf('day');
return completedAt.isSameOrAfter(TWO_WEEKS, 'd'); return completedAt.isSameOrAfter(TWO_WEEKS, 'd');
case TaskSince.THREE_WEEKS: case TaskSince.THREE_WEEKS:
const THREE_WEEKS = REFERENCE.clone() const THREE_WEEKS = REFERENCE.clone().subtract(21, 'day').startOf('day');
.subtract(21, 'day')
.startOf('day');
return completedAt.isSameOrAfter(THREE_WEEKS, 'd'); return completedAt.isSameOrAfter(THREE_WEEKS, 'd');
default: default:
return true; return true;
@ -203,14 +196,14 @@ const SimpleLists: React.FC<SimpleProps> = ({
let beforeDropDraggables: Array<DraggableElement> | null = null; let beforeDropDraggables: Array<DraggableElement> | null = null;
if (isList) { if (isList) {
const droppedGroup = taskGroups.find(taskGroup => taskGroup.id === draggableId); const droppedGroup = taskGroups.find((taskGroup) => taskGroup.id === draggableId);
if (droppedGroup) { if (droppedGroup) {
droppedDraggable = { droppedDraggable = {
id: draggableId, id: draggableId,
position: droppedGroup.position, position: droppedGroup.position,
}; };
beforeDropDraggables = getSortedDraggables( beforeDropDraggables = getSortedDraggables(
taskGroups.map(taskGroup => { taskGroups.map((taskGroup) => {
return { id: taskGroup.id, position: taskGroup.position }; return { id: taskGroup.id, position: taskGroup.position };
}), }),
); );
@ -234,13 +227,13 @@ const SimpleLists: React.FC<SimpleProps> = ({
} }
} else { } else {
const curTaskGroup = taskGroups.findIndex( 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; let targetTaskGroup = curTaskGroup;
if (!isSameList) { 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) { if (droppedTask) {
droppedDraggable = { droppedDraggable = {
@ -248,7 +241,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
position: droppedTask.position, position: droppedTask.position,
}; };
beforeDropDraggables = getSortedDraggables( beforeDropDraggables = getSortedDraggables(
taskGroups[targetTaskGroup].tasks.map(task => { taskGroups[targetTaskGroup].tasks.map((task) => {
return { id: task.id, position: task.position }; return { id: task.id, position: task.position };
}), }),
); );
@ -270,6 +263,9 @@ const SimpleLists: React.FC<SimpleProps> = ({
id: destination.droppableId, 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); onTaskDrop(newTask, droppedTask.taskGroup.id);
} }
} }
@ -286,7 +282,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
<BoardWrapper> <BoardWrapper>
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<Droppable direction="horizontal" type="column" droppableId="root"> <Droppable direction="horizontal" type="column" droppableId="root">
{provided => ( {(provided) => (
<Container {...provided.droppableProps} ref={provided.innerRef}> <Container {...provided.droppableProps} ref={provided.innerRef}>
{taskGroups {taskGroups
.slice() .slice()
@ -294,14 +290,14 @@ const SimpleLists: React.FC<SimpleProps> = ({
.map((taskGroup: TaskGroup, index: number) => { .map((taskGroup: TaskGroup, index: number) => {
return ( return (
<Draggable draggableId={taskGroup.id} key={taskGroup.id} index={index}> <Draggable draggableId={taskGroup.id} key={taskGroup.id} index={index}>
{columnDragProvided => ( {(columnDragProvided) => (
<Droppable type="tasks" droppableId={taskGroup.id}> <Droppable type="tasks" droppableId={taskGroup.id}>
{(columnDropProvided, snapshot) => ( {(columnDropProvided, snapshot) => (
<List <List
name={taskGroup.name} name={taskGroup.name}
onOpenComposer={id => setCurrentComposer(id)} onOpenComposer={(id) => setCurrentComposer(id)}
isComposerOpen={currentComposer === taskGroup.id} isComposerOpen={currentComposer === taskGroup.id}
onSaveName={name => onChangeTaskGroupName(taskGroup.id, name)} onSaveName={(name) => onChangeTaskGroupName(taskGroup.id, name)}
isPublic={isPublic} isPublic={isPublic}
ref={columnDragProvided.innerRef} ref={columnDragProvided.innerRef}
wrapperProps={columnDragProvided.draggableProps} wrapperProps={columnDragProvided.draggableProps}
@ -314,8 +310,8 @@ const SimpleLists: React.FC<SimpleProps> = ({
<ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}> <ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}>
{taskGroup.tasks {taskGroup.tasks
.slice() .slice()
.filter(t => shouldStatusFilter(t, taskStatusFilter)) .filter((t) => shouldStatusFilter(t, taskStatusFilter))
.filter(t => shouldMetaFilter(t, taskMetaFilters)) .filter((t) => shouldMetaFilter(t, taskMetaFilters))
.sort((a: any, b: any) => a.position - b.position) .sort((a: any, b: any) => a.position - b.position)
.sort((a: any, b: any) => sortTasks(a, b, taskSorting)) .sort((a: any, b: any) => sortTasks(a, b, taskSorting))
.map((task: Task, taskIndex: any) => { .map((task: Task, taskIndex: any) => {
@ -326,13 +322,14 @@ const SimpleLists: React.FC<SimpleProps> = ({
index={taskIndex} index={taskIndex}
isDragDisabled={taskSorting.type !== TaskSortingType.NONE} isDragDisabled={taskSorting.type !== TaskSortingType.NONE}
> >
{taskProvided => { {(taskProvided) => {
return ( return (
<Card <Card
toggleDirection={toggleDirection} toggleDirection={toggleDirection}
toggleLabels={toggleLabels} toggleLabels={toggleLabels}
isPublic={isPublic} isPublic={isPublic}
labelVariant={cardLabelVariant} labelVariant={cardLabelVariant}
watched={task.watched}
wrapperProps={{ wrapperProps={{
...taskProvided.draggableProps, ...taskProvided.draggableProps,
...taskProvided.dragHandleProps, ...taskProvided.dragHandleProps,
@ -352,12 +349,12 @@ const SimpleLists: React.FC<SimpleProps> = ({
complete={task.complete ?? false} complete={task.complete ?? false}
taskGroupID={taskGroup.id} taskGroupID={taskGroup.id}
description="" description=""
labels={task.labels.map(label => label.projectLabel)} labels={task.labels.map((label) => label.projectLabel)}
dueDate={ dueDate={
task.dueDate task.dueDate.at
? { ? {
isPastDue: false, isPastDue: false,
formattedDate: dayjs(task.dueDate).format('MMM D, YYYY'), formattedDate: dayjs(task.dueDate.at).format('MMM D, YYYY'),
} }
: undefined : undefined
} }
@ -367,6 +364,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
onTaskClick(task); onTaskClick(task);
}} }}
checklists={task.badges && task.badges.checklist} checklists={task.badges && task.badges.checklist}
comments={task.badges && task.badges.comments}
onCardMemberClick={onCardMemberClick} onCardMemberClick={onCardMemberClick}
onContextMenu={onQuickEditorOpen} onContextMenu={onQuickEditorOpen}
/> />
@ -381,7 +379,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
onClose={() => { onClose={() => {
setCurrentComposer(''); setCurrentComposer('');
}} }}
onCreateCard={name => { onCreateCard={(name) => {
onCreateTask(taskGroup.id, name); onCreateTask(taskGroup.id, name);
}} }}
isOpen isOpen
@ -402,7 +400,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
</DragDropContext> </DragDropContext>
{!isPublic && ( {!isPublic && (
<AddList <AddList
onSave={listName => { onSave={(listName) => {
onCreateTaskGroup(listName); onCreateTaskGroup(listName);
}} }}
/> />

View File

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

View File

@ -1,6 +1,7 @@
import styled from 'styled-components'; import styled from 'styled-components';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
import AccessAccount from 'shared/undraw/AccessAccount';
export const Wrapper = styled.div` export const Wrapper = styled.div`
background: #eff2f7; background: #eff2f7;
@ -15,6 +16,12 @@ export const Column = styled.div`
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@media (max-width: 600px) {
svg {
display: none;
}
position: absolute;
}
`; `;
export const LoginFormWrapper = styled.div` export const LoginFormWrapper = styled.div`
@ -25,18 +32,47 @@ export const LoginFormWrapper = styled.div`
export const LoginFormContainer = styled.div` export const LoginFormContainer = styled.div`
min-height: 505px; min-height: 505px;
padding: 2rem; 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` export const Title = styled.h1`
color: #ebeefd; color: #ebeefd;
font-size: 18px; font-size: 18px;
margin-bottom: 14px; margin-bottom: 14px;
@media (max-width: 600px) {
font-size: 38px;
margin-top: 50px;
text-align: center;
}
`; `;
export const SubTitle = styled.h2` export const SubTitle = styled.h2`
color: #c2c6dc; color: #c2c6dc;
font-size: 14px; font-size: 14px;
margin-bottom: 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` export const Form = styled.form`
display: flex; display: flex;
@ -48,6 +84,10 @@ export const FormLabel = styled.label`
font-size: 12px; font-size: 12px;
position: relative; position: relative;
margin-top: 14px; margin-top: 14px;
@media (max-width: 600px) {
font-size: 35px;
font-weight: bold;
}
`; `;
export const FormTextInput = styled.input` export const FormTextInput = styled.input`
@ -59,28 +99,92 @@ export const FormTextInput = styled.input`
font-size: 1rem; font-size: 1rem;
color: #c2c6dc; color: #c2c6dc;
border-radius: 5px; 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` export const FormIcon = styled.div`
top: 30px; top: 30px;
left: 16px; left: 16px;
position: absolute; position: absolute;
@media (max-width: 600px) {
svg {
width: 40px;
height: 40px;
display: inline;
position: absolute;
top: 30px;
left: -5px;
}
}
`; `;
export const FormError = styled.span` export const FormError = styled.span`
font-size: 0.875rem; 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` export const ActionButtons = styled.div`
margin-top: 17.5px; margin-top: 17.5px;
display: flex; display: flex;
justify-content: space-between; 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` export const LogoTitle = styled.div`
font-size: 24px; font-size: 24px;
@ -88,6 +192,9 @@ export const LogoTitle = styled.div`
margin-left: 12px; margin-left: 12px;
transition: visibility, opacity, transform 0.25s ease; transition: visibility, opacity, transform 0.25s ease;
color: #7367f0; color: #7367f0;
@media (max-width: 600px) {
font-size: 60px;
}
`; `;
export const LogoWrapper = styled.div` export const LogoWrapper = styled.div`
@ -100,5 +207,16 @@ export const LogoWrapper = styled.div`
padding-bottom: 16px; padding-bottom: 16px;
margin-bottom: 24px; margin-bottom: 24px;
color: rgb(222, 235, 255); 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 AccessAccount from 'shared/undraw/AccessAccount';
import { User, Lock, Taskcafe } from 'shared/icons'; import { User, Lock, Taskcafe } from 'shared/icons';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useHistory } from 'react-router';
import LoadingSpinner from 'shared/components/LoadingSpinner'; import LoadingSpinner from 'shared/components/LoadingSpinner';
import { import {
Form, Form,
@ -25,6 +25,7 @@ import {
const Login = ({ onSubmit }: LoginProps) => { const Login = ({ onSubmit }: LoginProps) => {
const [isComplete, setComplete] = useState(true); const [isComplete, setComplete] = useState(true);
const [showRegistration, setShowRegistration] = useState(false);
const { const {
register, register,
handleSubmit, handleSubmit,
@ -35,6 +36,17 @@ const Login = ({ onSubmit }: LoginProps) => {
setComplete(false); setComplete(false);
onSubmit(data, setComplete, setError); 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 ( return (
<Wrapper> <Wrapper>
<Column> <Column>
@ -52,7 +64,11 @@ const Login = ({ onSubmit }: LoginProps) => {
<Form onSubmit={handleSubmit(loginSubmit)}> <Form onSubmit={handleSubmit(loginSubmit)}>
<FormLabel htmlFor="username"> <FormLabel htmlFor="username">
Username Username
<FormTextInput type="text" {...register('username', { required: 'Username is required' })} /> <FormTextInput
placeholder="Username"
type="text"
{...register('username', { required: 'Username is required' })}
/>
<FormIcon> <FormIcon>
<User width={20} height={20} /> <User width={20} height={20} />
</FormIcon> </FormIcon>
@ -60,7 +76,11 @@ const Login = ({ onSubmit }: LoginProps) => {
{errors.username && <FormError>{errors.username.message}</FormError>} {errors.username && <FormError>{errors.username.message}</FormError>}
<FormLabel htmlFor="password"> <FormLabel htmlFor="password">
Password Password
<FormTextInput type="password" {...register('password', { required: 'Password is required' })} /> <FormTextInput
placeholder="Password"
type="password"
{...register('password', { required: 'Password is required' })}
/>
<FormIcon> <FormIcon>
<Lock width={20} height={20} /> <Lock width={20} height={20} />
</FormIcon> </FormIcon>
@ -68,7 +88,7 @@ const Login = ({ onSubmit }: LoginProps) => {
{errors.password && <FormError>{errors.password.message}</FormError>} {errors.password && <FormError>{errors.password.message}</FormError>}
<ActionButtons> <ActionButtons>
<RegisterButton variant="outline">Register</RegisterButton> {showRegistration ? <RegisterButton variant="outline">Register</RegisterButton> : <div />}
{!isComplete && <LoadingSpinner size="32px" thickness="2px" borderSize="48px" />} {!isComplete && <LoadingSpinner size="32px" thickness="2px" borderSize="48px" />}
<LoginButton type="submit" disabled={!isComplete}> <LoginButton type="submit" disabled={!isComplete}>
Login Login

View File

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

View File

@ -1,8 +1,37 @@
import React from 'react'; import React, { useRef, useState } from 'react';
import styled from 'styled-components'; import styled, { css } from 'styled-components';
import TimeAgo from 'react-timeago'; 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` const ItemWrapper = styled.div`
cursor: pointer; cursor: pointer;
@ -37,7 +66,7 @@ const ItemTextContainer = styled.div`
const ItemTextTitle = styled.span` const ItemTextTitle = styled.span`
font-weight: 500; font-weight: 500;
display: block; display: block;
color: ${props => props.theme.colors.primary}; color: ${(props) => props.theme.colors.primary};
font-size: 14px; font-size: 14px;
`; `;
const ItemTextDesc = styled.span` const ItemTextDesc = styled.span`
@ -72,38 +101,578 @@ export const NotificationItem: React.FC<NotificationItemProps> = ({ title, descr
}; };
const NotificationHeader = styled.div` const NotificationHeader = styled.div`
padding: 0.75rem; padding: 20px 28px;
text-align: center; text-align: center;
border-top-left-radius: 6px; border-top-left-radius: 6px;
border-top-right-radius: 6px; border-top-right-radius: 6px;
background: ${props => props.theme.colors.primary}; background: ${(props) => props.theme.colors.primary};
`; `;
const NotificationHeaderTitle = styled.span` const NotificationHeaderTitle = styled.span`
font-size: 14px; 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` const NotificationFooter = styled.div`
cursor: pointer; cursor: pointer;
padding: 0.5rem; padding: 0.5rem;
text-align: center; text-align: center;
color: ${props => props.theme.colors.primary}; color: ${(props) => props.theme.colors.primary};
&:hover { &:hover {
background: ${props => props.theme.colors.bg.primary}; background: ${(props) => props.theme.colors.bg.primary};
} }
border-bottom-left-radius: 6px; border-bottom-left-radius: 6px;
border-bottom-right-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 ( return (
<Popup title={null} tab={0} borders={false} padding={false}> <Popup title={null} tab={0} borders={false} padding={false}>
<NotificationHeader> <PopupContent>
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle> <NotificationHeader>
</NotificationHeader> <NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
<ul>{children}</ul> <NotificationHeaderMenu>
<NotificationFooter>View All</NotificationFooter> <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>
<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> </Popup>
); );
}; };

View File

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

View File

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

View File

@ -311,7 +311,7 @@ const ResetPasswordTab: React.FC<ResetPasswordTabProps> = ({ onResetPassword })
}; };
type UserInfoData = { type UserInfoData = {
full_name: string; fullName: string;
bio: string; bio: string;
initials: string; initials: string;
email: string; email: string;
@ -355,12 +355,12 @@ const UserInfoTab: React.FC<UserInfoTabProps> = ({
})} })}
> >
<UserInfoInput <UserInfoInput
{...register('full_name', { required: 'Full name is required' })} {...register('fullName', { required: 'Full name is required' })}
defaultValue={profile.fullName} defaultValue={profile.fullName}
width="100%" width="100%"
label="Name" label="Name"
/> />
{errors.full_name && <FormError>{errors.full_name.message}</FormError>} {errors.fullName && <FormError>{errors.fullName.message}</FormError>}
<UserInfoInput <UserInfoInput
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''} defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
{...register('initials', { {...register('initials', {

View File

@ -9,13 +9,21 @@ type ActivityMessageProps = {
}; };
function getVariable(data: Array<TaskActivityData>, name: string) { 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; 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 (timestamp) {
return dayjs(timestamp).format('MMM D [at] h:mm A'); if (hasTime) {
return dayjs(timestamp).format('MMM D [at] h:mm A');
}
return dayjs(timestamp).format('MMM D');
} }
return null; 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')}`; message = `moved this task from ${getVariable(data, 'PrevTaskGroup')} to ${getVariable(data, 'CurTaskGroup')}`;
break; break;
case ActivityType.TaskDueDateAdded: 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; break;
case ActivityType.TaskDueDateRemoved: case ActivityType.TaskDueDateRemoved:
message = `removed the due date from this task`; message = `removed the due date from this task`;
break; break;
case ActivityType.TaskDueDateChanged: 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; break;
case ActivityType.TaskMarkedComplete: case ActivityType.TaskMarkedComplete:
message = `marked this task complete`; message = `marked this task complete`;

View File

@ -13,6 +13,7 @@ import {
Smile, Smile,
} from 'shared/icons'; } from 'shared/icons';
import { toArray } from 'react-emoji-render'; import { toArray } from 'react-emoji-render';
import { useCurrentUser } from 'App/context';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import TaskAssignee from 'shared/components/TaskAssignee'; import TaskAssignee from 'shared/components/TaskAssignee';
import useOnOutsideClick from 'shared/hooks/onOutsideClick'; import useOnOutsideClick from 'shared/hooks/onOutsideClick';
@ -80,11 +81,8 @@ import {
ActivityItemHeaderTitleName, ActivityItemHeaderTitleName,
ActivityItemComment, ActivityItemComment,
} from './Styles'; } from './Styles';
import { useCurrentUser } from 'App/context';
type TaskDetailsProps = {}; const TaskDetailsLoading: React.FC = () => {
const TaskDetailsLoading: React.FC<TaskDetailsProps> = () => {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
return ( return (
<Container> <Container>

View File

@ -4,6 +4,7 @@ import { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import TaskAssignee from 'shared/components/TaskAssignee'; import TaskAssignee from 'shared/components/TaskAssignee';
import theme from 'App/ThemeStyles'; import theme from 'App/ThemeStyles';
import { Checkmark } from 'shared/icons';
export const Container = styled.div` export const Container = styled.div`
display: flex; display: flex;
@ -22,14 +23,14 @@ export const MarkCompleteButton = styled.button<{ invert: boolean; disabled?: bo
position: relative; position: relative;
border: none; border: none;
cursor: pointer; cursor: pointer;
border-radius: ${props => props.theme.borderRadius.alternate}; border-radius: ${(props) => props.theme.borderRadius.alternate};
display: flex; display: flex;
align-items: center; align-items: center;
background: transparent; background: transparent;
& span { & span {
margin-left: 4px; margin-left: 4px;
} }
${props => ${(props) =>
props.invert props.invert
? css` ? css`
background: ${props.theme.colors.success}; background: ${props.theme.colors.success};
@ -63,7 +64,7 @@ export const MarkCompleteButton = styled.button<{ invert: boolean; disabled?: bo
color: ${props.theme.colors.success}; color: ${props.theme.colors.success};
} }
`} `}
${props => ${(props) =>
props.invert && props.invert &&
css` css`
opacity: 0.6; opacity: 0.6;
@ -89,7 +90,7 @@ export const SidebarTitle = styled.div`
font-size: 12px; font-size: 12px;
min-height: 24px; min-height: 24px;
margin-left: 8px; 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; padding-top: 4px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-transform: uppercase; text-transform: uppercase;
@ -110,12 +111,12 @@ export const skeletonKeyframes = keyframes`
export const SidebarButton = styled.div<{ $loading?: boolean }>` export const SidebarButton = styled.div<{ $loading?: boolean }>`
font-size: 14px; font-size: 14px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
min-height: 32px; min-height: 32px;
width: 100%; width: 100%;
border-radius: 6px; border-radius: 6px;
${props => ${(props) =>
props.$loading props.$loading
? css` ? css`
background: ${props.theme.colors.bg.primary}; background: ${props.theme.colors.bg.primary};
@ -183,7 +184,7 @@ export const TaskDetailsTitleWrapper = styled.div<{ $loading?: boolean }>`
margin: 8px 0 4px 0; margin: 8px 0 4px 0;
display: flex; display: flex;
border-radius: 6px; 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 }>`
@ -201,7 +202,7 @@ export const TaskDetailsTitle = styled(TextareaAutosize)<{ $loading?: boolean }>
&:disabled { &:disabled {
opacity: 1; opacity: 1;
} }
${props => ${(props) =>
props.$loading props.$loading
? css` ? css`
background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor}); background-image: linear-gradient(90deg, ${defaultBaseColor}, ${defaultHighlightColor}, ${defaultBaseColor});
@ -226,7 +227,7 @@ export const DueDateTitle = styled.div`
font-size: 12px; font-size: 12px;
min-height: 24px; min-height: 24px;
margin-left: 8px; 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; padding-top: 8px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-transform: uppercase; text-transform: uppercase;
@ -237,7 +238,7 @@ export const AssignedUsersSection = styled.div`
padding-right: 32px; padding-right: 32px;
padding-top: 24px; padding-top: 24px;
padding-bottom: 24px; padding-bottom: 24px;
border-bottom: 1px solid ${props => props.theme.colors.alternate}; border-bottom: 1px solid ${(props) => props.theme.colors.alternate};
display: flex; display: flex;
flex-direction: column; flex-direction: column;
`; `;
@ -255,10 +256,10 @@ export const AssignUserIcon = styled.div`
justify-content: center; justify-content: center;
align-items: center; align-items: center;
&:hover { &: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 { &:hover svg {
fill: ${props => mixin.rgba(props.theme.colors.text.secondary, 0.75)}; fill: ${(props) => mixin.rgba(props.theme.colors.text.secondary, 0.75)};
} }
`; `;
@ -273,17 +274,17 @@ export const AssignUsersButton = styled.div`
align-items: center; align-items: center;
border: 1px solid transparent; border: 1px solid transparent;
&:hover { &: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} { &:hover ${AssignUserIcon} {
border: 1px solid ${props => props.theme.colors.alternate}; border: 1px solid ${(props) => props.theme.colors.alternate};
} }
`; `;
export const AssignUserLabel = styled.span` export const AssignUserLabel = styled.span`
flex: 1 1 auto; flex: 1 1 auto;
line-height: 15px; 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` export const ExtraActionsSection = styled.div`
@ -295,7 +296,7 @@ export const ExtraActionsSection = styled.div`
`; `;
export const ActionButtonsTitle = styled.h3` export const ActionButtonsTitle = styled.h3`
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.04em; letter-spacing: 0.04em;
@ -305,16 +306,17 @@ export const ActionButton = styled(Button)`
margin-top: 8px; margin-top: 8px;
margin-left: -10px; margin-left: -10px;
padding: 8px 16px; 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; text-align: left;
transition: transform 0.2s ease; transition: transform 0.2s ease;
& span { & span {
position: unset;
justify-content: flex-start; justify-content: flex-start;
} }
&:hover { &:hover {
box-shadow: none; box-shadow: none;
transform: translateX(4px); 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)};
} }
`; `;
@ -333,10 +335,10 @@ export const HeaderActionIcon = styled.div`
cursor: pointer; cursor: pointer;
svg { 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 { &:hover svg {
fill: ${props => mixin.rgba(props.theme.colors.primary, 0.75)}); fill: ${(props) => mixin.rgba(props.theme.colors.primary, 0.75)});
} }
`; `;
@ -393,7 +395,7 @@ export const MetaDetail = styled.div`
`; `;
export const MetaDetailTitle = styled.h3` export const MetaDetailTitle = styled.h3`
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.04em; letter-spacing: 0.04em;
@ -412,7 +414,7 @@ export const MetaDetailContent = styled.div`
`; `;
export const TaskDetailsAddLabel = styled.div` export const TaskDetailsAddLabel = styled.div`
border-radius: 3px; 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; cursor: pointer;
&:hover { &:hover {
opacity: 0.8; opacity: 0.8;
@ -427,7 +429,7 @@ export const TaskDetailsAddLabelIcon = styled.div`
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 3px; 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; cursor: pointer;
&:hover { &:hover {
opacity: 0.8; opacity: 0.8;
@ -443,7 +445,7 @@ export const TaskDetailLabel = styled.div<{ color: string }>`
&:hover { &:hover {
opacity: 0.8; opacity: 0.8;
} }
background-color: ${props => props.color}; background-color: ${(props) => props.color};
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@ -496,17 +498,22 @@ export const TabBarSection = styled.div`
margin-top: 2px; margin-top: 2px;
padding-left: 23px; padding-left: 23px;
display: flex; display: flex;
justify-content: space-between;
text-transform: uppercase; text-transform: uppercase;
min-height: 35px; min-height: 35px;
border-bottom: 1px solid #414561; border-bottom: 1px solid #414561;
`; `;
export const TabBarItem = styled.div` 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; padding: 12px 7px 14px 7px;
margin-bottom: -1px; margin-bottom: -1px;
margin-right: 36px; 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` export const CommentContainer = styled.div`
@ -542,13 +549,13 @@ export const CommentTextArea = styled(TextareaAutosize)<{ $showCommentActions: b
line-height: 28px; line-height: 28px;
padding: 4px 6px; padding: 4px 6px;
border-radius: 6px; border-radius: 6px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
background: #1f243e; background: #1f243e;
border: none; border: none;
transition: max-height 200ms, height 200ms, min-height 200ms; transition: max-height 200ms, height 200ms, min-height 200ms;
min-height: 36px; min-height: 36px;
max-height: 36px; max-height: 36px;
${props => ${(props) =>
props.$showCommentActions props.$showCommentActions
? css` ? css`
min-height: 80px; min-height: 80px;
@ -561,7 +568,7 @@ export const CommentTextArea = styled(TextareaAutosize)<{ $showCommentActions: b
`; `;
export const CommentEditorActions = styled.div<{ visible: boolean }>` export const CommentEditorActions = styled.div<{ visible: boolean }>`
display: ${props => (props.visible ? 'flex' : 'none')}; display: ${(props) => (props.visible ? 'flex' : 'none')};
align-items: center; align-items: center;
padding: 5px 5px 5px 9px; padding: 5px 5px 5px 9px;
border-top: 1px solid #414561; border-top: 1px solid #414561;
@ -594,7 +601,7 @@ export const ActivityItemCommentAction = styled.div`
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
svg { svg {
fill: ${props => props.theme.colors.text.primary} !important; fill: ${(props) => props.theme.colors.text.primary} !important;
} }
`; `;
@ -614,7 +621,7 @@ export const ActivityItemHeader = styled.div<{ editable?: boolean }>`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-left: 8px; padding-left: 8px;
${props => props.editable && 'width: 100%;'} ${(props) => props.editable && 'width: 100%;'}
`; `;
export const ActivityItemHeaderUser = styled(TaskAssignee)` export const ActivityItemHeaderUser = styled(TaskAssignee)`
align-items: start; align-items: start;
@ -623,7 +630,7 @@ export const ActivityItemHeaderUser = styled(TaskAssignee)`
export const ActivityItemHeaderTitle = styled.div` export const ActivityItemHeaderTitle = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
padding-bottom: 2px; padding-bottom: 2px;
`; `;
@ -634,8 +641,8 @@ export const ActivityItemHeaderTitleName = styled.span`
export const ActivityItemTimestamp = styled.span<{ margin: number }>` export const ActivityItemTimestamp = styled.span<{ margin: number }>`
font-size: 12px; font-size: 12px;
color: ${props => mixin.rgba(props.theme.colors.text.primary, 0.65)}; color: ${(props) => mixin.rgba(props.theme.colors.text.primary, 0.65)};
margin-left: ${props => props.margin}px; margin-left: ${(props) => props.margin}px;
`; `;
export const ActivityItemDetails = styled.div` export const ActivityItemDetails = styled.div`
@ -649,11 +656,11 @@ export const ActivityItemComment = styled.div<{ editable: boolean }>`
border-radius: 3px; border-radius: 3px;
${mixin.boxShadowCard} ${mixin.boxShadowCard}
position: relative; position: relative;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
padding: 8px 12px; padding: 8px 12px;
margin: 4px 0; margin: 4px 0;
background-color: ${props => mixin.darken(props.theme.colors.alternate, 0.1)}; background-color: ${(props) => mixin.darken(props.theme.colors.alternate, 0.1)};
${props => props.editable && 'width: 100%;'} ${(props) => props.editable && 'width: 100%;'}
& span { & span {
display: inline-flex; display: inline-flex;
@ -683,7 +690,7 @@ export const ActivityItemCommentActions = styled.div`
export const ActivityItemLog = styled.span` export const ActivityItemLog = styled.span`
margin-left: 2px; margin-left: 2px;
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
`; `;
export const ViewRawButton = styled.button` export const ViewRawButton = styled.button`
@ -694,9 +701,9 @@ export const ViewRawButton = styled.button`
right: 4px; right: 4px;
bottom: -24px; bottom: -24px;
cursor: pointer; 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 { &:hover {
color: ${props => props.theme.colors.text.primary}; color: ${(props) => props.theme.colors.text.primary};
} }
`; `;
@ -712,3 +719,8 @@ export const TaskDetailsEditor = styled(TextareaAutosize)`
outline: none; outline: none;
border: 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 React, { useState, useRef } from 'react';
import { useCurrentUser } from 'App/context';
import { import {
Plus, Plus,
User, User,
@ -11,6 +12,7 @@ import {
CheckSquareOutline, CheckSquareOutline,
At, At,
Smile, Smile,
Eye,
} from 'shared/icons'; } from 'shared/icons';
import { toArray } from 'react-emoji-render'; import { toArray } from 'react-emoji-render';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
@ -78,12 +80,13 @@ import {
ActivityItemHeaderTitle, ActivityItemHeaderTitle,
ActivityItemHeaderTitleName, ActivityItemHeaderTitleName,
ActivityItemComment, ActivityItemComment,
TabBarButton,
WatchedCheckmark,
} from './Styles'; } from './Styles';
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist'; import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
import onDragEnd from './onDragEnd'; import onDragEnd from './onDragEnd';
import { plugin as em } from './remark'; import plugin from './remark';
import ActivityMessage from './ActivityMessage'; import ActivityMessage from './ActivityMessage';
import { useCurrentUser } from 'App/context';
const parseEmojis = (value: string) => { const parseEmojis = (value: string) => {
const emojisArray = toArray(value); const emojisArray = toArray(value);
@ -136,7 +139,7 @@ const StreamComment: React.FC<StreamCommentProps> = ({
onCreateComment={onUpdateComment} onCreateComment={onUpdateComment}
/> />
) : ( ) : (
<ReactMarkdown skipHtml plugins={[em]}> <ReactMarkdown skipHtml plugins={[plugin]}>
{DOMPurify.sanitize(comment.message, { FORBID_TAGS: ['style', 'img'] })} {DOMPurify.sanitize(comment.message, { FORBID_TAGS: ['style', 'img'] })}
</ReactMarkdown> </ReactMarkdown>
)} )}
@ -236,6 +239,7 @@ type TaskDetailsProps = {
onToggleChecklistItem: (itemID: string, complete: boolean) => void; onToggleChecklistItem: (itemID: string, complete: boolean) => void;
onOpenAddMemberPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void; onOpenAddMemberPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onOpenAddLabelPopup: (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; onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void; onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onCreateComment: (task: Task, message: string) => void; onCreateComment: (task: Task, message: string) => void;
@ -257,6 +261,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
task, task,
editableComment = null, editableComment = null,
onDeleteChecklist, onDeleteChecklist,
onToggleTaskWatch,
onTaskNameChange, onTaskNameChange,
onCommentShowActions, onCommentShowActions,
onOpenAddChecklistPopup, onOpenAddChecklistPopup,
@ -345,9 +350,9 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
} }
}} }}
> >
{task.dueDate ? ( {task.dueDate.at ? (
<SidebarButtonText> <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>
) : ( ) : (
<SidebarButtonText>No due date</SidebarButtonText> <SidebarButtonText>No due date</SidebarButtonText>
@ -417,6 +422,14 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
Checklist Checklist
</ActionButton> </ActionButton>
<ActionButton>Cover</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> </ExtraActionsSection>
)} )}
</LeftSidebarContent> </LeftSidebarContent>
@ -514,6 +527,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<Editor <Editor
defaultValue={task.description ?? ''} defaultValue={task.description ?? ''}
readOnly={user === null || !editTaskDescription} readOnly={user === null || !editTaskDescription}
theme={dark}
autoFocus autoFocus
onChange={(value) => { onChange={(value) => {
setSaveTimeout(() => { setSaveTimeout(() => {
@ -617,6 +631,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
{activityStream.map((stream) => {activityStream.map((stream) =>
stream.data.type === 'comment' ? ( stream.data.type === 'comment' ? (
<StreamComment <StreamComment
key={stream.id}
onExtraActions={onCommentShowActions} onExtraActions={onCommentShowActions}
onCancelCommentEdit={onCancelCommentEdit} onCancelCommentEdit={onCancelCommentEdit}
onUpdateComment={(message) => onUpdateComment(stream.id, message)} onUpdateComment={(message) => onUpdateComment(stream.id, message)}
@ -625,6 +640,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
/> />
) : ( ) : (
<StreamActivity <StreamActivity
key={stream.id}
activity={task.activity && task.activity.find((activity) => activity.id === stream.id)} activity={task.activity && task.activity.find((activity) => activity.id === stream.id)}
/> />
), ),

View File

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

View File

@ -1,8 +1,8 @@
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { Home, Star, Bell, AngleDown, BarChart, CheckCircle, ListUnordered } from 'shared/icons'; import { Home, Star, Bell, AngleDown, BarChart, CheckCircle, ListUnordered } from 'shared/icons';
import { Link } from 'react-router-dom';
import { RoleCode } from 'shared/generated/graphql'; import { RoleCode } from 'shared/generated/graphql';
import * as S from './Styles'; import * as S from './Styles';
import { Link } from 'react-router-dom';
export type MenuItem = { export type MenuItem = {
name: string; name: string;

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ query getProjects {
} }
projects { projects {
id id
shortId
name name
team { team {
id 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 }) { myTasks(input: { status: $status, sort: $sort }) {
tasks { tasks {
id id
shortId
taskGroup { taskGroup {
id id
name name
} }
name name
dueDate dueDate {
at
}
hasTime hasTime
complete complete
completedAt 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

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

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
export const TOP_NAVBAR_QUERY = gql`
query hasUnreadNotifications {
hasUnreadNotifications {
unread
}
}
`;
export default TOP_NAVBAR_QUERY;

View File

@ -1,4 +1,8 @@
mutation updateTaskDueDate($taskID: UUID!, $dueDate: Time, $hasTime: Boolean!) { mutation updateTaskDueDate($taskID: UUID!, $dueDate: Time, $hasTime: Boolean!,
$createNotifications: [CreateTaskDueDateNotification!]!,
$updateNotifications: [UpdateTaskDueDateNotification!]!
$deleteNotifications: [DeleteTaskDueDateNotification!]!
) {
updateTaskDueDate ( updateTaskDueDate (
input: { input: {
taskID: $taskID taskID: $taskID
@ -7,7 +11,26 @@ mutation updateTaskDueDate($taskID: UUID!, $dueDate: Time, $hasTime: Boolean!) {
} }
) { ) {
id id
dueDate dueDate {
at
}
hasTime hasTime
} }
createTaskDueDateNotifications(input: $createNotifications) {
notifications {
id
period
duration
}
}
updateTaskDueDateNotifications(input: $updateNotifications) {
notifications {
id
period
duration
}
}
deleteTaskDueDateNotifications(input: $deleteNotifications) {
notifications
}
} }

View File

@ -1,4 +1,13 @@
import React from 'react'; import React, { Dispatch, SetStateAction, useEffect, useState } from 'react';
// A wrapper for "JSON.parse()"" to support "undefined" value
function parseJSON<T>(value: string | null): T | undefined {
try {
return value === 'undefined' ? undefined : JSON.parse(value ?? '');
} catch (error) {
return undefined;
}
}
const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => { const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => {
const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || ''); const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || '');
@ -11,3 +20,78 @@ const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispa
}; };
export default useStateWithLocalStorage; export default useStateWithLocalStorage;
type SetValue<T> = Dispatch<SetStateAction<T>>;
function useLocalStorage<T>(key: string, initialValue: T): [T, SetValue<T>] {
// Get from local storage then
// parse stored json or return initialValue
const readValue = (): T => {
// Prevent build error "window is undefined" but keep keep working
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? (parseJSON(item) as T) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
return initialValue;
}
};
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(readValue);
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: SetValue<T> = (value) => {
// Prevent build error "window is undefined" but keeps working
if (typeof window === 'undefined') {
console.warn(`Tried setting localStorage key “${key}” even though environment is not a client`);
}
try {
// Allow value to be a function so we have the same API as useState
const newValue = value instanceof Function ? value(storedValue) : value;
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(newValue));
// Save state
setStoredValue(newValue);
// We dispatch a custom event so every useLocalStorage hook are notified
window.dispatchEvent(new Event('local-storage'));
} catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error);
}
};
useEffect(() => {
setStoredValue(readValue());
}, []);
useEffect(() => {
const handleStorageChange = () => {
setStoredValue(readValue());
};
// this only works for other documents, not the current one
window.addEventListener('storage', handleStorageChange);
// this is a custom event, triggered in writeValueToLocalStorage
window.addEventListener('local-storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('local-storage', handleStorageChange);
};
}, []);
return [storedValue, setValue];
}
export { useLocalStorage };

View File

@ -16,9 +16,4 @@ const AngleLeft = ({ size, color }: Props) => {
); );
}; };
AngleLeft.defaultProps = {
size: 16,
color: '#000',
};
export default AngleLeft; export default AngleLeft;

View File

@ -17,10 +17,4 @@ const ArrowLeft = ({ width, height, color }: Props) => {
); );
}; };
ArrowLeft.defaultProps = {
width: 16,
height: 16,
color: '#000',
};
export default ArrowLeft; export default ArrowLeft;

View File

@ -1,21 +1,12 @@
import React from 'react'; import React from 'react';
import Icon, { IconProps } from './Icon';
type Props = { const Bell: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
size: number | string;
color: string;
};
const Bell = ({ size, color }: Props) => {
return ( return (
<svg fill={color} xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 448 512"> <Icon width={width} height={height} className={className} viewBox="0 0 448 512">
<path d="M439.39 362.29c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71zM67.53 368c21.22-27.97 44.42-74.33 44.53-159.42 0-.2-.06-.38-.06-.58 0-61.86 50.14-112 112-112s112 50.14 112 112c0 .2-.06.38-.06.58.11 85.1 23.31 131.46 44.53 159.42H67.53zM224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64z" /> <path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71z" />
</svg> </Icon>
); );
}; };
Bell.defaultProps = {
size: 16,
color: '#000',
};
export default Bell; export default Bell;

View File

@ -14,9 +14,4 @@ const Bin = ({ size, color }: Props) => {
); );
}; };
Bin.defaultProps = {
size: 16,
color: '#000',
};
export default Bin; export default Bin;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Bubble: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 576 512">
<path d="M416 192c0-88.4-93.1-160-208-160S0 103.6 0 192c0 34.3 14.1 65.9 38 92-13.4 30.2-35.5 54.2-35.8 54.5-2.2 2.3-2.8 5.7-1.5 8.7S4.8 352 8 352c36.6 0 66.9-12.3 88.7-25 32.2 15.7 70.3 25 111.3 25 114.9 0 208-71.6 208-160zm122 220c23.9-26 38-57.7 38-92 0-66.9-53.5-124.2-129.3-148.1.9 6.6 1.3 13.3 1.3 20.1 0 105.9-107.7 192-240 192-10.8 0-21.3-.8-31.7-1.9C207.8 439.6 281.8 480 368 480c41 0 79.1-9.2 111.3-25 21.8 12.7 52.1 25 88.7 25 3.2 0 6.1-1.9 7.3-4.8 1.3-2.9.7-6.3-1.5-8.7-.3-.3-22.4-24.2-35.8-54.5z" />
</Icon>
);
};
export default Bubble;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Circle: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z" />
</Icon>
);
};
export default Circle;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const CircleSolid: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8z" />
</Icon>
);
};
export default CircleSolid;

View File

@ -16,9 +16,4 @@ const Cog = ({ size, color }: Props) => {
); );
}; };
Cog.defaultProps = {
size: 16,
color: '#000',
};
export default Cog; export default Cog;

View File

@ -21,10 +21,4 @@ const Ellipsis = ({ size, color, vertical }: Props) => {
); );
}; };
Ellipsis.defaultProps = {
size: 16,
color: '#000',
vertical: false,
};
export default Ellipsis; export default Ellipsis;

View File

@ -13,9 +13,4 @@ const Exit = ({ size, color }: Props) => {
); );
}; };
Exit.defaultProps = {
size: 16,
color: '#000',
};
export default Exit; export default Exit;

View File

@ -13,9 +13,4 @@ const Question = ({ size, color }: Props) => {
); );
}; };
Question.defaultProps = {
size: 16,
color: '#000',
};
export default Question; export default Question;

View File

@ -13,9 +13,4 @@ const Stack = ({ size, color }: Props) => {
); );
}; };
Stack.defaultProps = {
size: 16,
color: '#000',
};
export default Stack; export default Stack;

View File

@ -25,11 +25,4 @@ const Star = ({ width, height, color, filled }: Props) => {
); );
}; };
Star.defaultProps = {
width: 24,
height: 16,
color: '#000',
filled: false,
};
export default Star; export default Star;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const UserCircle: React.FC<IconProps> = ({ width = '16px', height = '16px', className, onClick }) => {
return (
<Icon onClick={onClick} width={width} height={height} className={className} viewBox="0 0 496 512">
<path d="M248 104c-53 0-96 43-96 96s43 96 96 96 96-43 96-96-43-96-96-96zm0 144c-26.5 0-48-21.5-48-48s21.5-48 48-48 48 21.5 48 48-21.5 48-48 48zm0-240C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-49.7 0-95.1-18.3-130.1-48.4 14.9-23 40.4-38.6 69.6-39.5 20.8 6.4 40.6 9.6 60.5 9.6s39.7-3.1 60.5-9.6c29.2 1 54.7 16.5 69.6 39.5-35 30.1-80.4 48.4-130.1 48.4zm162.7-84.1c-24.4-31.4-62.1-51.9-105.1-51.9-10.2 0-26 9.6-57.6 9.6-31.5 0-47.4-9.6-57.6-9.6-42.9 0-80.6 20.5-105.1 51.9C61.9 339.2 48 299.2 48 256c0-110.3 89.7-200 200-200s200 89.7 200 200c0 43.2-13.9 83.2-37.3 115.9z" />
</Icon>
);
};
export default UserCircle;

View File

@ -14,9 +14,4 @@ const Users = ({ size, color }: Props) => {
); );
}; };
Users.defaultProps = {
size: 16,
color: '#000',
};
export default Users; export default Users;

View File

@ -1,6 +1,10 @@
import Cross from './Cross'; import Cross from './Cross';
import Cog from './Cog'; import Cog from './Cog';
import Cogs from './Cogs'; import Cogs from './Cogs';
import Circle from './Circle';
import CircleSolid from './CircleSolid';
import UserCircle from './UserCircle';
import Bubble from './Bubble';
import ArrowDown from './ArrowDown'; import ArrowDown from './ArrowDown';
import CheckCircleOutline from './CheckCircleOutline'; import CheckCircleOutline from './CheckCircleOutline';
import Briefcase from './Briefcase'; import Briefcase from './Briefcase';
@ -110,5 +114,9 @@ export {
Briefcase, Briefcase,
DotCircle, DotCircle,
ChevronRight, ChevronRight,
Circle,
CircleSolid,
Bubble,
UserCircle,
Cogs, Cogs,
}; };

View File

@ -7,7 +7,7 @@ export function updateApolloCache<T>(
client: DataProxy, client: DataProxy,
document: DocumentNode, document: DocumentNode,
update: UpdateCacheFn<T>, update: UpdateCacheFn<T>,
variables?: object, variables?: any,
) { ) {
let queryArgs: DataProxy.Query<any, any>; let queryArgs: DataProxy.Query<any, any>;
if (variables) { if (variables) {

View File

@ -45,6 +45,56 @@ export const base = {
codeInserted: '#202746', codeInserted: '#202746',
codeImportant: '#c94922', codeImportant: '#c94922',
blockToolbarBackground: colors.bgPrimary,
blockToolbarTrigger: colors.primary,
blockToolbarTriggerIcon: colors.white,
blockToolbarItem: colors.white,
blockToolbarText: colors.white,
blockToolbarHoverBackground: colors.primary,
blockToolbarDivider: colors.almostWhite,
blockToolbarIcon: undefined,
blockToolbarIconSelected: colors.white,
blockToolbarTextSelected: colors.white,
blockToolbarSelectedBackground: colors.greyMid,
noticeInfoBackground: '#F5BE31',
noticeInfoText: colors.almostBlack,
noticeTipBackground: '#9E5CF7',
noticeTipText: colors.white,
noticeWarningBackground: '#FF5C80',
noticeWarningText: colors.white,
};
export const BASE_TWO = {
...colors,
fontFamily:
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen, Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif",
fontFamilyMono: "'SFMono-Regular',Consolas,'Liberation Mono', Menlo, Courier,monospace",
fontWeight: 400,
zIndex: 1000000,
link: colors.primary,
placeholder: '#B1BECC',
textSecondary: '#fff',
textLight: colors.white,
textHighlight: '#b3e7ff',
textHighlightForeground: colors.white,
selected: colors.primary,
codeComment: '#6a737d',
codePunctuation: '#5e6687',
codeNumber: '#d73a49',
codeProperty: '#c08b30',
codeTag: '#3d8fd1',
codeString: '#032f62',
codeSelector: '#6679cc',
codeAttr: '#c76b29',
codeEntity: '#22a2c9',
codeKeyword: '#d73a49',
codeFunction: '#6f42c1',
codeStatement: '#22a2c9',
codePlaceholder: '#3d8fd1',
codeInserted: '#202746',
codeImportant: '#c94922',
blockToolbarBackground: colors.bgPrimary, blockToolbarBackground: colors.bgPrimary,
blockToolbarTrigger: colors.white, blockToolbarTrigger: colors.white,
blockToolbarTriggerIcon: colors.white, blockToolbarTriggerIcon: colors.white,
@ -53,6 +103,10 @@ export const base = {
blockToolbarHoverBackground: colors.primary, blockToolbarHoverBackground: colors.primary,
blockToolbarDivider: colors.almostWhite, blockToolbarDivider: colors.almostWhite,
blockToolbarIcon: undefined,
blockToolbarIconSelected: colors.black,
blockToolbarTextSelected: colors.black,
noticeInfoBackground: '#F5BE31', noticeInfoBackground: '#F5BE31',
noticeInfoText: colors.almostBlack, noticeInfoText: colors.almostBlack,
noticeTipBackground: '#9E5CF7', noticeTipBackground: '#9E5CF7',

View File

@ -1,4 +1,5 @@
const localStorage = { const localStorage = {
NOTIFICATIONS_FILTER: 'notifications_filter',
CARD_LABEL_VARIANT_STORAGE_KEY: 'card_label_variant', CARD_LABEL_VARIANT_STORAGE_KEY: 'card_label_variant',
}; };

View File

@ -8,6 +8,7 @@ const polling = {
MEMBERS: resolve(3000), MEMBERS: resolve(3000),
TEAM_PROJECTS: resolve(3000), TEAM_PROJECTS: resolve(3000),
TASK_DETAILS: resolve(3000), TASK_DETAILS: resolve(3000),
UNREAD_NOTIFICATIONS: resolve(30000),
}; };
export default polling; export default polling;

View File

@ -46,7 +46,7 @@ export function sortTasks(a: Task, b: Task, taskSorting: TaskSorting) {
if (b.dueDate && !a.dueDate) { if (b.dueDate && !a.dueDate) {
return 1; return 1;
} }
return dayjs(a.dueDate).diff(dayjs(b.dueDate)); return dayjs(a.dueDate.at).diff(dayjs(b.dueDate.at));
} }
if (taskSorting.type === TaskSortingType.COMPLETE) { if (taskSorting.type === TaskSortingType.COMPLETE) {
if (a.complete && !b.complete) { if (a.complete && !b.complete) {

View File

@ -1,3 +1,5 @@
declare module 'loglevel-plugin-remote';
interface JWTToken { interface JWTToken {
userId: string; userId: string;
orgRole: string; orgRole: string;
@ -89,7 +91,7 @@ type ErrorOption =
type SetFailedFn = () => void; type SetFailedFn = () => void;
type ConfirmProps = { type ConfirmProps = {
hasConfirmToken: boolean; hasConfirmToken: boolean;
onConfirmUser: (setFailed: SetFailedFn) => void; hasFailed: boolean;
}; };
type RegisterProps = { type RegisterProps = {

View File

@ -59,8 +59,13 @@ type ChecklistBadge = {
total: number; total: number;
}; };
type CommentsBadge = {
total: number;
unread: boolean;
};
type TaskBadges = { type TaskBadges = {
checklist?: ChecklistBadge | null; checklist?: ChecklistBadge | null;
comments?: CommentsBadge | null;
}; };
type TaskActivityData = { type TaskActivityData = {
@ -98,12 +103,14 @@ type TaskComment = {
type Task = { type Task = {
id: string; id: string;
shortId: string;
taskGroup: InnerTaskGroup; taskGroup: InnerTaskGroup;
name: string; name: string;
watched?: boolean;
badges?: TaskBadges; badges?: TaskBadges;
position: number; position: number;
hasTime?: boolean; hasTime?: boolean;
dueDate?: string; dueDate: { at?: string; notifications?: Array<{ id: string; period: number; duration: string }> };
complete?: boolean; complete?: boolean;
completedAt?: string | null; completedAt?: string | null;
labels: TaskLabel[]; labels: TaskLabel[];

File diff suppressed because it is too large Load Diff

2
go.mod
View File

@ -8,6 +8,8 @@ require (
github.com/brianvoe/gofakeit/v5 v5.11.2 github.com/brianvoe/gofakeit/v5 v5.11.2
github.com/go-chi/chi v3.3.2+incompatible github.com/go-chi/chi v3.3.2+incompatible
github.com/go-chi/cors v1.2.0 github.com/go-chi/cors v1.2.0
github.com/go-redis/redis v6.15.8+incompatible
github.com/go-redis/redis/v8 v8.0.0-beta.6
github.com/golang-migrate/migrate/v4 v4.11.0 github.com/golang-migrate/migrate/v4 v4.11.0
github.com/google/uuid v1.1.1 github.com/google/uuid v1.1.1
github.com/jinzhu/now v1.1.1 github.com/jinzhu/now v1.1.1

View File

@ -1,6 +1,6 @@
# Where are all the schema files located? globs are supported eg src/**/*.graphqls # Where are all the schema files located? globs are supported eg src/**/*.graphqls
schema: schema:
- internal/graph/*.graphqls - internal/graph/schema/*.gql
# Where should the generated server code go? # Where should the generated server code go?
exec: exec:
@ -22,6 +22,8 @@ resolver:
layout: follow-schema layout: follow-schema
dir: internal/graph dir: internal/graph
package: graph package: graph
filename_template: "{name}.resolvers.go"
# Optional: turn on to use []Thing instead of []*Thing # Optional: turn on to use []Thing instead of []*Thing
omit_slice_element_pointers: true omit_slice_element_pointers: true

View File

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/jordanknott/taskcafe/internal/config"
"github.com/jordanknott/taskcafe/internal/utils" "github.com/jordanknott/taskcafe/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
@ -52,6 +53,7 @@ func initConfig() {
viper.SetEnvPrefix("TASKCAFE") viper.SetEnvPrefix("TASKCAFE")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv() viper.AutomaticEnv()
config.InitDefaults()
err := viper.ReadInConfig() err := viper.ReadInConfig()
if err == nil { if err == nil {
@ -61,31 +63,11 @@ func initConfig() {
panic(err) panic(err)
} }
viper.SetDefault("server.hostname", "0.0.0.0:3333")
viper.SetDefault("database.host", "127.0.0.1")
viper.SetDefault("database.name", "taskcafe")
viper.SetDefault("database.user", "taskcafe")
viper.SetDefault("database.password", "taskcafe_test")
viper.SetDefault("queue.broker", "amqp://guest:guest@localhost:5672/")
viper.SetDefault("queue.store", "memcache://localhost:11211")
} }
// Execute the root cobra command // Execute the root cobra command
func Execute() { func Execute() {
viper.SetDefault("server.hostname", "0.0.0.0:3333")
viper.SetDefault("database.host", "127.0.0.1")
viper.SetDefault("database.name", "taskcafe")
viper.SetDefault("database.user", "taskcafe")
viper.SetDefault("database.password", "taskcafe_test")
viper.SetDefault("database.port", "5432")
viper.SetDefault("security.token_expiration", "15m")
viper.SetDefault("queue.broker", "amqp://guest:guest@localhost:5672/")
viper.SetDefault("queue.store", "memcache://localhost:11211")
rootCmd.SetVersionTemplate(VersionTemplate()) rootCmd.SetVersionTemplate(VersionTemplate())
rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd()) rootCmd.AddCommand(newJobCmd(), newTokenCmd(), newWebCmd(), newMigrateCmd(), newWorkerCmd(), newResetPasswordCmd(), newSeedCmd())
rootCmd.Execute() rootCmd.Execute()
} }

60
internal/commands/job.go Normal file
View File

@ -0,0 +1,60 @@
package commands
import (
"time"
"github.com/spf13/cobra"
"github.com/RichardKnop/machinery/v1"
mTasks "github.com/RichardKnop/machinery/v1/tasks"
queueLog "github.com/RichardKnop/machinery/v1/log"
"github.com/jmoiron/sqlx"
"github.com/jordanknott/taskcafe/internal/config"
"github.com/jordanknott/taskcafe/internal/jobs"
log "github.com/sirupsen/logrus"
)
func newJobCmd() *cobra.Command {
cc := &cobra.Command{
Use: "job",
Short: "Run a task manually",
Long: "Run a task manually",
RunE: func(cmd *cobra.Command, args []string) error {
Formatter := new(log.TextFormatter)
Formatter.TimestampFormat = "02-01-2006 15:04:05"
Formatter.FullTimestamp = true
log.SetFormatter(Formatter)
log.SetLevel(log.InfoLevel)
appConfig, err := config.GetAppConfig()
if err != nil {
log.Panic(err)
}
db, err := sqlx.Connect("postgres", config.GetDatabaseConfig().GetDatabaseConnectionUri())
if err != nil {
log.Panic(err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
defer db.Close()
log.Info("starting task queue server instance")
jobConfig := appConfig.Job.GetJobConfig()
server, err := machinery.NewServer(&jobConfig)
if err != nil {
// do something with the error
}
queueLog.Set(&jobs.MachineryLogger{})
signature := &mTasks.Signature{
Name: "scheduleDueDateNotifications",
}
server.SendTask(signature)
return nil
},
}
return cc
}

View File

@ -15,7 +15,7 @@ import (
func newResetPasswordCmd() *cobra.Command { func newResetPasswordCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "reset-password", Use: "reset-password <username> <password>",
Short: "Resets password of the specified user", Short: "Resets password of the specified user",
Long: "If the user forgets its password you can reset it with this command.", Long: "If the user forgets its password you can reset it with this command.",
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),

View File

@ -0,0 +1,93 @@
package commands
import (
"context"
"fmt"
"time"
"github.com/jordanknott/taskcafe/internal/config"
"github.com/jordanknott/taskcafe/internal/db"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/jmoiron/sqlx"
log "github.com/sirupsen/logrus"
)
func newTokenCmd() *cobra.Command {
cc := &cobra.Command{
Use: "token [username]",
Short: "Creates an access token for a user",
Long: "Creates an access token for a user",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Formatter := new(log.TextFormatter)
Formatter.TimestampFormat = "02-01-2006 15:04:05"
Formatter.FullTimestamp = true
log.SetFormatter(Formatter)
log.SetLevel(log.InfoLevel)
appConfig, err := config.GetAppConfig()
if err != nil {
return err
}
var dbConnection *sqlx.DB
var retryDuration time.Duration
maxRetryNumber := 4
for i := 0; i < maxRetryNumber; i++ {
dbConnection, err = sqlx.Connect("postgres", appConfig.Database.GetDatabaseConnectionUri())
if err == nil {
break
}
retryDuration = time.Duration(i*2) * time.Second
log.WithFields(log.Fields{"retryNumber": i, "retryDuration": retryDuration}).WithError(err).Error("issue connecting to database, retrying")
if i != maxRetryNumber-1 {
time.Sleep(retryDuration)
}
}
if err != nil {
return err
}
dbConnection.SetMaxOpenConns(25)
dbConnection.SetMaxIdleConns(25)
dbConnection.SetConnMaxLifetime(5 * time.Minute)
defer dbConnection.Close()
if viper.GetBool("migrate") {
log.Info("running auto schema migrations")
if err = runMigration(dbConnection); err != nil {
return err
}
}
ctx := context.Background()
repository := db.NewRepository(dbConnection)
user, err := repository.GetUserAccountByUsername(ctx, args[0])
if err != nil {
return err
}
token, err := repository.CreateAuthToken(ctx, db.CreateAuthTokenParams{
UserID: user.UserID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Hour * 24 * 7),
})
if err != nil {
return err
}
fmt.Printf("Created token: %s\n", token.TokenID.String())
return nil
},
}
cc.Flags().Bool("migrate", false, "if true, auto run's schema migrations before starting the web server")
cc.Flags().IntVar(&teams, "teams", 5, "number of teams to generate")
cc.Flags().IntVar(&projects, "projects", 10, "number of projects to create per team (personal projects are included)")
cc.Flags().IntVar(&taskGroups, "task_groups", 5, "number of task groups to generate per project")
cc.Flags().IntVar(&tasks, "tasks", 25, "number of tasks to generate per task group")
viper.SetDefault("migrate", false)
return cc
}

View File

@ -1,21 +1,20 @@
package commands package commands
import ( import (
"fmt"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/RichardKnop/machinery/v1"
mTasks "github.com/RichardKnop/machinery/v1/tasks"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/httpfs" "github.com/golang-migrate/migrate/v4/source/httpfs"
"github.com/google/uuid"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/jordanknott/taskcafe/internal/config"
"github.com/jordanknott/taskcafe/internal/route" "github.com/jordanknott/taskcafe/internal/route"
"github.com/jordanknott/taskcafe/internal/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -33,15 +32,19 @@ func newWebCmd() *cobra.Command {
log.SetFormatter(Formatter) log.SetFormatter(Formatter)
log.SetLevel(log.InfoLevel) log.SetLevel(log.InfoLevel)
connection := fmt.Sprintf("user=%s password=%s host=%s dbname=%s port=%s sslmode=disable", appConfig, err := config.GetAppConfig()
viper.GetString("database.user"), if err != nil {
viper.GetString("database.password"), return err
viper.GetString("database.host"), }
viper.GetString("database.name"),
viper.GetString("database.port"), redisClient, err := appConfig.MessageQueue.GetMessageQueueClient()
) if err != nil {
return err
}
defer redisClient.Close()
connection := appConfig.Database.GetDatabaseConnectionUri()
var db *sqlx.DB var db *sqlx.DB
var err error
var retryDuration time.Duration var retryDuration time.Duration
maxRetryNumber := 4 maxRetryNumber := 4
for i := 0; i < maxRetryNumber; i++ { for i := 0; i < maxRetryNumber; i++ {
@ -70,36 +73,26 @@ func newWebCmd() *cobra.Command {
} }
} }
secret := viper.GetString("server.secret") var server *machinery.Server
if strings.TrimSpace(secret) == "" { jobConfig := appConfig.Job.GetJobConfig()
log.Warn("server.secret is not set, generating a random secret") server, err = machinery.NewServer(&jobConfig)
secret = uuid.New().String() if err != nil {
return err
} }
security, err := utils.GetSecurityConfig(viper.GetString("security.token_expiration"), []byte(secret)) signature := &mTasks.Signature{
r, _ := route.NewRouter(db, utils.EmailConfig{ Name: "scheduleDueDateNotifications",
From: viper.GetString("smtp.from"), }
Host: viper.GetString("smtp.host"), server.SendTask(signature)
Port: viper.GetInt("smtp.port"),
Username: viper.GetString("smtp.username"), r, _ := route.NewRouter(db, redisClient, server, appConfig)
Password: viper.GetString("smtp.password"),
InsecureSkipVerify: viper.GetBool("smtp.skip_verify"),
}, security)
log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server") log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server")
return http.ListenAndServe(viper.GetString("server.hostname"), r) return http.ListenAndServe(viper.GetString("server.hostname"), r)
}, },
} }
viper.SetDefault("smtp.from", "no-reply@example.com")
viper.SetDefault("smtp.host", "localhost")
viper.SetDefault("smtp.port", 587)
viper.SetDefault("smtp.username", "")
viper.SetDefault("smtp.password", "")
viper.SetDefault("smtp.skip_verify", false)
cc.Flags().Bool("migrate", false, "if true, auto run's schema migrations before starting the web server") cc.Flags().Bool("migrate", false, "if true, auto run's schema migrations before starting the web server")
viper.BindPFlag("migrate", cc.Flags().Lookup("migrate")) viper.BindPFlag("migrate", cc.Flags().Lookup("migrate"))
viper.SetDefault("migrate", false) viper.SetDefault("migrate", false)
return cc return cc
} }

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