Basic log styling (#4634)

* basic log styling

* fixed mobile wrap and changed default event icon

* add group by test
This commit is contained in:
brendanlaschke
2024-03-25 10:15:39 +01:00
committed by GitHub
parent 0a15994695
commit 922d632607
10 changed files with 505 additions and 20 deletions

View File

@ -1,8 +1,11 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { EventRow } from '@/activities/events/components/EventRow';
import { EventsGroup } from '@/activities/events/components/EventsGroup';
import { Event } from '@/activities/events/types/Event';
import { groupEventsByMonth } from '@/activities/events/utils/groupEventsByMonth';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
type EventListProps = {
targetableObject: ActivityTargetableObject;
@ -11,12 +14,43 @@ type EventListProps = {
button?: ReactElement | false;
};
export const EventList = ({ events }: EventListProps) => {
const StyledTimelineContainer = styled.div`
align-items: center;
align-self: stretch;
display: flex;
flex: 1 0 0;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: flex-start;
padding: ${({ theme }) => theme.spacing(4)};
width: calc(100% - ${({ theme }) => theme.spacing(8)});
`;
export const EventList = ({ events, targetableObject }: EventListProps) => {
const groupedEvents = groupEventsByMonth(events);
return (
<>
{events &&
events.length > 0 &&
events.map((event: Event) => <EventRow key={event.id} event={event} />)}
</>
<ScrollWrapper>
<StyledTimelineContainer>
{groupedEvents.map((group, index) => (
<EventsGroup
targetableObject={targetableObject}
key={group.year.toString() + group.month}
group={group}
month={new Date(group.items[0].createdAt).toLocaleString(
'default',
{ month: 'long' },
)}
year={
index === 0 || group.year !== groupedEvents[index - 1].year
? group.year
: undefined
}
/>
))}
</StyledTimelineContainer>
</ScrollWrapper>
);
};

View File

@ -1,11 +1,203 @@
import { Event } from '@/activities/events/types/Event';
import { Tooltip } from 'react-tooltip';
import styled from '@emotion/styled';
import { EventUpdateProperty } from '@/activities/events/components/EventUpdateProperty';
import { Event } from '@/activities/events/types/Event';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import {
IconCirclePlus,
IconEditCircle,
IconFocusCentered,
} from '@/ui/display/icon';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import {
beautifyExactDateTime,
beautifyPastDateRelativeToNow,
} from '~/utils/date-utils';
const StyledIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
user-select: none;
height: 16px;
margin: 5px;
justify-content: center;
text-decoration-line: underline;
width: 16px;
z-index: 2;
`;
const StyledActionName = styled.span`
overflow: hidden;
flex: none;
white-space: nowrap;
`;
const StyledItemContainer = styled.div`
align-content: center;
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(1)};
span {
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.span`
display: flex;
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(1)};
white-space: nowrap;
`;
const StyledItemTitle = styled.span`
display: flex;
flex-flow: row nowrap;
overflow: hidden;
white-space: nowrap;
`;
const StyledItemTitleDate = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: flex-end;
margin-left: auto;
`;
const StyledVerticalLineContainer = styled.div`
align-items: center;
align-self: stretch;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: center;
width: 26px;
z-index: 2;
`;
const StyledVerticalLine = styled.div`
align-self: stretch;
background: ${({ theme }) => theme.border.color.light};
flex-shrink: 0;
width: 2px;
`;
const StyledTooltip = styled(Tooltip)`
background-color: ${({ theme }) => theme.background.primary};
box-shadow: 0px 2px 4px 3px
${({ theme }) => theme.background.transparent.light};
box-shadow: 2px 4px 16px 6px
${({ theme }) => theme.background.transparent.light};
color: ${({ theme }) => theme.font.color.primary};
opacity: 1;
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>`
align-items: center;
align-self: stretch;
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
height: ${({ isGap, theme }) =>
isGap ? (useIsMobile() ? theme.spacing(6) : theme.spacing(3)) : 'auto'};
overflow: hidden;
white-space: nowrap;
`;
type EventRowProps = {
targetableObject: ActivityTargetableObject;
isLastEvent?: boolean;
event: Event;
};
export const EventRow = ({
isLastEvent,
event,
targetableObject,
}: EventRowProps) => {
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(event.createdAt);
const exactCreatedAt = beautifyExactDateTime(event.createdAt);
const properties = JSON.parse(event.properties);
const diff: Record<string, { before: any; after: any }> = properties?.diff;
const isEventType = (type: 'created' | 'updated') => {
return (
event.name === type + '.' + targetableObject.targetObjectNameSingular
);
};
export const EventRow = ({ event }: { event: Event }) => {
return (
<>
<p>
{event.name}:<pre>{event.properties}</pre>
</p>
<StyledTimelineItemContainer>
<StyledIconContainer>
{isEventType('created') && <IconCirclePlus />}
{isEventType('updated') && <IconEditCircle />}
{!isEventType('created') && !isEventType('updated') && (
<IconFocusCentered />
)}
</StyledIconContainer>
<StyledItemContainer>
<StyledItemTitleContainer>
<StyledItemAuthorText>
{event.workspaceMember?.name.firstName}{' '}
{event.workspaceMember?.name.lastName}
</StyledItemAuthorText>
<StyledActionName>
{isEventType('created') && 'created'}
{isEventType('updated') && 'updated'}
{!isEventType('created') && !isEventType('updated') && event.name}
</StyledActionName>
<StyledItemTitle>
{isEventType('created') &&
`a new ${targetableObject.targetObjectNameSingular}`}
{isEventType('updated') &&
Object.entries(diff).map(([key, value]) => (
<EventUpdateProperty
propertyName={key}
after={value?.after}
></EventUpdateProperty>
))}
{!isEventType('created') &&
!isEventType('updated') &&
JSON.stringify(diff)}
</StyledItemTitle>
</StyledItemTitleContainer>
<StyledItemTitleDate id={`id-${event.id}`}>
{beautifiedCreatedAt}
</StyledItemTitleDate>
<StyledTooltip
anchorSelect={`#id-${event.id}`}
content={exactCreatedAt}
clickable
noArrow
/>
</StyledItemContainer>
</StyledTimelineItemContainer>
{!isLastEvent && (
<StyledTimelineItemContainer isGap>
<StyledVerticalLineContainer>
<StyledVerticalLine></StyledVerticalLine>
</StyledVerticalLineContainer>
</StyledTimelineItemContainer>
)}
</>
);
};

View File

@ -0,0 +1,32 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconArrowRight } from '@/ui/display/icon';
type EventUpdatePropertyProps = {
propertyName: string;
after?: string;
};
const StyledContainer = styled.div`
display: flex;
margin-right: ${({ theme }) => theme.spacing(1)};
gap: ${({ theme }) => theme.spacing(1)};
white-space: nowrap;
`;
const StyledPropertyName = styled.div``;
export const EventUpdateProperty = ({
propertyName,
after,
}: EventUpdatePropertyProps) => {
const theme = useTheme();
return (
<StyledContainer>
<StyledPropertyName>{propertyName}</StyledPropertyName>
<IconArrowRight size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
{after}
</StyledContainer>
);
};

View File

@ -1,8 +1,29 @@
import styled from '@emotion/styled';
import { isNonEmptyArray } from '@sniptt/guards';
import { EventList } from '@/activities/events/components/EventList';
import { useEvents } from '@/activities/events/hooks/useEvents';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder';
import {
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
const StyledMainContainer = styled.div`
align-items: flex-start;
align-self: stretch;
border-top: ${({ theme }) =>
useIsMobile() ? `1px solid ${theme.border.color.medium}` : 'none'};
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
`;
export const Events = ({
targetableObject,
@ -12,14 +33,28 @@ export const Events = ({
const { events } = useEvents(targetableObject);
if (!isNonEmptyArray(events)) {
return <div>No log yet</div>;
return (
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="emptyTimeline" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
No Events
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
There are no events associated with this record.{' '}
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
);
}
return (
<EventList
targetableObject={targetableObject}
title="All"
events={events ?? []}
/>
<StyledMainContainer>
<EventList
targetableObject={targetableObject}
title="All"
events={events ?? []}
/>
</StyledMainContainer>
);
};

View File

@ -0,0 +1,81 @@
import styled from '@emotion/styled';
import { EventRow } from '@/activities/events/components/EventRow';
import { EventGroup } from '@/activities/events/utils/groupEventsByMonth';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
type EventsGroupProps = {
group: EventGroup;
month: string;
year?: number;
targetableObject: ActivityTargetableObject;
};
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 EventsGroup = ({
group,
month,
year,
targetableObject,
}: EventsGroupProps) => {
return (
<StyledActivityGroup>
<StyledMonthSeperator>
{month} {year}
<StyledMonthSeperatorLine />
</StyledMonthSeperator>
<StyledActivityGroupContainer>
<StyledActivityGroupBar />
{group.items.map((event, index) => (
<EventRow
targetableObject={targetableObject}
key={event.id}
event={event}
isLastEvent={index === group.items.length - 1}
/>
))}
</StyledActivityGroupContainer>
</StyledActivityGroup>
);
};

View File

@ -1,12 +1,15 @@
import { WorkspaceMember } from '~/generated/graphql';
export type Event = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
opportunityId: string | null;
companyId: string;
personId: string;
companyId: string | null;
personId: string | null;
workspaceMemberId: string;
workspaceMember: WorkspaceMember;
properties: any;
name: string;
};

View File

@ -0,0 +1,19 @@
import { mockedEvents } from '~/testing/mock-data/events';
import { groupEventsByMonth } from '../groupEventsByMonth';
describe('groupEventsByMonth', () => {
it('should group activities by month', () => {
const grouped = groupEventsByMonth(mockedEvents as unknown as Event[]);
expect(grouped).toHaveLength(2);
expect(grouped[0].items).toHaveLength(1);
expect(grouped[1].items).toHaveLength(1);
expect(grouped[0].year).toBe(new Date().getFullYear());
expect(grouped[1].year).toBe(2023);
expect(grouped[0].month).toBe(new Date().getMonth());
expect(grouped[1].month).toBe(3);
});
});

View File

@ -0,0 +1,33 @@
import { Event } from '@/activities/events/types/Event';
import { isDefined } from '~/utils/isDefined';
export type EventGroup = {
month: number;
year: number;
items: Event[];
};
export const groupEventsByMonth = (events: Event[]) => {
const acitivityGroups: EventGroup[] = [];
for (const event of events) {
const d = new Date(event.createdAt);
const month = d.getMonth();
const year = d.getFullYear();
const matchingGroup = acitivityGroups.find(
(x) => x.year === year && x.month === month,
);
if (isDefined(matchingGroup)) {
matchingGroup.items.push(event);
} else {
acitivityGroups.push({
year,
month,
items: [event],
});
}
}
return acitivityGroups.sort((a, b) => b.year - a.year || b.month - a.month);
};

View File

@ -37,6 +37,7 @@ export {
IconChevronUp,
IconCircleDot,
IconCircleOff,
IconCirclePlus,
IconCircleX,
IconClick,
IconCode,
@ -56,6 +57,7 @@ export {
IconDoorEnter,
IconDotsVertical,
IconDownload,
IconEditCircle,
IconEye,
IconEyeOff,
IconFile,
@ -66,6 +68,7 @@ export {
IconFileUpload,
IconFileZip,
IconFilterOff,
IconFocusCentered,
IconForbid,
IconGripVertical,
IconH1,

View File

@ -0,0 +1,53 @@
import { Event } from '@/activities/events/types/Event';
export const mockedEvents: Array<Event> = [
{
properties: '{"diff": {"address": {"after": "TEST", "before": ""}}}',
updatedAt: '2023-04-26T10:12:42.33625+00:00',
id: '79f84835-b2f9-4ab5-8ab9-17dbcc45dda3',
personId: null,
companyId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d0',
name: 'updated.company',
opportunityId: null,
createdAt: '2023-04-26T10:12:42.33625+00:00',
workspaceMember: {
__typename: 'WorkspaceMember',
id: '20202020-0687-4c41-b707-ed1bfca972a7',
avatarUrl: '',
locale: 'en',
name: {
__typename: 'FullName',
firstName: 'Tim',
lastName: 'Apple',
},
colorScheme: 'Light',
},
workspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
deletedAt: null,
},
{
properties:
'{"after": {"id": "ce40eca0-8f4b-4bba-ba91-5cbd870c64d0", "name": "", "xLink": {"url": "", "label": ""}, "events": {"edges": [], "__typename": "eventConnection"}, "people": {"edges": [], "__typename": "personConnection"}, "address": "", "position": 0.5, "createdAt": "2024-03-24T21:33:45.765295", "employees": null, "favorites": {"edges": [], "__typename": "favoriteConnection"}, "updatedAt": "2024-03-24T21:33:45.765295", "__typename": "company", "domainName": "", "attachments": {"edges": [], "__typename": "attachmentConnection"}, "accountOwner": null, "linkedinLink": {"url": "", "label": ""}, "opportunities": {"edges": [], "__typename": "opportunityConnection"}, "accountOwnerId": null, "activityTargets": {"edges": [], "__typename": "activityTargetConnection"}, "idealCustomerProfile": false, "annualRecurringRevenue": {"amountMicros": null, "currencyCode": ""}}}',
updatedAt: new Date().toISOString(),
id: '1ad72a42-6ab4-4474-a62a-a57cae3c0298',
personId: null,
companyId: 'ce40eca0-8f4b-4bba-ba91-5cbd870c64d0',
name: 'created.company',
opportunityId: null,
createdAt: new Date().toISOString(),
workspaceMember: {
__typename: 'WorkspaceMember',
id: '20202020-0687-4c41-b707-ed1bfca972a7',
avatarUrl: '',
locale: 'en',
name: {
__typename: 'FullName',
firstName: 'Tim',
lastName: 'Apple',
},
colorScheme: 'Light',
},
workspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7',
deletedAt: null,
},
];