New Timeline (#4936)

Refactored the code to introduce two different concepts:
- AuditLogs (immutable, raw data)
- TimelineActivities (user-friendly, transformed data)

Still some work needed:
- Add message, files, calendar events to timeline (~2 hours if done
naively)
- Refactor repository to try to abstract concept when we can (tbd, wait
for Twenty ORM)
- Introduce ability to display child timelines on parent timeline with
filtering (~2 days)
- Improve UI: add links to open note/task, improve diff display, etc
(half a day)
- Decide the path forward for Task vs Notes: either introduce a new
field type "Record Type" and start going into that direction ; or split
in two objects?
- Trigger updates when a field is changed (will be solved by real-time /
websockets: 2 weeks)
- Integrate behavioral events (1 day for POC, 1 week for
clean/documented)

<img width="1248" alt="Screenshot 2024-04-12 at 09 24 49"
src="https://github.com/twentyhq/twenty/assets/6399865/9428db1a-ab2b-492c-8b0b-d4d9a36e81fa">
This commit is contained in:
Félix Malfait
2024-04-19 17:52:57 +02:00
committed by GitHub
parent 9c8cb52952
commit d145684966
56 changed files with 1314 additions and 368 deletions

View File

@ -0,0 +1,16 @@
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const useLinkedObject = (id: string) => {
const objectMetadataItems: ObjectMetadataItem[] = useRecoilValue(
objectMetadataItemsState,
);
return (
objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.id === id,
) ?? null
);
};

View File

