feat(server): derive email/calendar timelines from object relations (#21684)

Simplifies our existing implementation that uses three different GraphQL
endpoints to just one `getTimelineEventsFrom{Person, Company,
Opportunity}Id` to `getTimelineCalendarEventsFromObjectRecord`


/closes https://github.com/twentyhq/twenty/issues/19676

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21684?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
neo773
2026-06-19 05:52:27 +05:30
committed by GitHub
parent 1b3d933900
commit 814b43ca41
27 changed files with 1286 additions and 484 deletions
@@ -6,14 +6,11 @@ import { format, getYear } from 'date-fns';
import { CalendarMonthCard } from '@/activities/calendar/components/CalendarMonthCard';
import { TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE } from '@/activities/calendar/constants/Calendar';
import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext';
import { getTimelineCalendarEventsFromCompanyId } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromCompanyId';
import { getTimelineCalendarEventsFromOpportunityId } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromOpportunityId';
import { getTimelineCalendarEventsFromPersonId } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromPersonId';
import { getTimelineCalendarEventsFromObjectRecord } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromObjectRecord';
import { useCalendarEvents } from '@/activities/calendar/hooks/useCalendarEvents';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { H3Title } from 'twenty-ui/typography';
import {
@@ -52,33 +49,17 @@ export const CalendarEventsCard = () => {
const targetRecord = useTargetRecord();
const { localeCatalog } = useAtomStateValue(dateLocaleState);
const [query, queryName] =
targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Person
? [
getTimelineCalendarEventsFromPersonId,
'getTimelineCalendarEventsFromPersonId',
]
: targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Company
? [
getTimelineCalendarEventsFromCompanyId,
'getTimelineCalendarEventsFromCompanyId',
]
: [
getTimelineCalendarEventsFromOpportunityId,
'getTimelineCalendarEventsFromOpportunityId',
];
const { data, firstQueryLoading, isFetchingMore, fetchMoreRecords } =
useCustomResolver<TimelineCalendarEventsWithTotal>(
query,
queryName,
getTimelineCalendarEventsFromObjectRecord,
'getTimelineCalendarEventsFromObjectRecord',
'timelineCalendarEvents',
targetRecord,
TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE,
);
const { timelineCalendarEvents, totalNumberOfCalendarEvents } =
data?.[queryName] ?? {};
data?.getTimelineCalendarEventsFromObjectRecord ?? {};
const {
calendarEventsByDayTime,
@@ -3,7 +3,7 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
import { HttpResponse, graphql } from 'msw';
import { CalendarEventsCard } from '@/activities/calendar/components/CalendarEventsCard';
import { getTimelineCalendarEventsFromCompanyId } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromCompanyId';
import { getTimelineCalendarEventsFromObjectRecord } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromObjectRecord';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { ComponentDecorator } from 'twenty-ui/testing';
@@ -41,12 +41,12 @@ const meta: Meta<typeof CalendarEventsCard> = {
handlers: [
...graphqlMocks.handlers,
graphql.query(
getOperationName(getTimelineCalendarEventsFromCompanyId) ?? '',
getOperationName(getTimelineCalendarEventsFromObjectRecord) ?? '',
({ variables }) => {
if (variables.page > 1) {
return HttpResponse.json({
data: {
getTimelineCalendarEventsFromCompanyId: {
getTimelineCalendarEventsFromObjectRecord: {
__typename: 'TimelineCalendarEventsWithTotal',
totalNumberOfCalendarEvents: 3,
timelineCalendarEvents: [],
@@ -56,7 +56,7 @@ const meta: Meta<typeof CalendarEventsCard> = {
}
return HttpResponse.json({
data: {
getTimelineCalendarEventsFromCompanyId: {
getTimelineCalendarEventsFromObjectRecord: {
__typename: 'TimelineCalendarEventsWithTotal',
totalNumberOfCalendarEvents: 3,
timelineCalendarEvents: mockedTimelineCalendarEvents,
@@ -1,14 +1,16 @@
import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/graphql/queries/fragments/timelineCalendarEventWithTotalFragment';
import { gql } from '@apollo/client';
export const getTimelineCalendarEventsFromCompanyId = gql`
query GetTimelineCalendarEventsFromCompanyId(
$companyId: UUID!
export const getTimelineCalendarEventsFromObjectRecord = gql`
query GetTimelineCalendarEventsFromObjectRecord(
$objectNameSingular: String!
$recordId: UUID!
$page: Int!
$pageSize: Int!
) {
getTimelineCalendarEventsFromCompanyId(
companyId: $companyId
getTimelineCalendarEventsFromObjectRecord(
objectNameSingular: $objectNameSingular
recordId: $recordId
page: $page
pageSize: $pageSize
) {
@@ -1,20 +0,0 @@
import { gql } from '@apollo/client';
import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/graphql/queries/fragments/timelineCalendarEventWithTotalFragment';
export const getTimelineCalendarEventsFromOpportunityId = gql`
query GetTimelineCalendarEventsFromOpportunityId(
$opportunityId: UUID!
$page: Int!
$pageSize: Int!
) {
getTimelineCalendarEventsFromOpportunityId(
opportunityId: $opportunityId
page: $page
pageSize: $pageSize
) {
...TimelineCalendarEventsWithTotalFragment
}
}
${timelineCalendarEventWithTotalFragment}
`;
@@ -1,19 +0,0 @@
import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/graphql/queries/fragments/timelineCalendarEventWithTotalFragment';
import { gql } from '@apollo/client';
export const getTimelineCalendarEventsFromPersonId = gql`
query GetTimelineCalendarEventsFromPersonId(
$personId: UUID!
$page: Int!
$pageSize: Int!
) {
getTimelineCalendarEventsFromPersonId(
personId: $personId
page: $page
pageSize: $pageSize
) {
...TimelineCalendarEventsWithTotalFragment
}
}
${timelineCalendarEventWithTotalFragment}
`;
@@ -7,11 +7,8 @@ import { ComposeEmailButton } from '@/activities/emails/components/ComposeEmailB
import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview';
import { EmptyInboxPlaceholder } from '@/activities/emails/components/EmptyInboxPlaceholder';
import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
import { getTimelineThreadsFromOpportunityId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId';
import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId';
import { getTimelineThreadsFromObjectRecord } from '@/activities/emails/graphql/queries/getTimelineThreadsFromObjectRecord';
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { Trans } from '@lingui/react/macro';
import { H1Title, H1TitleFontColor } from 'twenty-ui/typography';
@@ -52,26 +49,17 @@ const StyledEmailCount = styled.span`
export const EmailsCard = () => {
const targetRecord = useTargetRecord();
const [query, queryName] =
targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Person
? [getTimelineThreadsFromPersonId, 'getTimelineThreadsFromPersonId']
: targetRecord.targetObjectNameSingular === CoreObjectNameSingular.Company
? [getTimelineThreadsFromCompanyId, 'getTimelineThreadsFromCompanyId']
: [
getTimelineThreadsFromOpportunityId,
'getTimelineThreadsFromOpportunityId',
];
const { data, firstQueryLoading, isFetchingMore, fetchMoreRecords } =
useCustomResolver<TimelineThreadsWithTotal>(
query,
queryName,
getTimelineThreadsFromObjectRecord,
'getTimelineThreadsFromObjectRecord',
'timelineThreads',
targetRecord,
TIMELINE_THREADS_DEFAULT_PAGE_SIZE,
);
const { totalNumberOfThreads, timelineThreads } = data?.[queryName] ?? {};
const { totalNumberOfThreads, timelineThreads } =
data?.getTimelineThreadsFromObjectRecord ?? {};
const hasMoreTimelineThreads =
timelineThreads && totalNumberOfThreads
? timelineThreads?.length < totalNumberOfThreads
@@ -1,16 +0,0 @@
import { gql } from '@apollo/client';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
jest.mock('@apollo/client', () => ({
gql: jest.fn().mockImplementation((strings) => {
return strings.map((str: string) => str.trim()).join(' ');
}),
}));
describe('getTimelineThreadsFromCompanyId query', () => {
test('should construct the query correctly', () => {
expect(gql).toHaveBeenCalled();
expect(getTimelineThreadsFromCompanyId).toBeDefined();
});
});
@@ -1,16 +0,0 @@
import { gql } from '@apollo/client';
import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId';
jest.mock('@apollo/client', () => ({
gql: jest.fn().mockImplementation((strings) => {
return strings.map((str: string) => str.trim()).join(' ');
}),
}));
describe('getTimelineThreadsFromPersonId query', () => {
test('should construct the query correctly', () => {
expect(gql).toHaveBeenCalled();
expect(getTimelineThreadsFromPersonId).toBeDefined();
});
});
@@ -1,19 +0,0 @@
import { timelineThreadWithTotalFragment } from '@/activities/emails/graphql/queries/fragments/timelineThreadWithTotalFragment';
import { gql } from '@apollo/client';
export const getTimelineThreadsFromCompanyId = gql`
query GetTimelineThreadsFromCompanyId(
$companyId: UUID!
$page: Int!
$pageSize: Int!
) {
getTimelineThreadsFromCompanyId(
companyId: $companyId
page: $page
pageSize: $pageSize
) {
...TimelineThreadsWithTotalFragment
}
}
${timelineThreadWithTotalFragment}
`;
@@ -1,15 +1,16 @@
import { timelineThreadWithTotalFragment } from '@/activities/emails/graphql/queries/fragments/timelineThreadWithTotalFragment';
import { gql } from '@apollo/client';
import { timelineThreadWithTotalFragment } from '@/activities/emails/graphql/queries/fragments/timelineThreadWithTotalFragment';
export const getTimelineThreadsFromOpportunityId = gql`
query GetTimelineThreadsFromOpportunityId(
$opportunityId: UUID!
export const getTimelineThreadsFromObjectRecord = gql`
query GetTimelineThreadsFromObjectRecord(
$objectNameSingular: String!
$recordId: UUID!
$page: Int!
$pageSize: Int!
) {
getTimelineThreadsFromOpportunityId(
opportunityId: $opportunityId
getTimelineThreadsFromObjectRecord(
objectNameSingular: $objectNameSingular
recordId: $recordId
page: $page
pageSize: $pageSize
) {
@@ -1,20 +0,0 @@
import { gql } from '@apollo/client';
import { timelineThreadWithTotalFragment } from '@/activities/emails/graphql/queries/fragments/timelineThreadWithTotalFragment';
export const getTimelineThreadsFromPersonId = gql`
query GetTimelineThreadsFromPersonId(
$personId: UUID!
$page: Int!
$pageSize: Int!
) {
getTimelineThreadsFromPersonId(
personId: $personId
page: $page
pageSize: $pageSize
) {
...TimelineThreadsWithTotalFragment
}
}
${timelineThreadWithTotalFragment}
`;
@@ -3,9 +3,7 @@ import { useCallback } from 'react';
import { type EmailAttachment } from 'twenty-shared/types';
import { SEND_EMAIL } from '@/activities/emails/graphql/mutations/sendEmail';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId';
import { getTimelineThreadsFromOpportunityId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId';
import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId';
import { getTimelineThreadsFromObjectRecord } from '@/activities/emails/graphql/queries/getTimelineThreadsFromObjectRecord';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { t } from '@lingui/core/macro';
@@ -60,9 +58,7 @@ export const useSendEmail = () => {
await apolloCoreClient.refetchQueries({
include: [
getTimelineThreadsFromCompanyId,
getTimelineThreadsFromPersonId,
getTimelineThreadsFromOpportunityId,
getTimelineThreadsFromObjectRecord,
'FindManyMessages',
'FindManyMessageParticipants',
'FindManyMessageChannelMessageAssociations',
@@ -0,0 +1,57 @@
import { renderHook } from '@testing-library/react';
import { getTimelineThreadsFromObjectRecord } from '@/activities/emails/graphql/queries/getTimelineThreadsFromObjectRecord';
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
jest.mock('@apollo/client/react', () => ({
useQuery: jest.fn(),
}));
jest.mock('@/object-metadata/hooks/useApolloCoreClient', () => ({
useApolloCoreClient: jest.fn(() => ({})),
}));
jest.mock('@/apollo/hooks/useSnackBarOnQueryError', () => ({
useSnackBarOnQueryError: jest.fn(),
}));
const useQueryMock = jest.requireMock('@apollo/client/react').useQuery;
describe('useCustomResolver', () => {
beforeEach(() => {
useQueryMock.mockReturnValue({
data: undefined,
loading: false,
fetchMore: jest.fn(),
error: undefined,
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('queries the timeline resolver by object name and record id for any object', () => {
renderHook(() =>
useCustomResolver(
getTimelineThreadsFromObjectRecord,
'getTimelineThreadsFromObjectRecord',
'timelineThreads',
{ id: 'record-id', targetObjectNameSingular: 'peopleList' },
10,
),
);
expect(useQueryMock).toHaveBeenCalledWith(
getTimelineThreadsFromObjectRecord,
expect.objectContaining({
variables: {
objectNameSingular: 'peopleList',
recordId: 'record-id',
page: 1,
pageSize: 10,
},
}),
);
});
});
@@ -9,7 +9,6 @@ import { useState } from 'react';
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useSnackBarOnQueryError } from '@/apollo/hooks/useSnackBarOnQueryError';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { CoreObjectNameSingular } from 'twenty-shared/types';
type CustomResolverQueryResult<
T extends {
@@ -47,13 +46,8 @@ export const useCustomResolver = <
const [isFetchingMore, setIsFetchingMore] = useState(false);
const queryVariables = {
...(activityTargetableObject.targetObjectNameSingular ===
CoreObjectNameSingular.Person
? { personId: activityTargetableObject.id }
: activityTargetableObject.targetObjectNameSingular ===
CoreObjectNameSingular.Opportunity
? { opportunityId: activityTargetableObject.id }
: { companyId: activityTargetableObject.id }),
objectNameSingular: activityTargetableObject.targetObjectNameSingular,
recordId: activityTargetableObject.id,
page: 1,
pageSize,
};