Redesign Timeline (#1772)

* Timeline redesign for desktop and mobile
* Fixed nowrap on desktop

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
brendanlaschke
2023-12-04 11:37:25 +01:00
committed by GitHub
parent 2171eff1a0
commit 40b4e9f8e9
6 changed files with 256 additions and 113 deletions

View File

@ -1,41 +1,85 @@
import { Tooltip } from 'react-tooltip'; import { Tooltip } from 'react-tooltip';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { useCompleteTask } from '@/activities/tasks/hooks/useCompleteTask';
import { Activity } from '@/activities/types/Activity'; import { Activity } from '@/activities/types/Activity';
import { IconNotes } from '@/ui/display/icon'; import { IconCheckbox, IconNotes } from '@/ui/display/icon';
import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { Avatar } from '@/users/components/Avatar';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { import {
beautifyExactDateTime, beautifyExactDateTime,
beautifyPastDateRelativeToNow, beautifyPastDateRelativeToNow,
} from '~/utils/date-utils'; } from '~/utils/date-utils';
import { TimelineActivityCardFooter } from './TimelineActivityCardFooter'; const StyledAvatarContainer = styled.div`
import { TimelineActivityTitle } from './TimelineActivityTitle'; align-items: center;
display: flex;
height: 26px;
justify-content: center;
user-select: none;
width: 26px;
z-index: 2;
`;
const StyledIconContainer = styled.div` const StyledIconContainer = styled.div`
align-items: center; align-items: center;
color: ${({ theme }) => theme.font.color.tertiary}; color: ${({ theme }) => theme.font.color.tertiary};
display: flex; display: flex;
height: 20px; height: 16px;
justify-content: center; justify-content: center;
width: 20px; text-decoration-line: underline;
width: 16px;
`; `;
const StyledItemTitleContainer = styled.div` const StyledActivityTitle = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
display: flex;
flex: 1;
font-weight: ${({ theme }) => theme.font.weight.regular};
overflow: hidden;
`;
const StyledActivityLink = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-weight: ${({ theme }) => theme.font.weight.regular};
overflow: hidden;
text-decoration-line: underline;
text-overflow: ellipsis;
`;
const StyledItemContainer = styled.div`
align-content: center; align-content: center;
align-items: center; align-items: center;
color: ${({ theme }) => theme.font.color.tertiary}; color: ${({ theme }) => theme.font.color.tertiary};
display: flex; display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(1)}; gap: ${({ theme }) => theme.spacing(1)};
height: 20px;
span { span {
color: ${({ theme }) => theme.font.color.secondary}; color: ${({ theme }) => theme.font.color.secondary};
} }
overflow: hidden;
`;
const StyledItemTitleContainer = styled.div`
display: flex;
flex: 1;
flex-flow: row ${() => (useIsMobile() ? 'wrap' : 'nowrap')};
gap: ${({ theme }) => theme.spacing(1)};
overflow: hidden;
`;
const StyledItemAuthorText = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledItemTitle = styled.div`
display: flex;
flex-flow: row nowrap;
overflow: hidden;
`; `;
const StyledItemTitleDate = styled.div` const StyledItemTitleDate = styled.div`
@ -44,6 +88,7 @@ const StyledItemTitleDate = styled.div`
display: flex; display: flex;
gap: ${({ theme }) => theme.spacing(2)}; gap: ${({ theme }) => theme.spacing(2)};
justify-content: flex-end; justify-content: flex-end;
margin-left: auto;
`; `;
const StyledVerticalLineContainer = styled.div` const StyledVerticalLineContainer = styled.div`
@ -52,7 +97,8 @@ const StyledVerticalLineContainer = styled.div`
display: flex; display: flex;
gap: ${({ theme }) => theme.spacing(2)}; gap: ${({ theme }) => theme.spacing(2)};
justify-content: center; justify-content: center;
width: 20px; width: 26px;
z-index: 2;
`; `;
const StyledVerticalLine = styled.div` const StyledVerticalLine = styled.div`
@ -62,35 +108,6 @@ const StyledVerticalLine = styled.div`
width: 2px; width: 2px;
`; `;
const StyledCardContainer = styled.div`
align-items: center;
cursor: pointer;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
max-width: 100%;
padding: 4px 0px 20px 0px;
width: ${() => (useIsMobile() ? '100%' : '400px')};
`;
const StyledCard = styled.div`
align-items: flex-start;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
width: calc(100% - ${({ theme }) => theme.spacing(4)});
`;
const StyledCardContent = styled.div`
align-self: stretch;
color: ${({ theme }) => theme.font.color.secondary};
margin-top: ${({ theme }) => theme.spacing(2)};
width: calc(100% - ${({ theme }) => theme.spacing(4)});
`;
const StyledTooltip = styled(Tooltip)` const StyledTooltip = styled(Tooltip)`
background-color: ${({ theme }) => theme.background.primary}; background-color: ${({ theme }) => theme.background.primary};
@ -106,16 +123,15 @@ const StyledTooltip = styled(Tooltip)`
padding: ${({ theme }) => theme.spacing(2)}; padding: ${({ theme }) => theme.spacing(2)};
`; `;
const StyledCardDetailsContainer = styled.div` const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>`
padding: ${({ theme }) => theme.spacing(2)};
width: calc(100% - ${({ theme }) => theme.spacing(4)});
`;
const StyledTimelineItemContainer = styled.div`
align-items: center; align-items: center;
align-self: stretch; align-self: stretch;
display: flex; display: flex;
gap: ${({ theme }) => theme.spacing(4)}; gap: ${({ theme }) => theme.spacing(4)};
height: ${({ isGap, theme }) =>
isGap ? (useIsMobile() ? theme.spacing(6) : theme.spacing(3)) : 'auto'};
overflow: hidden;
white-space: nowrap;
`; `;
type TimelineActivityProps = { type TimelineActivityProps = {
@ -129,66 +145,80 @@ type TimelineActivityProps = {
| 'type' | 'type'
| 'comments' | 'comments'
| 'dueAt' | 'dueAt'
> & { author: Pick<WorkspaceMember, 'name'> } & { > & { author: Pick<WorkspaceMember, 'name' | 'avatarUrl'> } & {
assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null; assignee?: Pick<WorkspaceMember, 'id' | 'name' | 'avatarUrl'> | null;
}; };
isLastActivity?: boolean;
}; };
export const TimelineActivity = ({ activity }: TimelineActivityProps) => { export const TimelineActivity = ({
activity,
isLastActivity,
}: TimelineActivityProps) => {
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(activity.createdAt); const beautifiedCreatedAt = beautifyPastDateRelativeToNow(activity.createdAt);
const exactCreatedAt = beautifyExactDateTime(activity.createdAt); const exactCreatedAt = beautifyExactDateTime(activity.createdAt);
const body = JSON.parse(
isNonEmptyString(activity.body) ? activity.body : '{}',
)[0]?.content[0]?.text;
const openActivityRightDrawer = useOpenActivityRightDrawer(); const openActivityRightDrawer = useOpenActivityRightDrawer();
const { completeTask } = useCompleteTask(activity); const theme = useTheme();
return ( return (
<> <>
<StyledTimelineItemContainer> <StyledTimelineItemContainer>
<StyledIconContainer> <StyledAvatarContainer>
<IconNotes /> <Avatar
</StyledIconContainer> avatarUrl={activity.author.avatarUrl}
<StyledItemTitleContainer> placeholder={activity.author.name.firstName ?? ''}
<span> size="sm"
{activity.author.name.firstName + type="rounded"
' ' + />
activity.author.name.lastName} </StyledAvatarContainer>
</span> <StyledItemContainer>
created a {activity.type.toLowerCase()} <StyledItemTitleContainer>
</StyledItemTitleContainer> <StyledItemAuthorText>
<StyledItemTitleDate id={`id-${activity.id}`}> <span>
{beautifiedCreatedAt} {activity.author.name.firstName} {activity.author.name.lastName}
</StyledItemTitleDate> </span>
<StyledTooltip created a {activity.type.toLowerCase()}
anchorSelect={`#id-${activity.id}`} </StyledItemAuthorText>
content={exactCreatedAt} <StyledItemTitle>
clickable <StyledIconContainer>
noArrow {activity.type === 'Note' && (
/> <IconNotes size={theme.icon.size.sm} />
</StyledTimelineItemContainer> )}
<StyledTimelineItemContainer> {activity.type === 'Task' && (
<StyledVerticalLineContainer> <IconCheckbox size={theme.icon.size.sm} />
<StyledVerticalLine></StyledVerticalLine> )}
</StyledVerticalLineContainer> </StyledIconContainer>
<StyledCardContainer> {(activity.type === 'Note' || activity.type === 'Task') && (
<StyledCard onClick={() => openActivityRightDrawer(activity.id)}> <StyledActivityTitle
<StyledCardDetailsContainer> onClick={() => openActivityRightDrawer(activity.id)}
<TimelineActivityTitle >
title={activity.title ?? ''}
completed={!!activity.completedAt} <StyledActivityLink title={activity.title ?? '(No Title)'}>
type={activity.type} {activity.title ?? '(No Title)'}
onCompletionChange={completeTask} </StyledActivityLink>
/>
<StyledCardContent> </StyledActivityTitle>
{body && <OverflowingTextWithTooltip text={body ? body : ''} />} )}
</StyledCardContent> </StyledItemTitle>
</StyledCardDetailsContainer> </StyledItemTitleContainer>
<TimelineActivityCardFooter activity={activity} /> <StyledItemTitleDate id={`id-${activity.id}`}>
</StyledCard> {beautifiedCreatedAt}
</StyledCardContainer> </StyledItemTitleDate>
<StyledTooltip
anchorSelect={`#id-${activity.id}`}
content={exactCreatedAt}
clickable
noArrow
/>
</StyledItemContainer>
</StyledTimelineItemContainer> </StyledTimelineItemContainer>
{!isLastActivity && (
<StyledTimelineItemContainer isGap>
<StyledVerticalLineContainer>
<StyledVerticalLine></StyledVerticalLine>
</StyledVerticalLineContainer>
</StyledTimelineItemContainer>
)}
</> </>
); );
}; };

View File

@ -1,12 +1,11 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ActivityForDrawer } from '@/activities/types/ActivityForDrawer'; import { ActivityForDrawer } from '@/activities/types/ActivityForDrawer';
import { IconCircleDot } from '@/ui/display/icon';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { TimelineActivity } from './TimelineActivity'; import { groupActivitiesByMonth } from '../utils/groupActivitiesByMonth';
import { TimelineActivityGroup } from './TimelingeActivityGroup';
const StyledTimelineContainer = styled.div` const StyledTimelineContainer = styled.div`
align-items: center; align-items: center;
@ -18,15 +17,8 @@ const StyledTimelineContainer = styled.div`
gap: ${({ theme }) => theme.spacing(1)}; gap: ${({ theme }) => theme.spacing(1)};
justify-content: flex-start; justify-content: flex-start;
padding: ${({ theme }) => theme.spacing(3)} ${({ theme }) => theme.spacing(4)}; padding: ${({ theme }) => theme.spacing(4)};
`; width: calc(100% - ${({ theme }) => theme.spacing(8)});
const StyledStartIcon = styled.div`
align-self: flex-start;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
height: 20px;
width: 20px;
`; `;
const StyledScrollWrapper = styled(ScrollWrapper)``; const StyledScrollWrapper = styled(ScrollWrapper)``;
@ -38,16 +30,26 @@ export type TimelineItemsContainerProps = {
export const TimelineItemsContainer = ({ export const TimelineItemsContainer = ({
activities, activities,
}: TimelineItemsContainerProps) => { }: TimelineItemsContainerProps) => {
const theme = useTheme(); const groupedActivities = groupActivitiesByMonth(activities);
return ( return (
<StyledScrollWrapper> <StyledScrollWrapper>
<StyledTimelineContainer> <StyledTimelineContainer>
{activities.map((activity) => ( {groupedActivities.map((group, index) => (
<TimelineActivity key={activity.id} activity={activity} /> <TimelineActivityGroup
key={group.year.toString() + group.month}
group={group}
month={new Date(group.items[0].createdAt).toLocaleString(
'default',
{ month: 'long' },
)}
year={
index === 0 || group.year !== groupedActivities[index - 1].year
? group.year
: undefined
}
/>
))} ))}
<StyledStartIcon>
<IconCircleDot size={theme.icon.size.lg} />
</StyledStartIcon>
</StyledTimelineContainer> </StyledTimelineContainer>
</StyledScrollWrapper> </StyledScrollWrapper>
); );

View File

@ -0,0 +1,78 @@
import styled from '@emotion/styled';
import { ActivityGroup } from '../utils/groupActivitiesByMonth';
import { TimelineActivity } from './TimelineActivity';
type TimelineActivityGroupProps = {
group: ActivityGroup;
month: string;
year?: number;
};
const StyledActivityGroup = styled.div`
display: flex;
flex-flow: column;
gap: ${({ theme }) => theme.spacing(4)};
margin-bottom: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
const StyledActivityGroupContainer = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
position: relative;
`;
const StyledActivityGroupBar = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.xl};
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
position: absolute;
top: 0;
width: 24px;
`;
const StyledMonthSeperator = styled.div`
align-items: center;
align-self: stretch;
color: ${({ theme }) => theme.font.color.light};
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledMonthSeperatorLine = styled.div`
background: ${({ theme }) => theme.border.color.light};
border-radius: 50px;
flex: 1 0 0;
height: 1px;
`;
export const TimelineActivityGroup = ({
group,
month,
year,
}: TimelineActivityGroupProps) => {
return (
<StyledActivityGroup>
<StyledMonthSeperator>
{month} {year}
<StyledMonthSeperatorLine />
</StyledMonthSeperator>
<StyledActivityGroupContainer>
<StyledActivityGroupBar />
{group.items.map((activity, index) => (
<TimelineActivity
key={activity.id}
activity={activity}
isLastActivity={index === group.items.length - 1}
/>
))}
</StyledActivityGroupContainer>
</StyledActivityGroup>
);
};

View File

@ -0,0 +1,31 @@
import { ActivityForDrawer } from '@/activities/types/ActivityForDrawer';
export interface ActivityGroup {
month: number;
year: number;
items: ActivityForDrawer[];
}
export const groupActivitiesByMonth = (activities: ActivityForDrawer[]) => {
const acitivityGroups: ActivityGroup[] = [];
for (const activity of activities) {
const d = new Date(activity.createdAt);
const month = d.getMonth();
const year = d.getFullYear();
const matchingGroup = acitivityGroups.find(
(x) => x.year === year && x.month === month,
);
if (matchingGroup) {
matchingGroup.items.push(activity);
} else {
acitivityGroups.push({
year,
month,
items: [activity],
});
}
}
return acitivityGroups.sort((a, b) => b.year - a.year || b.month - a.month);
};

View File

@ -28,6 +28,7 @@ const StyledContainer = styled.div`
gap: ${({ theme }) => theme.spacing(2)}; gap: ${({ theme }) => theme.spacing(2)};
height: 40px; height: 40px;
padding-left: ${({ theme }) => theme.spacing(2)}; padding-left: ${({ theme }) => theme.spacing(2)};
user-select: none;
`; `;
export const TabList = ({ tabs, context }: TabListProps) => { export const TabList = ({ tabs, context }: TabListProps) => {

View File

@ -5,6 +5,7 @@ const common = {
xs: '2px', xs: '2px',
sm: '4px', sm: '4px',
md: '8px', md: '8px',
xl: '20px',
rounded: '100%', rounded: '100%',
}, },
}; };