@ -1,16 +1,17 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { EventsGroup } from '@/activities/events/components/EventsGroup';
import { Event } from '@/activities/events/types/Event';
import { groupEventsByMonth } from '@/activities/events/utils/groupEventsByMonth';
import { EventsGroup } from '@/activities/timelineActivities/components/EventsGroup';
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
import { groupEventsByMonth } from '@/activities/timelineActivities/utils/groupEventsByMonth';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
type EventListProps = {
targetableObject: ActivityTargetableObject;
title: string;
events: Event[];
events: TimelineActivity[];
button?: ReactElement | false;
};
@ -31,12 +32,16 @@ const StyledTimelineContainer = styled.div`
export const EventList = ({ events, targetableObject }: EventListProps) => {
const groupedEvents = groupEventsByMonth(events);
const mainObjectMetadataItem = useObjectMetadataItem({
objectNameSingular: targetableObject.targetObjectNameSingular,
}).objectMetadataItem;
return (
<ScrollWrapper>
<StyledTimelineContainer>
{groupedEvents.map((group, index) => (
<EventsGroup
targetableObject={targetableObject}
mainObjectMetadataItem={mainObjectMetadataItem}
key={group.year.toString() + group.month}
group={group}
month={new Date(group.items[0].createdAt).toLocaleString(

View File

@ -1,15 +1,25 @@
import { Tooltip } from 'react-tooltip';
import styled from '@emotion/styled';
import { IconCirclePlus, IconEditCircle, IconFocusCentered } from 'twenty-ui';
import {
IconCheckbox,
IconCirclePlus,
IconEditCircle,
IconFocusCentered,
IconNotes,
useIcons,
} from 'twenty-ui';
import { EventUpdateProperty } from '@/activities/events/components/EventUpdateProperty';
import { Event } from '@/activities/events/types/Event';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { useLinkedObject } from '@/activities/timeline/hooks/useLinkedObject';
import { EventUpdateProperty } from '@/activities/timelineActivities/components/EventUpdateProperty';
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import {
beautifyExactDateTime,
beautifyPastDateRelativeToNow,
} from '~/utils/date-utils';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const StyledIconContainer = styled.div`
align-items: center;
@ -43,14 +53,6 @@ const StyledItemContainer = styled.div`
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};
@ -65,6 +67,11 @@ const StyledItemTitle = styled.span`
white-space: nowrap;
`;
const StyledLinkedObject = styled.span`
cursor: pointer;
text-decoration: underline;
`;
const StyledItemTitleDate = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
@ -117,16 +124,24 @@ const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>`
white-space: nowrap;
`;
const StyledSummary = styled.summary`
display: flex;
flex: 1;
flex-flow: row ${() => (useIsMobile() ? 'wrap' : 'nowrap')};
gap: ${({ theme }) => theme.spacing(1)};
overflow: hidden;
`;
type EventRowProps = {
targetableObject: ActivityTargetableObject;
mainObjectMetadataItem: ObjectMetadataItem | null;
isLastEvent?: boolean;
event: Event;
event: TimelineActivity;
};
export const EventRow = ({
isLastEvent,
event,
targetableObject,
mainObjectMetadataItem,
}: EventRowProps) => {
const beautifiedCreatedAt = beautifyPastDateRelativeToNow(event.createdAt);
const exactCreatedAt = beautifyExactDateTime(event.createdAt);
@ -135,47 +150,108 @@ export const EventRow = ({
const diff: Record<string, { before: any; after: any }> = properties?.diff;
const isEventType = (type: 'created' | 'updated') => {
return (
event.name === type + '.' + targetableObject.targetObjectNameSingular
);
if (event.name.includes('.')) {
return event.name.split('.')[1] === type;
}
return false;
};
const { getIcon } = useIcons();
const linkedObjectMetadata = useLinkedObject(event.linkedObjectMetadataId);
const linkedObjectLabel = event.name.includes('note')
? 'note'
: event.name.includes('task')
? 'task'
: linkedObjectMetadata?.labelSingular;
const ActivityIcon = event.linkedObjectMetadataId
? event.name.includes('note')
? IconNotes
: event.name.includes('task')
? IconCheckbox
: getIcon(linkedObjectMetadata?.icon)
: isEventType('created')
? IconCirclePlus
: isEventType('updated')
? IconEditCircle
: IconFocusCentered;
const author =
event.workspaceMember?.name.firstName +
' ' +
event.workspaceMember?.name.lastName;
const action = isEventType('created')
? 'created'
: isEventType('updated')
? 'updated'
: event.name;
let description;
if (!isUndefinedOrNull(linkedObjectMetadata)) {
description = 'a ' + linkedObjectLabel;
} else if (!event.linkedObjectMetadataId && isEventType('created')) {
description = `a new ${mainObjectMetadataItem?.labelSingular}`;
} else if (isEventType('updated')) {
const diffKeys = Object.keys(diff);
if (diffKeys.length === 0) {
description = `a ${mainObjectMetadataItem?.labelSingular}`;
} else if (diffKeys.length === 1) {
const [key, value] = Object.entries(diff)[0];
description = [
<EventUpdateProperty
propertyName={key}
after={value?.after as string}
/>,
];
} else if (diffKeys.length === 2) {
description =
mainObjectMetadataItem?.fields.find(
(field) => diffKeys[0] === field.name,
)?.label +
' and ' +
mainObjectMetadataItem?.fields.find(
(field) => diffKeys[1] === field.name,
)?.label;
} else if (diffKeys.length > 2) {
description =
diffKeys[0] + ' and ' + (diffKeys.length - 1) + ' other fields';
}
} else if (!isEventType('created') && !isEventType('updated')) {
description = JSON.stringify(diff);
}
const details = JSON.stringify(diff);
const openActivityRightDrawer = useOpenActivityRightDrawer();
return (
<>
<StyledTimelineItemContainer>
<StyledIconContainer>
{isEventType('created') && <IconCirclePlus />}
{isEventType('updated') && <IconEditCircle />}
{!isEventType('created') && !isEventType('updated') && (
<IconFocusCentered />
)}
<ActivityIcon />
</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>
<details>
<StyledSummary>
<StyledItemAuthorText>{author}</StyledItemAuthorText>
<StyledActionName>{action}</StyledActionName>
<StyledItemTitle>{description}</StyledItemTitle>
{isUndefinedOrNull(linkedObjectMetadata) ? (
<></>
) : (
<StyledLinkedObject
onClick={() => openActivityRightDrawer(event.linkedRecordId)}
>
{event.linkedRecordCachedName}
</StyledLinkedObject>
)}
</StyledSummary>
{details}
</details>
<StyledItemTitleDate id={`id-${event.id}`}>
{beautifiedCreatedAt}
</StyledItemTitleDate>

View File

@ -4,6 +4,7 @@ import { IconArrowRight } from 'twenty-ui';
type EventUpdatePropertyProps = {
propertyName: string;
before?: string;
after?: string;
};
@ -23,9 +24,9 @@ export const EventUpdateProperty = ({
const theme = useTheme();
return (
<StyledContainer>
<StyledPropertyName>{propertyName}</StyledPropertyName>
<StyledPropertyName>{propertyName ?? '(empty)'}</StyledPropertyName>
<IconArrowRight size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
{after}
{JSON.stringify(after)}
</StyledContainer>
);
};

View File

@ -1,14 +1,14 @@
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';
import { EventRow } from '@/activities/timelineActivities/components/EventRow';
import { EventGroup } from '@/activities/timelineActivities/utils/groupEventsByMonth';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
type EventsGroupProps = {
group: EventGroup;
month: string;
year?: number;
targetableObject: ActivityTargetableObject;
mainObjectMetadataItem: ObjectMetadataItem | null;
};
const StyledActivityGroup = styled.div`
@ -57,7 +57,7 @@ export const EventsGroup = ({
group,
month,
year,
targetableObject,
mainObjectMetadataItem,
}: EventsGroupProps) => {
return (
<StyledActivityGroup>
@ -69,7 +69,7 @@ export const EventsGroup = ({
<StyledActivityGroupBar />
{group.items.map((event, index) => (
<EventRow
targetableObject={targetableObject}
mainObjectMetadataItem={mainObjectMetadataItem}
key={event.id}
event={event}
isLastEvent={index === group.items.length - 1}

View File

@ -1,8 +1,9 @@
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 { TimelineCreateButtonGroup } from '@/activities/timeline/components/TimelineCreateButtonGroup';
import { EventList } from '@/activities/timelineActivities/components/EventList';
import { useTimelineActivities } from '@/activities/timelineActivities/hooks/useTimelineActivities';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder';
import {
@ -25,25 +26,26 @@ const StyledMainContainer = styled.div`
justify-content: center;
`;
export const Events = ({
export const TimelineActivities = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const { events } = useEvents(targetableObject);
const { timelineActivities } = useTimelineActivities(targetableObject);
if (!isNonEmptyArray(events)) {
if (!isNonEmptyArray(timelineActivities)) {
return (
<AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="emptyTimeline" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
No Events
Add your first Activity
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
There are no events associated with this record.{' '}
There are no activities associated with this record.{' '}
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<TimelineCreateButtonGroup targetableObject={targetableObject} />
</AnimatedPlaceholderEmptyContainer>
);
}
@ -53,7 +55,7 @@ export const Events = ({
<EventList
targetableObject={targetableObject}
title="All"
events={events ?? []}
events={timelineActivities ?? []}
/>
</StyledMainContainer>
);

View File

@ -1,53 +1,21 @@
import { renderHook } from '@testing-library/react';
import { useEvents } from '@/activities/events/hooks/useEvents';
import { useTimelineActivities } from '@/activities/timelineActivities/hooks/useTimelineActivities';
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: jest.fn(),
}));
describe('useEvent', () => {
describe('useTimelineActivities', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('fetches events correctly for a given targetableObject', () => {
const mockEvents = [
const mockedTimelineActivities = [
{
__typename: 'Event',
id: '166ec73f-26b1-4934-bb3b-c86c8513b99b',
opportunityId: null,
opportunity: null,
personId: null,
person: null,
company: {
__typename: 'Company',
address: 'Paris',
linkedinLink: {
__typename: 'Link',
label: '',
url: '',
},
xLink: {
__typename: 'Link',
label: '',
url: '',
},
position: 4,
domainName: 'microsoft.com',
employees: null,
createdAt: '2024-03-21T16:01:41.809Z',
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: 100000000,
currencyCode: 'USD',
},
idealCustomerProfile: false,
accountOwnerId: null,
updatedAt: '2024-03-22T08:28:44.812Z',
name: 'Microsoft',
id: '460b6fb1-ed89-413a-b31a-962986e67bb4',
},
workspaceMember: {
__typename: 'WorkspaceMember',
locale: 'en',
@ -81,11 +49,13 @@ describe('useEvent', () => {
'@/object-record/hooks/useFindManyRecords',
);
useFindManyRecordsMock.useFindManyRecords.mockReturnValue({
records: mockEvents,
records: mockedTimelineActivities,
});
const { result } = renderHook(() => useEvents(mockTargetableObject));
const { result } = renderHook(() =>
useTimelineActivities(mockTargetableObject),
);
expect(result.current.events).toEqual(mockEvents);
expect(result.current.timelineActivities).toEqual(mockedTimelineActivities);
});
});

View File

@ -1,17 +1,19 @@
import { Event } from '@/activities/events/types/Event';
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
// do we need to test this?
export const useEvents = (targetableObject: ActivityTargetableObject) => {
export const useTimelineActivities = (
targetableObject: ActivityTargetableObject,
) => {
const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
});
const { records: events } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Event,
const { records: TimelineActivities } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.TimelineActivity,
filter: {
[targetableObjectFieldIdName]: {
eq: targetableObject.id,
@ -20,9 +22,10 @@ export const useEvents = (targetableObject: ActivityTargetableObject) => {
orderBy: {
createdAt: 'DescNullsFirst',
},
fetchPolicy: 'network-only',
});
return {
events: events as Event[],
timelineActivities: TimelineActivities as TimelineActivity[],
};
};

View File

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

View File

@ -1,10 +1,10 @@
import { mockedEvents } from '~/testing/mock-data/events';
import { mockedTimelineActivities } from '~/testing/mock-data/timeline-activities';
import { groupEventsByMonth } from '../groupEventsByMonth';
describe('groupEventsByMonth', () => {
it('should group activities by month', () => {
const grouped = groupEventsByMonth(mockedEvents);
const grouped = groupEventsByMonth(mockedTimelineActivities);
expect(grouped).toHaveLength(2);
expect(grouped[0].items).toHaveLength(1);

View File

@ -1,13 +1,13 @@
import { Event } from '@/activities/events/types/Event';
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
import { isDefined } from '~/utils/isDefined';
export type EventGroup = {
month: number;
year: number;
items: Event[];
items: TimelineActivity[];
};
export const groupEventsByMonth = (events: Event[]) => {
export const groupEventsByMonth = (events: TimelineActivity[]) => {
const acitivityGroups: EventGroup[] = [];
for (const event of events) {

View File

@ -33,7 +33,6 @@ export const objectMetadataItemFamilySelector = selectorFamily<
) ?? null
);
}
return null;
},
});

View File

@ -9,7 +9,7 @@ export enum CoreObjectNameSingular {
Comment = 'comment',
Company = 'company',
ConnectedAccount = 'connectedAccount',
Event = 'event',
TimelineActivity = 'timelineActivity',
Favorite = 'favorite',
Message = 'message',
MessageChannel = 'messageChannel',

View File

@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react';
import { useQuery } from '@apollo/client';
import { useQuery, WatchQueryFetchPolicy } from '@apollo/client';
import { isNonEmptyArray } from '@apollo/client/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
@ -32,6 +32,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
onCompleted,
skip,
queryFields,
fetchPolicy,
}: ObjectMetadataItemIdentifier &
ObjectRecordQueryVariables & {
onCompleted?: (
@ -44,6 +45,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
skip?: boolean;
depth?: number;
queryFields?: Record<string, any>;
fetchPolicy?: WatchQueryFetchPolicy;
}) => {
const findManyQueryStateIdentifier =
objectNameSingular +
@ -84,6 +86,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
limit,
orderBy,
},
fetchPolicy: fetchPolicy,
onCompleted: (data) => {
if (!isDefined(data)) {
onCompleted?.([]);

View File

@ -11,12 +11,12 @@ import {
import { Calendar } from '@/activities/calendar/components/Calendar';
import { EmailThreads } from '@/activities/emails/components/EmailThreads';
import { Events } from '@/activities/events/components/Events';
import { Attachments } from '@/activities/files/components/Attachments';
import { Notes } from '@/activities/notes/components/Notes';
import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks';
import { Timeline } from '@/activities/timeline/components/Timeline';
import { TimelineQueryEffect } from '@/activities/timeline/components/TimelineQueryEffect';
import { TimelineActivities } from '@/activities/timelineActivities/components/TimelineActivities';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { TabList } from '@/ui/layout/tab/components/TabList';
@ -144,7 +144,9 @@ export const ShowPageRightContainer = ({
{activeTabId === 'calendar' && (
<Calendar targetableObject={targetableObject} />
)}
{activeTabId === 'logs' && <Events targetableObject={targetableObject} />}
{activeTabId === 'logs' && (
<TimelineActivities targetableObject={targetableObject} />
)}
</StyledShowPageRightContainer>
);
};