feat!: due date reminder notifications

This commit is contained in:
Jordan Knott
2021-11-17 17:11:28 -06:00
parent 0d00fc7518
commit 886b2763ee
32 changed files with 1244 additions and 287 deletions

View File

@ -73,7 +73,9 @@ const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
});
const { showPopup, hidePopup } = usePopup();
const { setUser } = useCurrentUser();
const { data: unreadData } = useHasUnreadNotificationsQuery({ pollInterval: polling.UNREAD_NOTIFICATIONS });
const { data: unreadData, refetch: refetchHasUnread } = useHasUnreadNotificationsQuery({
pollInterval: polling.UNREAD_NOTIFICATIONS,
});
const history = useHistory();
const onLogout = () => {
fetch('/auth/logout', {
@ -118,9 +120,11 @@ const LoggedInNavbar: React.FC<GlobalTopNavbarProps> = ({
// TODO: rewrite popup to contain subscription and notification fetch
const onNotificationClick = ($target: React.RefObject<HTMLElement>) => {
if (data) {
showPopup($target, <NotificationPopup />, { width: 605, borders: false, diamondColor: theme.colors.primary });
}
showPopup($target, <NotificationPopup onToggleRead={() => refetchHasUnread()} />, {
width: 605,
borders: false,
diamondColor: theme.colors.primary,
});
};
// TODO: readd permision check

View File

@ -446,10 +446,8 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
</LeftWrapper>
<RightWrapper>
<ActionIcon
// disabled={notifications.length === 3}
disabled
disabled={notifications.length === 3}
onClick={() => {
/*
setNotifications((prev) => [
...prev,
{
@ -459,7 +457,6 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
period: 10,
},
]);
*/
}}
>
<Bell width={16} height={16} />

View File

@ -1,9 +1,10 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import styled, { css } from 'styled-components';
import TimeAgo from 'react-timeago';
import { Link } from 'react-router-dom';
import { mixin } from 'shared/utils/styles';
import {
useNotificationMarkAllReadMutation,
useNotificationsQuery,
NotificationFilter,
ActionType,
@ -13,10 +14,24 @@ import {
import dayjs from 'dayjs';
import { Popup, usePopup } from 'shared/components/PopupMenu';
import { CheckCircleOutline, Circle, CircleSolid, UserCircle } from 'shared/icons';
import { Bell, CheckCircleOutline, Circle, Ellipsis, UserCircle } from 'shared/icons';
import produce from 'immer';
import { useLocalStorage } from 'shared/hooks/useStateWithLocalStorage';
import localStorage from 'shared/utils/localStorage';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
function getFilterMessage(filter: NotificationFilter) {
switch (filter) {
case NotificationFilter.Unread:
return 'no unread';
case NotificationFilter.Assigned:
return 'no assigned';
case NotificationFilter.Mentioned:
return 'no mentioned';
default:
return 'no';
}
}
const ItemWrapper = styled.div`
cursor: pointer;
@ -98,6 +113,17 @@ const NotificationHeaderTitle = styled.span`
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);
@ -180,7 +206,6 @@ const NotificationTab = styled.div<{ active: boolean }>`
const NotificationLink = styled(Link)`
display: flex;
align-items: center;
text-decoration: none;
padding: 16px 8px;
width: 100%;
@ -213,8 +238,8 @@ const NotificationButton = styled.div`
}
`;
const NotificationWrapper = styled.li`
min-height: 112px;
const NotificationWrapper = styled.li<{ read: boolean }>`
min-height: 80px;
display: flex;
font-size: 14px;
transition: background-color 0.1s ease-in-out;
@ -231,20 +256,28 @@ const NotificationWrapper = styled.li`
&: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: 8px;
margin-top: 10px;
display: flex;
align-items: center;
color: ${(props) => props.theme.colors.text.primary};
`;
const NotificationCausedBy = styled.div`
height: 60px;
width: 60px;
min-height: 60px;
min-width: 60px;
height: 48px;
width: 48px;
min-height: 48px;
min-width: 48px;
`;
const NotificationCausedByInitials = styled.div`
position: relative;
@ -292,7 +325,6 @@ const NotificationContentHeader = styled.div`
`;
const NotificationBody = styled.div`
margin-top: 8px;
display: flex;
align-items: center;
color: #fff;
@ -328,17 +360,39 @@ const Notification: React.FC<NotificationProps> = ({ causedBy, createdAt, data,
let link = '#';
switch (actionType) {
case ActionType.TaskAssigned:
prefix.push(<UserCircle width={14} height={16} />);
prefix.push(<NotificationPrefix>Assigned </NotificationPrefix>);
prefix.push(<span>you to the task "{dataMap.get('TaskName')}"</span>);
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 "{dataMap.get('TaskName')}"</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>
<NotificationWrapper read={read}>
<NotificationLink to={link} onClick={hidePopup}>
<NotificationCausedBy>
<NotificationCausedByInitials>
@ -351,10 +405,6 @@ const Notification: React.FC<NotificationProps> = ({ causedBy, createdAt, data,
</NotificationCausedByInitials>
</NotificationCausedBy>
<NotificationContent>
<NotificationContentHeader>
{causedBy ? causedBy.fullname : 'Removed user'}
{!read && <CircleSolid width={10} height={10} />}
</NotificationContentHeader>
<NotificationBody>{prefix}</NotificationBody>
<NotificationContentFooter>
<span>{dayjs.duration(dayjs(createdAt).diff(dayjs())).humanize(true)}</span>
@ -404,7 +454,59 @@ type NotificationEntry = {
createdAt: string;
};
};
const NotificationPopup: React.FC = ({ children }) => {
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,
@ -425,10 +527,12 @@ const NotificationPopup: React.FC = ({ children }) => {
}
});
});
onToggleRead();
},
});
const { data: nData, fetchMore } = useNotificationsQuery({
variables: { limit: 5, filter },
const { fetchMore } = useNotificationsQuery({
variables: { limit: 8, filter },
fetchPolicy: 'network-only',
onCompleted: (d) => {
setData((prev) => ({
hasNextPage: d.notified.pageInfo.hasNextPage,
@ -437,7 +541,7 @@ const NotificationPopup: React.FC = ({ children }) => {
}));
},
});
const { data: sData, loading } = useNotificationAddedSubscription({
useNotificationAddedSubscription({
onSubscriptionData: (d) => {
setData((n) => {
if (d.subscriptionData.data) {
@ -450,12 +554,40 @@ const NotificationPopup: React.FC = ({ children }) => {
});
},
});
const [toggleAllRead] = useNotificationMarkAllReadMutation();
const [showHeaderMenu, setShowHeaderMenu] = useState(false);
const $menuContent = useRef<HTMLDivElement>(null);
useOnOutsideClick($menuContent, true, () => setShowHeaderMenu(false), null);
return (
<Popup title={null} tab={0} borders={false} padding={false}>
<PopupContent>
<NotificationHeader>
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
<NotificationHeaderMenu>
<NotificationHeaderMenuIcon onClick={() => setShowHeaderMenu(true)}>
<Ellipsis size={18} color="#fff" vertical={false} />
<NotificationHeaderMenuContent ref={$menuContent} show={showHeaderMenu}>
<NotificationHeaderMenuButton
onClick={(e) => {
e.stopPropagation();
setShowHeaderMenu(() => false);
toggleAllRead().then(() => {
setData((prev) =>
produce(prev, (draftData) => {
draftData.nodes = draftData.nodes.map((node) => ({ ...node, read: true }));
}),
);
onToggleRead();
});
}}
>
Mark all as read
</NotificationHeaderMenuButton>
</NotificationHeaderMenuContent>
</NotificationHeaderMenuIcon>
</NotificationHeaderMenu>
</NotificationHeader>
<NotificationTabs>
{tabs.map((tab) => (
@ -473,65 +605,73 @@ const NotificationPopup: React.FC = ({ children }) => {
</NotificationTab>
))}
</NotificationTabs>
<Notifications
onScroll={({ currentTarget }) => {
if (currentTarget.scrollTop + currentTarget.clientHeight >= currentTarget.scrollHeight) {
if (data.hasNextPage) {
console.log(`fetching more = ${data.cursor} - ${data.hasNextPage}`);
fetchMore({
variables: {
limit: 5,
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(),
{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],
},
};
},
});
}
}
/>
))}
</Notifications>
}}
>
{data.nodes.map((n) => (
<Notification
key={n.id}
read={n.read}
actionType={n.notification.actionType}
data={n.notification.data}
createdAt={n.notification.createdAt}
causedBy={n.notification.causedBy}
onToggleRead={() =>
toggleRead({
variables: { notifiedID: n.id },
optimisticResponse: {
__typename: 'Mutation',
notificationToggleRead: {
__typename: 'Notified',
id: n.id,
read: !n.read,
readAt: new Date().toUTCString(),
},
},
}).then(() => {
onToggleRead();
})
}
/>
))}
</Notifications>
) : (
<EmptyMessage>
<EmptyMessageLabel>You have {getFilterMessage(filter)} notifications</EmptyMessageLabel>
</EmptyMessage>
)}
</PopupContent>
</Popup>
);

View File

@ -34,6 +34,7 @@ export enum ActionType {
DueDateAdded = 'DUE_DATE_ADDED',
DueDateRemoved = 'DUE_DATE_REMOVED',
DueDateChanged = 'DUE_DATE_CHANGED',
DueDateReminder = 'DUE_DATE_REMINDER',
TaskAssigned = 'TASK_ASSIGNED',
TaskMoved = 'TASK_MOVED',
TaskArchived = 'TASK_ARCHIVED',
@ -456,6 +457,7 @@ export type Mutation = {
duplicateTaskGroup: DuplicateTaskGroupPayload;
inviteProjectMembers: InviteProjectMembersPayload;
logoutUser: Scalars['Boolean'];
notificationMarkAllRead: NotificationMarkAllAsReadResult;
notificationToggleRead: Notified;
removeTaskLabel: Task;
setTaskChecklistItemComplete: TaskChecklistItem;
@ -899,6 +901,11 @@ export enum NotificationFilter {
Mentioned = 'MENTIONED'
}
export type NotificationMarkAllAsReadResult = {
__typename?: 'NotificationMarkAllAsReadResult';
success: Scalars['Boolean'];
};
export type NotificationToggleReadInput = {
notifiedID: Scalars['UUID'];
};
@ -1929,6 +1936,17 @@ export type NotificationsQuery = (
) }
);
export type NotificationMarkAllReadMutationVariables = Exact<{ [key: string]: never; }>;
export type NotificationMarkAllReadMutation = (
{ __typename?: 'Mutation' }
& { notificationMarkAllRead: (
{ __typename?: 'NotificationMarkAllAsReadResult' }
& Pick<NotificationMarkAllAsReadResult, 'success'>
) }
);
export type NotificationAddedSubscriptionVariables = Exact<{ [key: string]: never; }>;
@ -3891,6 +3909,38 @@ export function useNotificationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOpti
export type NotificationsQueryHookResult = ReturnType<typeof useNotificationsQuery>;
export type NotificationsLazyQueryHookResult = ReturnType<typeof useNotificationsLazyQuery>;
export type NotificationsQueryResult = Apollo.QueryResult<NotificationsQuery, NotificationsQueryVariables>;
export const NotificationMarkAllReadDocument = gql`
mutation notificationMarkAllRead {
notificationMarkAllRead {
success
}
}
`;
export type NotificationMarkAllReadMutationFn = Apollo.MutationFunction<NotificationMarkAllReadMutation, NotificationMarkAllReadMutationVariables>;
/**
* __useNotificationMarkAllReadMutation__
*
* To run a mutation, you first call `useNotificationMarkAllReadMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useNotificationMarkAllReadMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [notificationMarkAllReadMutation, { data, loading, error }] = useNotificationMarkAllReadMutation({
* variables: {
* },
* });
*/
export function useNotificationMarkAllReadMutation(baseOptions?: Apollo.MutationHookOptions<NotificationMarkAllReadMutation, NotificationMarkAllReadMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<NotificationMarkAllReadMutation, NotificationMarkAllReadMutationVariables>(NotificationMarkAllReadDocument, options);
}
export type NotificationMarkAllReadMutationHookResult = ReturnType<typeof useNotificationMarkAllReadMutation>;
export type NotificationMarkAllReadMutationResult = Apollo.MutationResult<NotificationMarkAllReadMutation>;
export type NotificationMarkAllReadMutationOptions = Apollo.BaseMutationOptions<NotificationMarkAllReadMutation, NotificationMarkAllReadMutationVariables>;
export const NotificationAddedDocument = gql`
subscription notificationAdded {
notificationAdded {

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;