feat: add bell notification system for task assignment

This commit is contained in:
Jordan Knott
2021-11-02 14:45:05 -05:00
parent 3afd860534
commit 799d7f3ad0
53 changed files with 3306 additions and 163 deletions

View File

@@ -225,7 +225,7 @@ const Card = React.forwardRef(
<ListCardBadges>
{watched && (
<ListCardBadge>
<Eye width={8} height={8} />
<Eye width={12} height={12} />
</ListCardBadge>
)}
{dueDate && (

View File

@@ -329,6 +329,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
toggleLabels={toggleLabels}
isPublic={isPublic}
labelVariant={cardLabelVariant}
watched={task.watched}
wrapperProps={{
...taskProvided.draggableProps,
...taskProvided.dragHandleProps,

View File

@@ -1,8 +1,20 @@
import React from 'react';
import styled from 'styled-components';
import React, { 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 {
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 { CheckCircleOutline, Circle, CircleSolid, UserCircle } from 'shared/icons';
import produce from 'immer';
const ItemWrapper = styled.div`
cursor: pointer;
@@ -37,7 +49,7 @@ const ItemTextContainer = styled.div`
const ItemTextTitle = styled.span`
font-weight: 500;
display: block;
color: ${props => props.theme.colors.primary};
color: ${(props) => props.theme.colors.primary};
font-size: 14px;
`;
const ItemTextDesc = styled.span`
@@ -72,38 +84,450 @@ export const NotificationItem: React.FC<NotificationItemProps> = ({ title, descr
};
const NotificationHeader = styled.div`
padding: 0.75rem;
padding: 20px 28px;
text-align: center;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
background: ${props => props.theme.colors.primary};
background: ${(props) => props.theme.colors.primary};
`;
const NotificationHeaderTitle = styled.span`
font-size: 14px;
color: ${props => props.theme.colors.text.secondary};
color: ${(props) => props.theme.colors.text.secondary};
`;
const Notifications = styled.div`
border-right: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-color: #414561;
height: 448px;
overflow-y: scroll;
user-select: none;
`;
const NotificationFooter = styled.div`
cursor: pointer;
padding: 0.5rem;
text-align: center;
color: ${props => props.theme.colors.primary};
color: ${(props) => props.theme.colors.primary};
&:hover {
background: ${props => props.theme.colors.bg.primary};
background: ${(props) => props.theme.colors.bg.primary};
}
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
border-right: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
border-color: #414561;
`;
const 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;
align-items: center;
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`
min-height: 112px;
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;
}
`;
const NotificationContentFooter = styled.div`
margin-top: 8px;
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;
`;
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`
margin-top: 8px;
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 width={14} height={16} />);
prefix.push(<NotificationPrefix>Assigned </NotificationPrefix>);
prefix.push(<span>you to the task "{dataMap.get('TaskName')}"</span>);
link = `/projects/${dataMap.get('ProjectID')}/board/c/${dataMap.get('TaskID')}`;
break;
default:
throw new Error('unknown action type');
}
return (
<NotificationWrapper>
<NotificationLink to={link} onClick={hidePopup}>
<NotificationCausedBy>
<NotificationCausedByInitials>
{causedBy
? causedBy.fullname
.split(' ')
.map((n) => n[0])
.join('.')
: 'RU'}
</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>
<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;
};
};
const NotificationPopup: React.FC = ({ children }) => {
const [filter, setFilter] = useState<NotificationFilter>(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;
}
});
});
},
});
const { data: nData, fetchMore } = useNotificationsQuery({
variables: { limit: 5, filter },
onCompleted: (d) => {
setData((prev) => ({
hasNextPage: d.notified.pageInfo.hasNextPage,
cursor: d.notified.pageInfo.endCursor,
nodes: [...prev.nodes, ...d.notified.notified],
}));
},
});
const { data: sData, loading } = useNotificationAddedSubscription({
onSubscriptionData: (d) => {
setData((n) => {
if (d.subscriptionData.data) {
return {
...n,
nodes: [d.subscriptionData.data.notificationAdded, ...n.nodes],
};
}
return n;
});
},
});
return (
<Popup title={null} tab={0} borders={false} padding={false}>
<NotificationHeader>
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
</NotificationHeader>
<ul>{children}</ul>
<NotificationFooter>View All</NotificationFooter>
<PopupContent>
<NotificationHeader>
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
</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>
<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(),
},
},
})
}
/>
))}
</Notifications>
</PopupContent>
</Popup>
);
};

