diff --git a/packages/twenty-front/src/modules/activities/events/components/EventList.tsx b/packages/twenty-front/src/modules/activities/events/components/EventList.tsx index 158e3ba1215f..92219b4e8430 100644 --- a/packages/twenty-front/src/modules/activities/events/components/EventList.tsx +++ b/packages/twenty-front/src/modules/activities/events/components/EventList.tsx @@ -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) => )} - + + + {groupedEvents.map((group, index) => ( + + ))} + + ); }; diff --git a/packages/twenty-front/src/modules/activities/events/components/EventRow.tsx b/packages/twenty-front/src/modules/activities/events/components/EventRow.tsx index d7bbc2d7556b..b392d3961e04 100644 --- a/packages/twenty-front/src/modules/activities/events/components/EventRow.tsx +++ b/packages/twenty-front/src/modules/activities/events/components/EventRow.tsx @@ -1,11 +1,203 @@ +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 = properties?.diff; + + const isEventType = (type: 'created' | 'updated') => { + return ( + event.name === type + '.' + targetableObject.targetObjectNameSingular + ); + }; -export const EventRow = ({ event }: { event: Event }) => { return ( <> -

- {event.name}:

{event.properties}
-

+ + + {isEventType('created') && } + {isEventType('updated') && } + {!isEventType('created') && !isEventType('updated') && ( + + )} + + + + + {event.workspaceMember?.name.firstName}{' '} + {event.workspaceMember?.name.lastName} + + + {isEventType('created') && 'created'} + {isEventType('updated') && 'updated'} + {!isEventType('created') && !isEventType('updated') && event.name} + + + {isEventType('created') && + `a new ${targetableObject.targetObjectNameSingular}`} + {isEventType('updated') && + Object.entries(diff).map(([key, value]) => ( + + ))} + {!isEventType('created') && + !isEventType('updated') && + JSON.stringify(diff)} + + + + {beautifiedCreatedAt} + + + + + {!isLastEvent && ( + + + + + + )} ); }; diff --git a/packages/twenty-front/src/modules/activities/events/components/EventUpdateProperty.tsx b/packages/twenty-front/src/modules/activities/events/components/EventUpdateProperty.tsx new file mode 100644 index 000000000000..2b9c998e03ed --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/components/EventUpdateProperty.tsx @@ -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 ( + + {propertyName} + + {after} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/events/components/Events.tsx b/packages/twenty-front/src/modules/activities/events/components/Events.tsx index 11536d79ac9d..5caccae98c59 100644 --- a/packages/twenty-front/src/modules/activities/events/components/Events.tsx +++ b/packages/twenty-front/src/modules/activities/events/components/Events.tsx @@ -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
No log yet
; + return ( + + + + + No Events + + + There are no events associated with this record.{' '} + + + + ); } return ( - + + + ); }; diff --git a/packages/twenty-front/src/modules/activities/events/components/EventsGroup.tsx b/packages/twenty-front/src/modules/activities/events/components/EventsGroup.tsx new file mode 100644 index 000000000000..091e9913e68e --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/components/EventsGroup.tsx @@ -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 ( + + + {month} {year} + + + + + {group.items.map((event, index) => ( + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/events/types/Event.ts b/packages/twenty-front/src/modules/activities/events/types/Event.ts index 1752b8367c2a..c39ceecd2bb4 100644 --- a/packages/twenty-front/src/modules/activities/events/types/Event.ts +++ b/packages/twenty-front/src/modules/activities/events/types/Event.ts @@ -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; }; diff --git a/packages/twenty-front/src/modules/activities/events/utils/__tests__/groupEventsByMonth.test.ts b/packages/twenty-front/src/modules/activities/events/utils/__tests__/groupEventsByMonth.test.ts new file mode 100644 index 000000000000..917ece6f6fb4 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/utils/__tests__/groupEventsByMonth.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/events/utils/groupEventsByMonth.ts b/packages/twenty-front/src/modules/activities/events/utils/groupEventsByMonth.ts new file mode 100644 index 000000000000..316bd25e858e --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/utils/groupEventsByMonth.ts @@ -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); +}; diff --git a/packages/twenty-front/src/modules/ui/display/icon/index.ts b/packages/twenty-front/src/modules/ui/display/icon/index.ts index 7bfc09d63b04..0fb9a7a4aaf5 100644 --- a/packages/twenty-front/src/modules/ui/display/icon/index.ts +++ b/packages/twenty-front/src/modules/ui/display/icon/index.ts @@ -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, diff --git a/packages/twenty-front/src/testing/mock-data/events.ts b/packages/twenty-front/src/testing/mock-data/events.ts new file mode 100644 index 000000000000..2be81b414f8b --- /dev/null +++ b/packages/twenty-front/src/testing/mock-data/events.ts @@ -0,0 +1,53 @@ +import { Event } from '@/activities/events/types/Event'; + +export const mockedEvents: Array = [ + { + 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, + }, +];