Compare commits

...
Author SHA1 Message Date
neo773 c7f962bdd2 Update CalendarEventsCard.tsx 2026-04-14 20:37:15 +05:30
neo773 caf80b0943 wip 2026-04-14 20:08:29 +05:30
neo773 392f70856b wip 2026-04-14 18:42:38 +05:30
neo773 934371ce9c wip 2026-04-14 18:16:26 +05:30
19 changed files with 359 additions and 31 deletions
@@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react/macro';
import { useCallback, useState } from 'react';
import { CalendarEventParticipantsResponseStatus } from '@/activities/calendar/components/CalendarEventParticipantsResponseStatus';
import { LinkifiedTextBody } from '@/ui/display/components/LinkifiedTextBody';
import { type CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
@@ -107,9 +108,9 @@ export const CalendarEventDetails = ({
const standardFieldOrder = [
'startsAt',
'endsAt',
'recurrence',
'conferenceLink',
'location',
'description',
];
const standardFields = standardFieldOrder
@@ -121,7 +122,10 @@ export const CalendarEventDetails = ({
.filter(isDefined);
const customFields = inlineFieldMetadataItems.filter(
(field) => field.isCustom && !standardFieldOrder.includes(field.name),
(field) =>
field.isCustom &&
!standardFieldOrder.includes(field.name) &&
field.name !== 'description',
);
const { calendarEventParticipants } = calendarEvent;
@@ -235,6 +239,12 @@ export const CalendarEventDetails = ({
/>
)}
{standardFields.slice(2).map(renderField)}
{calendarEvent.description && (
<LinkifiedTextBody
body={calendarEvent.description}
isDisplayed
/>
)}
{customFields.map(renderField)}
</StyledFields>
</StyledContainer>
@@ -58,6 +58,12 @@ const StyledTime = styled.div`
width: ${themeCssVariables.spacing[26]};
`;
const StyledRecurrence = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-weight: ${themeCssVariables.font.weight.regular};
text-decoration: none;
`;
const StyledTitle = styled.div<{ active: boolean; canceled: boolean }>`
color: ${({ active }) =>
active ? themeCssVariables.font.color.primary : 'inherit'};
@@ -120,6 +126,9 @@ export const CalendarEventRow = ({
{showTitle ? (
<StyledTitle active={!hasEnded} canceled={!!calendarEvent.isCanceled}>
{calendarEvent.title}
{calendarEvent.recurrence && (
<StyledRecurrence>{` · ${calendarEvent.recurrence}`}</StyledRecurrence>
)}
</StyledTitle>
) : (
<CalendarEventNotSharedContent />
@@ -3,6 +3,7 @@ import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { format, getYear } from 'date-fns';
import { CalendarDayCardContent } from '@/activities/calendar/components/CalendarDayCardContent';
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';
@@ -14,6 +15,7 @@ import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomRes
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { H3Title } from 'twenty-ui/display';
import {
@@ -22,6 +24,7 @@ import {
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
Card,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
Section,
} from 'twenty-ui/layout';
@@ -80,12 +83,18 @@ export const CalendarEventsCard = () => {
const { timelineCalendarEvents, totalNumberOfCalendarEvents } =
data?.[queryName] ?? {};
const allEvents = timelineCalendarEvents || [];
const recurringEvents = allEvents.filter((event) =>
isDefined(event.recurrence),
);
const {
calendarEventsByDayTime,
daysByMonthTime,
monthTimes,
monthTimesByYear,
} = useCalendarEvents(timelineCalendarEvents || []);
} = useCalendarEvents(allEvents);
const hasMoreCalendarEvents =
timelineCalendarEvents && totalNumberOfCalendarEvents
@@ -131,6 +140,22 @@ export const CalendarEventsCard = () => {
}}
>
<StyledContainer>
{recurringEvents.length > 0 && (
<Section>
<StyledTitleContainer>
<H3Title title={t`Recurring`} />
</StyledTitleContainer>
<Card fullWidth>
{recurringEvents.map((event, index) => (
<CalendarDayCardContent
key={event.id}
calendarEvents={[event]}
divider={index < recurringEvents.length - 1}
/>
))}
</Card>
</Section>
)}
{monthTimes.map((monthTime) => {
const monthDayTimes = daysByMonthTime[monthTime] || [];
const year = getYear(monthTime);
@@ -10,6 +10,7 @@ export const timelineCalendarEventFragment = gql`
startsAt
endsAt
isFullDay
recurrence
visibility
participants {
...TimelineCalendarEventParticipantFragment
@@ -14,6 +14,7 @@ export type CalendarEvent = {
isCanceled?: boolean;
isFullDay: boolean;
location?: string;
recurrence?: string;
startsAt: string;
title?: string;
visibility: CalendarChannelVisibility;
@@ -1,7 +1,7 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { EmailThreadMessageBody } from '@/activities/emails/components/EmailThreadMessageBody';
import { LinkifiedTextBody } from '@/ui/display/components/LinkifiedTextBody';
import { EmailThreadMessageBodyPreview } from '@/activities/emails/components/EmailThreadMessageBodyPreview';
import { EmailThreadMessageReceivers } from '@/activities/emails/components/EmailThreadMessageReceivers';
import { EmailThreadMessageSender } from '@/activities/emails/components/EmailThreadMessageSender';
@@ -82,7 +82,7 @@ export const EmailThreadMessage = ({
visibility={MessageChannelVisibility.METADATA}
/>
) : isOpen ? (
<EmailThreadMessageBody body={body} isDisplayed />
<LinkifiedTextBody body={body} isDisplayed />
) : (
<EmailThreadMessageBodyPreview body={body} />
)}
@@ -4,7 +4,7 @@ import Linkify from 'linkify-react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AnimatedEaseInOut } from 'twenty-ui/utilities';
const StyledThreadMessageBody = styled(motion.div)`
const StyledTextBody = styled(motion.div)`
color: ${themeCssVariables.font.color.primary};
display: flex;
flex-direction: column;
@@ -13,30 +13,27 @@ const StyledThreadMessageBody = styled(motion.div)`
white-space: pre-line;
a {
color: ${themeCssVariables.font.color.primary};
color: ${themeCssVariables.color.blue};
text-decoration: underline;
text-decoration-color: ${themeCssVariables.font.color.primary};
&:hover {
color: ${themeCssVariables.font.color.tertiary};
text-decoration-color: ${themeCssVariables.border.color.strong};
text-decoration-color: ${themeCssVariables.color.blue};
}
}
`;
type EmailThreadMessageBodyProps = {
type LinkifiedTextBodyProps = {
body: string;
isDisplayed: boolean;
};
export const EmailThreadMessageBody = ({
export const LinkifiedTextBody = ({
body,
isDisplayed,
}: EmailThreadMessageBodyProps) => {
}: LinkifiedTextBodyProps) => {
return (
<AnimatedEaseInOut isOpen={isDisplayed} duration="fast">
<StyledThreadMessageBody>
<StyledTextBody>
<Linkify
options={{
target: '_blank',
@@ -45,7 +42,7 @@ export const EmailThreadMessageBody = ({
>
{body}
</Linkify>
</StyledThreadMessageBody>
</StyledTextBody>
</AnimatedEaseInOut>
);
};
+1
View File
@@ -170,6 +170,7 @@
"react-dom": "18.3.1",
"redis": "^4.7.0",
"reflect-metadata": "0.2.2",
"rrule": "^2.8.1",
"rxjs": "7.8.1",
"semver": "7.6.3",
"sharp": "0.32.6",
@@ -54,6 +54,9 @@ export class TimelineCalendarEventDTO {
@Field()
conferenceSolution: string;
@Field({ nullable: true })
recurrence: string;
@Field(() => LinksMetadataDTO)
conferenceLink: LinksMetadataDTO;
@@ -383,6 +383,23 @@ export const buildCalendarEventStandardFlatFieldMetadatas = ({
twentyStandardApplicationId,
now,
}),
recurrence: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'recurrence',
type: FieldMetadataType.TEXT,
label: i18nLabel(msg`Recurrence`),
description: i18nLabel(msg`Recurrence pattern`),
icon: 'IconRepeat',
isNullable: true,
isUIReadOnly: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
conferenceLink: createStandardFieldFlatMetadata({
objectName,
workspaceId,
@@ -1,5 +1,6 @@
import { type calendar_v3 as calendarV3 } from 'googleapis';
import { parseRRule } from 'src/modules/calendar/calendar-event-import-manager/drivers/utils/parse-rrule.util';
import { sanitizeCalendarEvent } from 'src/modules/calendar/calendar-event-import-manager/drivers/utils/sanitizeCalendarEvent';
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
@@ -44,6 +45,7 @@ const formatGoogleCalendarEvent = (
conferenceLinkLabel: event.conferenceData?.entryPoints?.[0]?.uri ?? '',
conferenceLinkUrl: event.conferenceData?.entryPoints?.[0]?.uri ?? '',
recurringEventExternalId: event.recurringEventId ?? '',
recurrence: parseRRule(event.recurrence?.[0] ?? ''),
participants:
event.attendees?.map((attendee) => ({
handle: attendee.email ?? '',
@@ -0,0 +1,61 @@
import { parseRRule } from 'src/modules/calendar/calendar-event-import-manager/drivers/utils/parse-rrule.util';
describe('parseRRule', () => {
it('should return undefined for invalid input', () => {
expect(parseRRule('')).toBeUndefined();
});
it('should parse a weekly rule with BYDAY and UNTIL', () => {
expect(
parseRRule('RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20270331T000000Z'),
).toBe('every week on Friday until March 31, 2027');
});
it('should parse a daily rule with COUNT', () => {
expect(parseRRule('RRULE:FREQ=DAILY;COUNT=5')).toBe(
'every day for 5 times',
);
});
it('should parse a weekly rule with multiple days', () => {
expect(parseRRule('RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR')).toBe(
'every week on Monday, Wednesday, Friday',
);
});
it('should parse a biweekly rule', () => {
expect(parseRRule('RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TH')).toBe(
'every 2 weeks on Thursday',
);
});
it('should parse a monthly rule with BYMONTHDAY', () => {
expect(parseRRule('RRULE:FREQ=MONTHLY;BYMONTHDAY=15')).toBe(
'every month on the 15th',
);
});
it('should parse a monthly rule with ordinal BYDAY', () => {
expect(parseRRule('RRULE:FREQ=MONTHLY;BYDAY=2TU')).toBe(
'every month on the 2nd Tuesday',
);
});
it('should parse a yearly rule with BYMONTH and BYMONTHDAY', () => {
expect(parseRRule('RRULE:FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=1')).toBe(
'every June on the 1st',
);
});
it('should parse a real Google Calendar weekly recurring event', () => {
expect(
parseRRule(
'RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20270331T133000Z;INTERVAL=1;BYDAY=FR',
),
).toBe('every week on Friday until March 31, 2027');
});
it('should handle RRULE prefix being absent', () => {
expect(parseRRule('FREQ=WEEKLY;BYDAY=TU')).toBe('every week on Tuesday');
});
});
@@ -0,0 +1,15 @@
import { RRule } from 'rrule';
export const parseRRule = (rruleString: string): string | undefined => {
if (!rruleString) return undefined;
try {
const normalized = rruleString.startsWith('RRULE:')
? rruleString
: `RRULE:${rruleString}`;
return RRule.fromString(normalized).toText();
} catch {
return undefined;
}
};
@@ -0,0 +1,169 @@
import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { CalendarSaveEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-save-events.service';
import { type FetchedCalendarEvent } from 'src/modules/calendar/common/types/fetched-calendar-event';
let eventStore: Record<string, any>[];
let associationStore: Record<string, any>[];
const mockCalendarEventRepository = {
find: jest.fn(async ({ where }: any) => {
const iCalUids: string[] = where.iCalUid.value;
return eventStore.filter((e) => iCalUids.includes(e.iCalUid));
}),
insert: jest.fn(async (entities: any[]) => {
eventStore.push(...entities);
}),
updateMany: jest.fn(async (updates: any[]) => {
for (const { criteria, partialEntity } of updates) {
const idx = eventStore.findIndex((e) => e.id === criteria);
if (idx !== -1) {
eventStore[idx] = { ...eventStore[idx], ...partialEntity };
}
}
}),
};
const mockAssociationRepository = {
insert: jest.fn(async (entities: any[]) => {
associationStore.push(...entities);
}),
};
const mockCalendarEventParticipantService = {
upsertAndDeleteCalendarEventParticipants: jest.fn(),
};
const mockGlobalWorkspaceOrmManager = {
executeInWorkspaceContext: jest.fn(async (callback: () => Promise<void>) => {
await callback();
}),
getRepository: jest.fn(async (_workspaceId: string, entityName: string) => {
if (entityName === 'calendarEvent') return mockCalendarEventRepository;
if (entityName === 'calendarChannelEventAssociation')
return mockAssociationRepository;
return {};
}),
getGlobalWorkspaceDataSource: jest.fn(async () => ({
transaction: async (callback: (manager: unknown) => Promise<void>) => {
await callback({} as any);
},
})),
};
const workspaceId = 'workspace-123';
const calendarChannel = {
id: 'channel-123',
} as unknown as CalendarChannelEntity;
const connectedAccount = {
id: 'account-123',
} as unknown as ConnectedAccountEntity;
const SHARED_ICAL_UID =
'040000008200E00074C5B7101A82E0080000000030EA36F0D6ACDC01000000000000000010000000DAD2907D895870419A226EB4E0FE7607';
const MASTER_ID = 'recurring_master_abc';
const createRecurringEventInstance = (
date: string,
startsAt: string,
): FetchedCalendarEvent => ({
id: `${MASTER_ID}_${date}`,
iCalUid: SHARED_ICAL_UID,
recurringEventExternalId: MASTER_ID,
title: 'Weekly Sync',
startsAt,
endsAt: startsAt,
description: '',
location: '',
isFullDay: false,
isCanceled: false,
conferenceLinkLabel: '',
conferenceLinkUrl: '',
externalCreatedAt: '',
externalUpdatedAt: '',
conferenceSolution: '',
participants: [],
status: 'confirmed',
});
describe('CalendarSaveEventsService', () => {
let service: CalendarSaveEventsService;
const save = (events: FetchedCalendarEvent[]) =>
service.saveCalendarEventsAndEnqueueContactCreationJob(
events,
calendarChannel,
connectedAccount,
workspaceId,
);
beforeEach(() => {
jest.clearAllMocks();
eventStore = [];
associationStore = [];
service = new CalendarSaveEventsService(
mockGlobalWorkspaceOrmManager as any,
mockCalendarEventParticipantService as any,
);
});
describe('recurring event instances with shared iCalUID', () => {
it('should store only one record per recurring series', async () => {
await save([
createRecurringEventInstance('20260410', '2026-04-10T16:30:00+03:00'),
]);
await save([
createRecurringEventInstance('20260417', '2026-04-17T16:30:00+03:00'),
]);
expect(eventStore).toHaveLength(1);
expect(associationStore).toHaveLength(2);
});
it('should store one record when multiple instances arrive in the same batch', async () => {
await save([
createRecurringEventInstance('20260410', '2026-04-10T16:30:00+03:00'),
createRecurringEventInstance('20260417', '2026-04-17T16:30:00+03:00'),
createRecurringEventInstance('20260424', '2026-04-24T16:30:00+03:00'),
]);
expect(eventStore).toHaveLength(1);
expect(associationStore).toHaveLength(1);
});
it('should store the parsed recurrence text from the master event', async () => {
const masterEvent: FetchedCalendarEvent = {
id: MASTER_ID,
iCalUid: SHARED_ICAL_UID,
title: 'Weekly Sync',
startsAt: '2026-04-10T16:30:00+03:00',
endsAt: '2026-04-10T17:00:00+03:00',
description: '',
location: '',
isFullDay: false,
isCanceled: false,
conferenceLinkLabel: '',
conferenceLinkUrl: '',
externalCreatedAt: '',
externalUpdatedAt: '',
conferenceSolution: '',
recurrence: 'every week on Friday until March 31, 2027',
participants: [],
status: 'confirmed',
};
await save([masterEvent]);
expect(eventStore).toHaveLength(1);
expect(eventStore[0].recurrence).toBe(
'every week on Friday until March 31, 2027',
);
});
});
});
@@ -52,11 +52,21 @@ export class CalendarSaveEventsService {
await workspaceDataSource.transaction(
async (transactionManager: WorkspaceEntityManager) => {
const deduplicatedByICalUid = new Map<string, FetchedCalendarEvent>();
for (const event of fetchedCalendarEvents) {
if (!deduplicatedByICalUid.has(event.iCalUid) || event.recurrence) {
deduplicatedByICalUid.set(event.iCalUid, event);
}
}
const eventsToProcess = [...deduplicatedByICalUid.values()];
const existingCalendarEvents = await calendarEventRepository.find(
{
where: {
iCalUid: Any(
fetchedCalendarEvents.map((event) => event.iCalUid as string),
eventsToProcess.map((event) => event.iCalUid as string),
),
},
},
@@ -64,20 +74,17 @@ export class CalendarSaveEventsService {
);
const fetchedCalendarEventsWithDBEvents: FetchedCalendarEventWithDBEvent[] =
fetchedCalendarEvents.map(
(event): FetchedCalendarEventWithDBEvent => {
const existingEventWithSameiCalUid =
existingCalendarEvents.find(
(existingEvent) => existingEvent.iCalUid === event.iCalUid,
);
eventsToProcess.map((event): FetchedCalendarEventWithDBEvent => {
const existingEventWithSameiCalUid = existingCalendarEvents.find(
(existingEvent) => existingEvent.iCalUid === event.iCalUid,
);
return {
fetchedCalendarEvent: event,
existingCalendarEvent: existingEventWithSameiCalUid ?? null,
newlyCreatedCalendarEvent: null,
};
},
);
return {
fetchedCalendarEvent: event,
existingCalendarEvent: existingEventWithSameiCalUid ?? null,
newlyCreatedCalendarEvent: null,
};
});
const newCalendarEventsToInsert = fetchedCalendarEventsWithDBEvents
.filter(
@@ -94,6 +101,7 @@ export class CalendarSaveEventsService {
isFullDay: fetchedCalendarEvent.isFullDay,
isCanceled: fetchedCalendarEvent.isCanceled,
conferenceSolution: fetchedCalendarEvent.conferenceSolution,
recurrence: fetchedCalendarEvent.recurrence ?? null,
conferenceLink: {
primaryLinkLabel: fetchedCalendarEvent.conferenceLinkLabel,
primaryLinkUrl: fetchedCalendarEvent.conferenceLinkUrl,
@@ -155,6 +163,9 @@ export class CalendarSaveEventsService {
isFullDay: fetchedCalendarEvent.isFullDay,
isCanceled: fetchedCalendarEvent.isCanceled,
conferenceSolution: fetchedCalendarEvent.conferenceSolution,
...(fetchedCalendarEvent.recurrence
? { recurrence: fetchedCalendarEvent.recurrence }
: {}),
conferenceLink: {
primaryLinkLabel:
fetchedCalendarEvent.conferenceLinkLabel,
@@ -24,6 +24,7 @@ export class CalendarEventWorkspaceEntity extends BaseWorkspaceEntity {
location: string | null;
iCalUid: string | null;
conferenceSolution: string | null;
recurrence: string | null;
conferenceLink: LinksMetadata;
calendarChannelEventAssociations: EntityRelation<
CalendarChannelEventAssociationWorkspaceEntity[]
@@ -21,6 +21,7 @@ export type FetchedCalendarEvent = {
externalUpdatedAt: string;
conferenceSolution: string;
recurringEventExternalId?: string;
recurrence?: string;
participants: FetchedCalendarEventParticipant[];
status: string;
};
@@ -595,6 +595,9 @@ export const STANDARD_OBJECTS = {
conferenceSolution: {
universalIdentifier: '20202020-1c3f-4b5a-b526-5411a82179eb',
},
recurrence: {
universalIdentifier: '20202020-a1b2-4c3d-8e4f-f5a6b7c8d9e0',
},
conferenceLink: {
universalIdentifier: '20202020-35da-43ef-9ca0-e936e9dc237b',
},
+2 -1
View File
@@ -56209,7 +56209,7 @@ __metadata:
languageName: node
linkType: hard
"rrule@npm:2.8.1":
"rrule@npm:2.8.1, rrule@npm:^2.8.1":
version: 2.8.1
resolution: "rrule@npm:2.8.1"
dependencies:
@@ -60819,6 +60819,7 @@ __metadata:
redis: "npm:^4.7.0"
reflect-metadata: "npm:0.2.2"
rimraf: "npm:^5.0.5"
rrule: "npm:^2.8.1"
rxjs: "npm:7.8.1"
semver: "npm:7.6.3"
sharp: "npm:0.32.6"