diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml
index 1b39e5396406..3518ede354fd 100644
--- a/.github/workflows/ci-server.yaml
+++ b/.github/workflows/ci-server.yaml
@@ -31,5 +31,12 @@ jobs:
run: yarn nx lint twenty-server
- name: Server / Run jest tests
run: yarn nx test:unit twenty-server
- # - name: Server / Run e2e tests
- # run: yarn nx test:e2e twenty-server
+ - name: Server / Build
+ run: yarn nx build twenty-server
+ - name: Server / Write .env
+ run: |
+ cd packages/twenty-server
+ cp .env.example .env
+ - name: Worker / Run
+ run: MESSAGE_QUEUE_TYPE=sync yarn nx worker twenty-server
+>>>>>>> main
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,
+ },
+];