View File

@@ -4,6 +4,7 @@ import { mixin } from 'shared/utils/styles';
import Button from 'shared/components/Button';
import TaskAssignee from 'shared/components/TaskAssignee';
import theme from 'App/ThemeStyles';
import { Checkmark } from 'shared/icons';
export const Container = styled.div`
display: flex;
@@ -309,6 +310,7 @@ export const ActionButton = styled(Button)`
text-align: left;
transition: transform 0.2s ease;
& span {
position: unset;
justify-content: flex-start;
}
&:hover {
@@ -717,3 +719,8 @@ export const TaskDetailsEditor = styled(TextareaAutosize)`
outline: none;
border: none;
`;
export const WatchedCheckmark = styled(Checkmark)`
position: absolute;
right: 16px;
`;

View File

@@ -12,6 +12,7 @@ import {
CheckSquareOutline,
At,
Smile,
Eye,
} from 'shared/icons';
import { toArray } from 'react-emoji-render';
import DOMPurify from 'dompurify';
@@ -80,6 +81,7 @@ import {
ActivityItemHeaderTitleName,
ActivityItemComment,
TabBarButton,
WatchedCheckmark,
} from './Styles';
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
import onDragEnd from './onDragEnd';
@@ -237,6 +239,7 @@ type TaskDetailsProps = {
onToggleChecklistItem: (itemID: string, complete: boolean) => void;
onOpenAddMemberPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onToggleTaskWatch: (task: Task, watched: boolean) => void;
onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onCreateComment: (task: Task, message: string) => void;
@@ -258,6 +261,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
task,
editableComment = null,
onDeleteChecklist,
onToggleTaskWatch,
onTaskNameChange,
onCommentShowActions,
onOpenAddChecklistPopup,
@@ -328,6 +332,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
const saveDescription = () => {
onTaskDescriptionChange(task, taskDescriptionRef.current);
};
console.log(task.watched);
return (
<Container>
<LeftSidebar>
@@ -418,6 +423,14 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
Checklist
</ActionButton>
<ActionButton>Cover</ActionButton>
<ActionButton
onClick={() => {
onToggleTaskWatch(task, !task.watched);
}}
icon={<Eye width={12} height={12} />}
>
Watch {task.watched && <WatchedCheckmark width={18} height={18} />}
</ActionButton>
</ExtraActionsSection>
)}
</LeftSidebarContent>

View File

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

View File

@@ -36,6 +36,7 @@ import {
ProjectMember,
ProjectMembers,
ProjectSwitchInner,
NotificationCount,
} from './Styles';
type IconContainerProps = {
@@ -185,6 +186,7 @@ type NavBarProps = {
projectMembers?: Array<TaskUser> | null;
projectInvitedMembers?: Array<InvitedUser> | null;
hasUnread: boolean;
onRemoveFromBoard?: (userID: string) => void;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
onInvitedMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, email: string) => void;
@@ -203,6 +205,7 @@ const NavBar: React.FC<NavBarProps> = ({
onOpenProjectFinder,
onFavorite,
onSetTab,
hasUnread,
projectInvitedMembers,
onChangeRole,
name,
@@ -330,8 +333,9 @@ const NavBar: React.FC<NavBarProps> = ({
<IconContainer disabled onClick={NOOP}>
<ListUnordered width={20} height={20} />
</IconContainer>
<IconContainer disabled onClick={onNotificationClick}>
<IconContainer onClick={onNotificationClick}>
<Bell color="#c2c6dc" size={20} />
{hasUnread && <NotificationCount />}
</IconContainer>
<IconContainer disabled onClick={NOOP}>
<BarChart width={20} height={20} />