feat: add notification UI
showPopup was also refactored to be better
This commit is contained in:
111
frontend/src/shared/components/NotifcationPopup/index.tsx
Normal file
111
frontend/src/shared/components/NotifcationPopup/index.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import { Popup } from 'shared/components/PopupMenu';
|
||||
|
||||
const ItemWrapper = styled.div`
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #414561;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
background: #10163a;
|
||||
}
|
||||
`;
|
||||
const ItemWrapperContent = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
`;
|
||||
|
||||
const ItemIconContainer = styled.span`
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ItemTextContainer = styled.div`
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
`;
|
||||
|
||||
const ItemTextTitle = styled.span`
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
color: rgba(${props => props.theme.colors.primary});
|
||||
font-size: 14px;
|
||||
`;
|
||||
const ItemTextDesc = styled.span`
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const ItemTimeAgo = styled.span`
|
||||
margin-top: 0.25rem;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
`;
|
||||
|
||||
type NotificationItemProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export const NotificationItem: React.FC<NotificationItemProps> = ({ title, description, createdAt }) => {
|
||||
return (
|
||||
<ItemWrapper>
|
||||
<ItemWrapperContent>
|
||||
<ItemIconContainer />
|
||||
<ItemTextContainer>
|
||||
<ItemTextTitle>{title}</ItemTextTitle>
|
||||
<ItemTextDesc>{description}</ItemTextDesc>
|
||||
</ItemTextContainer>
|
||||
</ItemWrapperContent>
|
||||
<TimeAgo date={createdAt} component={ItemTimeAgo} />
|
||||
</ItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const NotificationHeader = styled.div`
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
background: rgba(${props => props.theme.colors.primary});
|
||||
`;
|
||||
|
||||
const NotificationHeaderTitle = styled.span`
|
||||
font-size: 14px;
|
||||
color: rgba(${props => props.theme.colors.text.secondary});
|
||||
`;
|
||||
|
||||
const NotificationFooter = styled.div`
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
color: rgba(${props => props.theme.colors.primary});
|
||||
&:hover {
|
||||
background: #10163a;
|
||||
}
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
`;
|
||||
|
||||
const NotificationPopup: React.FC = ({ children }) => {
|
||||
return (
|
||||
<Popup title={null} tab={0} borders={false} padding={false}>
|
||||
<NotificationHeader>
|
||||
<NotificationHeaderTitle>Notifications</NotificationHeaderTitle>
|
||||
</NotificationHeader>
|
||||
<ul>{children}</ul>
|
||||
<NotificationFooter>View All</NotificationFooter>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationPopup;
|
@ -5,6 +5,7 @@ import ControlledInput from 'shared/components/ControlledInput';
|
||||
export const Container = styled.div<{
|
||||
invertY: boolean;
|
||||
invert: boolean;
|
||||
targetPadding: string;
|
||||
top: number;
|
||||
left: number;
|
||||
ref: any;
|
||||
@ -15,7 +16,7 @@ export const Container = styled.div<{
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: ${props => props.width}px;
|
||||
padding-top: 10px;
|
||||
padding-top: ${props => props.targetPadding};
|
||||
height: auto;
|
||||
z-index: 40000;
|
||||
${props =>
|
||||
@ -28,14 +29,18 @@ export const Container = styled.div<{
|
||||
css`
|
||||
top: auto;
|
||||
padding-top: 0;
|
||||
padding-bottom: 10px;
|
||||
padding-bottom: ${props.targetPadding};
|
||||
bottom: ${props.top}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
padding: 5px;
|
||||
padding-top: 8px;
|
||||
export const Wrapper = styled.div<{ padding: boolean; borders: boolean }>`
|
||||
${props =>
|
||||
props.padding &&
|
||||
css`
|
||||
padding: 5px;
|
||||
padding-top: 8px;
|
||||
`}
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
@ -43,8 +48,12 @@ export const Wrapper = styled.div`
|
||||
|
||||
color: #c2c6dc;
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-color: #414561;
|
||||
${props =>
|
||||
props.borders &&
|
||||
css`
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-color: #414561;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Header = styled.div`
|
||||
@ -326,7 +335,7 @@ export const PreviousButton = styled.div`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const ContainerDiamond = styled.div<{ invert: boolean; invertY: boolean }>`
|
||||
export const ContainerDiamond = styled.div<{ borders: boolean; color: string; invert: boolean; invertY: boolean }>`
|
||||
${props => (props.invert ? 'right: 10px; ' : 'left: 15px;')}
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
@ -347,6 +356,10 @@ export const ContainerDiamond = styled.div<{ invert: boolean; invertY: boolean }
|
||||
transform: rotate(45deg) translate(-7px);
|
||||
z-index: 10;
|
||||
|
||||
background: #262c49;
|
||||
border-color: #414561;
|
||||
background: ${props => props.color};
|
||||
${props =>
|
||||
props.borders &&
|
||||
css`
|
||||
border-color: #414561;
|
||||
`}
|
||||
`;
|
||||
|
@ -15,9 +15,37 @@ import {
|
||||
Wrapper,
|
||||
} from './Styles';
|
||||
|
||||
function getPopupOptions(options?: PopupOptions) {
|
||||
const popupOptions = {
|
||||
borders: true,
|
||||
diamondColor: '#262c49',
|
||||
targetPadding: '10px',
|
||||
showDiamond: true,
|
||||
width: 316,
|
||||
};
|
||||
if (options) {
|
||||
if (options.borders) {
|
||||
popupOptions.borders = options.borders;
|
||||
}
|
||||
if (options.width) {
|
||||
popupOptions.width = options.width;
|
||||
}
|
||||
if (options.targetPadding) {
|
||||
popupOptions.targetPadding = options.targetPadding;
|
||||
}
|
||||
if (typeof options.showDiamond !== 'undefined' && options.showDiamond !== null) {
|
||||
popupOptions.showDiamond = options.showDiamond;
|
||||
}
|
||||
if (options.diamondColor) {
|
||||
popupOptions.diamondColor = options.diamondColor;
|
||||
}
|
||||
}
|
||||
return popupOptions;
|
||||
}
|
||||
|
||||
type PopupContextState = {
|
||||
show: (target: RefObject<HTMLElement>, content: JSX.Element, width?: string | number) => void;
|
||||
setTab: (newTab: number, width?: number | string) => void;
|
||||
show: (target: RefObject<HTMLElement>, content: JSX.Element, options?: PopupOptions) => void;
|
||||
setTab: (newTab: number, options?: PopupOptions) => void;
|
||||
getCurrentTab: () => number;
|
||||
hide: () => void;
|
||||
};
|
||||
@ -26,23 +54,44 @@ type PopupProps = {
|
||||
title: string | null;
|
||||
onClose?: () => void;
|
||||
tab: number;
|
||||
padding?: boolean;
|
||||
borders?: boolean;
|
||||
diamondColor?: string;
|
||||
};
|
||||
|
||||
type PopupContainerProps = {
|
||||
top: number;
|
||||
left: number;
|
||||
invert: boolean;
|
||||
targetPadding: string;
|
||||
invertY: boolean;
|
||||
onClose: () => void;
|
||||
width?: string | number;
|
||||
};
|
||||
|
||||
const PopupContainer: React.FC<PopupContainerProps> = ({ width, top, left, onClose, children, invert, invertY }) => {
|
||||
const PopupContainer: React.FC<PopupContainerProps> = ({
|
||||
width,
|
||||
top,
|
||||
left,
|
||||
onClose,
|
||||
children,
|
||||
invert,
|
||||
invertY,
|
||||
targetPadding,
|
||||
}) => {
|
||||
const $containerRef = useRef<HTMLDivElement>(null);
|
||||
const [currentTop, setCurrentTop] = useState(top);
|
||||
useOnOutsideClick($containerRef, true, onClose, null);
|
||||
return (
|
||||
<Container width={width ?? 316} left={left} top={currentTop} ref={$containerRef} invert={invert} invertY={invertY}>
|
||||
<Container
|
||||
targetPadding={targetPadding}
|
||||
width={width ?? 316}
|
||||
left={left}
|
||||
top={currentTop}
|
||||
ref={$containerRef}
|
||||
invert={invert}
|
||||
invertY={invertY}
|
||||
>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
@ -73,13 +122,28 @@ type PopupState = {
|
||||
currentTab: number;
|
||||
previousTab: number;
|
||||
content: JSX.Element | null;
|
||||
width?: string | number;
|
||||
options: PopupOptionsInternal | null;
|
||||
};
|
||||
|
||||
const { Provider, Consumer } = PopupContext;
|
||||
|
||||
const canUseDOM = !!(typeof window !== 'undefined' && window.document && window.document.createElement);
|
||||
|
||||
type PopupOptionsInternal = {
|
||||
width: number;
|
||||
borders: boolean;
|
||||
targetPadding: string;
|
||||
diamondColor: string;
|
||||
showDiamond: boolean;
|
||||
};
|
||||
|
||||
type PopupOptions = {
|
||||
targetPadding?: string | null;
|
||||
showDiamond?: boolean | null;
|
||||
width?: number | null;
|
||||
borders?: boolean | null;
|
||||
diamondColor?: string | null;
|
||||
};
|
||||
const defaultState = {
|
||||
isOpen: false,
|
||||
left: 0,
|
||||
@ -89,11 +153,12 @@ const defaultState = {
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content: null,
|
||||
options: null,
|
||||
};
|
||||
|
||||
export const PopupProvider: React.FC = ({ children }) => {
|
||||
const [currentState, setState] = useState<PopupState>(defaultState);
|
||||
const show = (target: RefObject<HTMLElement>, content: JSX.Element, width?: number | string) => {
|
||||
const show = (target: RefObject<HTMLElement>, content: JSX.Element, options?: PopupOptions) => {
|
||||
if (target && target.current) {
|
||||
const bounds = target.current.getBoundingClientRect();
|
||||
let top = bounds.top + bounds.height;
|
||||
@ -102,6 +167,7 @@ export const PopupProvider: React.FC = ({ children }) => {
|
||||
top = window.innerHeight - bounds.top;
|
||||
invertY = true;
|
||||
}
|
||||
const popupOptions = getPopupOptions(options);
|
||||
if (bounds.left + 304 + 30 > window.innerWidth) {
|
||||
setState({
|
||||
isOpen: true,
|
||||
@ -112,7 +178,7 @@ export const PopupProvider: React.FC = ({ children }) => {
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content,
|
||||
width: width ?? 316,
|
||||
options: popupOptions,
|
||||
});
|
||||
} else {
|
||||
setState({
|
||||
@ -124,7 +190,7 @@ export const PopupProvider: React.FC = ({ children }) => {
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content,
|
||||
width: width ?? 316,
|
||||
options: popupOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -139,20 +205,21 @@ export const PopupProvider: React.FC = ({ children }) => {
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content: null,
|
||||
options: null,
|
||||
});
|
||||
};
|
||||
const portalTarget = canUseDOM ? document.body : null; // appease flow
|
||||
|
||||
const setTab = (newTab: number, width?: number | string) => {
|
||||
const newWidth = width ?? currentState.width;
|
||||
setState((prevState: PopupState) => {
|
||||
return {
|
||||
...prevState,
|
||||
previousTab: currentState.currentTab,
|
||||
currentTab: newTab,
|
||||
width: newWidth,
|
||||
};
|
||||
});
|
||||
const setTab = (newTab: number, options?: PopupOptions) => {
|
||||
setState((prevState: PopupState) =>
|
||||
produce(prevState, draftState => {
|
||||
draftState.previousTab = currentState.currentTab;
|
||||
draftState.currentTab = newTab;
|
||||
if (options) {
|
||||
draftState.options = getPopupOptions(options);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const getCurrentTab = () => {
|
||||
@ -163,17 +230,26 @@ export const PopupProvider: React.FC = ({ children }) => {
|
||||
<Provider value={{ hide, show, setTab, getCurrentTab }}>
|
||||
{portalTarget &&
|
||||
currentState.isOpen &&
|
||||
currentState.options &&
|
||||
createPortal(
|
||||
<PopupContainer
|
||||
invertY={currentState.invertY}
|
||||
invert={currentState.invert}
|
||||
top={currentState.top}
|
||||
targetPadding={currentState.options.targetPadding}
|
||||
left={currentState.left}
|
||||
onClose={() => setState(defaultState)}
|
||||
width={currentState.width ?? 316}
|
||||
width={currentState.options.width}
|
||||
>
|
||||
{currentState.content}
|
||||
<ContainerDiamond invertY={currentState.invertY} invert={currentState.invert} />
|
||||
{currentState.options.showDiamond && (
|
||||
<ContainerDiamond
|
||||
color={currentState.options.diamondColor}
|
||||
borders={currentState.options.borders}
|
||||
invertY={currentState.invertY}
|
||||
invert={currentState.invert}
|
||||
/>
|
||||
)}
|
||||
</PopupContainer>,
|
||||
portalTarget,
|
||||
)}
|
||||
@ -197,8 +273,16 @@ const PopupMenu: React.FC<Props> = ({ width, title, top, left, onClose, noHeader
|
||||
useOnOutsideClick($containerRef, true, onClose, null);
|
||||
|
||||
return (
|
||||
<Container invertY={false} width={width ?? 316} invert={false} left={left} top={top} ref={$containerRef}>
|
||||
<Wrapper>
|
||||
<Container
|
||||
targetPadding="10px"
|
||||
invertY={false}
|
||||
width={width ?? 316}
|
||||
invert={false}
|
||||
left={left}
|
||||
top={top}
|
||||
ref={$containerRef}
|
||||
>
|
||||
<Wrapper padding borders>
|
||||
{onPrevious && (
|
||||
<PreviousButton onClick={onPrevious}>
|
||||
<AngleLeft color="#c2c6dc" />
|
||||
@ -222,7 +306,7 @@ const PopupMenu: React.FC<Props> = ({ width, title, top, left, onClose, noHeader
|
||||
);
|
||||
};
|
||||
|
||||
export const Popup: React.FC<PopupProps> = ({ title, onClose, tab, children }) => {
|
||||
export const Popup: React.FC<PopupProps> = ({ borders = true, padding = true, title, onClose, tab, children }) => {
|
||||
const { getCurrentTab, setTab } = usePopup();
|
||||
if (getCurrentTab() !== tab) {
|
||||
return null;
|
||||
@ -230,7 +314,7 @@ export const Popup: React.FC<PopupProps> = ({ title, onClose, tab, children }) =
|
||||
|
||||
return (
|
||||
<>
|
||||
<Wrapper>
|
||||
<Wrapper borders={borders} padding={padding}>
|
||||
{tab > 0 && (
|
||||
<PreviousButton
|
||||
onClick={() => {
|
||||
|
@ -5,6 +5,7 @@ import Button from 'shared/components/Button';
|
||||
import { Taskcafe } from 'shared/icons';
|
||||
import { NavLink, Link } from 'react-router-dom';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export const ProjectMember = styled(TaskAssignee)<{ zIndex: number }>`
|
||||
z-index: ${props => props.zIndex};
|
||||
@ -65,7 +66,7 @@ export const ProfileNameWrapper = styled.div`
|
||||
line-height: 1.25;
|
||||
`;
|
||||
|
||||
export const IconContainer = styled.div<{ disabled?: boolean }>`
|
||||
export const IconContainerWrapper = styled.div<{ disabled?: boolean }>`
|
||||
margin-right: 20px;
|
||||
cursor: pointer;
|
||||
${props =>
|
||||
@ -86,7 +87,10 @@ export const ProfileNameSecondary = styled.small`
|
||||
color: #c2c6dc;
|
||||
`;
|
||||
|
||||
export const ProfileIcon = styled.div<{ bgColor: string | null; backgroundURL: string | null }>`
|
||||
export const ProfileIcon = styled.div<{
|
||||
bgColor: string | null;
|
||||
backgroundURL: string | null;
|
||||
}>`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 9999px;
|
||||
|
@ -1,19 +1,17 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Home, Star, Bell, AngleDown, BarChart, CheckCircle } from 'shared/icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import ProfileIcon from 'shared/components/ProfileIcon';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { usePopup } from 'shared/components/PopupMenu';
|
||||
import { RoleCode } from 'shared/generated/graphql';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import {
|
||||
TaskcafeLogo,
|
||||
TaskcafeTitle,
|
||||
ProjectFinder,
|
||||
LogoContainer,
|
||||
NavSeparator,
|
||||
IconContainer,
|
||||
IconContainerWrapper,
|
||||
ProjectNameTextarea,
|
||||
InviteButton,
|
||||
GlobalActions,
|
||||
@ -33,6 +31,28 @@ import {
|
||||
ProjectMembers,
|
||||
} from './Styles';
|
||||
|
||||
type IconContainerProps = {
|
||||
disabled?: boolean;
|
||||
onClick?: ($target: React.RefObject<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const IconContainer: React.FC<IconContainerProps> = ({ onClick, disabled = false, children }) => {
|
||||
const $container = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<IconContainerWrapper
|
||||
ref={$container}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (onClick) {
|
||||
onClick($container);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</IconContainerWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const HomeDashboard = styled(Home)``;
|
||||
|
||||
type ProjectHeadingProps = {
|
||||
@ -145,7 +165,7 @@ type NavBarProps = {
|
||||
onFavorite?: () => void;
|
||||
onProfileClick: ($target: React.RefObject<HTMLElement>) => void;
|
||||
onSaveName?: (name: string) => void;
|
||||
onNotificationClick: () => void;
|
||||
onNotificationClick: ($target: React.RefObject<HTMLElement>) => void;
|
||||
canEditProjectName?: boolean;
|
||||
canInviteUser?: boolean;
|
||||
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
|
||||
@ -257,16 +277,16 @@ const NavBar: React.FC<NavBarProps> = ({
|
||||
<ProjectFinder onClick={onOpenProjectFinder} variant="gradient">
|
||||
Projects
|
||||
</ProjectFinder>
|
||||
<IconContainer onClick={onDashboardClick}>
|
||||
<IconContainer onClick={() => onDashboardClick()}>
|
||||
<HomeDashboard width={20} height={20} />
|
||||
</IconContainer>
|
||||
<IconContainer disabled>
|
||||
<IconContainer disabled onClick={NOOP}>
|
||||
<CheckCircle width={20} height={20} />
|
||||
</IconContainer>
|
||||
<IconContainer onClick={onNotificationClick}>
|
||||
<IconContainer disabled onClick={onNotificationClick}>
|
||||
<Bell color="#c2c6dc" size={20} />
|
||||
</IconContainer>
|
||||
<IconContainer disabled>
|
||||
<IconContainer disabled onClick={NOOP}>
|
||||
<BarChart width={20} height={20} />
|
||||
</IconContainer>
|
||||
|
||||
|
Reference in New Issue
Block a user