diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetails.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetails.tsx index 2c60c6b010..d816389774 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetails.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetails.tsx @@ -86,7 +86,7 @@ const StyledFields = styled.div` `; const StyledPropertyBoxContainer = styled.div` - height: ${themeCssVariables.spacing[6]}; + min-height: ${themeCssVariables.spacing[6]}; width: 100%; `; diff --git a/packages/twenty-front/src/modules/activities/hooks/useCustomResolver.ts b/packages/twenty-front/src/modules/activities/hooks/useCustomResolver.ts index 3812304288..67d4356b27 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useCustomResolver.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useCustomResolver.ts @@ -58,16 +58,15 @@ export const useCustomResolver = < pageSize, }; - const { - data, - loading: firstQueryLoading, - fetchMore, - error, - } = useQuery>(query, { + const { data, loading, fetchMore, error } = useQuery< + CustomResolverQueryResult + >(query, { client: apolloCoreClient, variables: queryVariables, }); + const firstQueryLoading = loading && !data; + useSnackBarOnQueryError(error); const fetchMoreRecords = async () => { diff --git a/packages/twenty-front/src/modules/ui/display/components/LinkifiedTextBody.tsx b/packages/twenty-front/src/modules/ui/display/components/LinkifiedTextBody.tsx new file mode 100644 index 0000000000..5f6c92991e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/display/components/LinkifiedTextBody.tsx @@ -0,0 +1,48 @@ +import { styled } from '@linaria/react'; +import { motion } from 'framer-motion'; +import Linkify from 'linkify-react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { AnimatedEaseInOut } from 'twenty-ui/utilities'; + +const StyledTextBody = styled(motion.div)` + color: ${themeCssVariables.font.color.primary}; + display: flex; + flex-direction: column; + margin-top: ${themeCssVariables.spacing[4]}; + overflow-wrap: break-word; + white-space: pre-line; + + a { + color: ${themeCssVariables.color.blue}; + text-decoration: underline; + + &:hover { + text-decoration-color: ${themeCssVariables.color.blue}; + } + } +`; + +type LinkifiedTextBodyProps = { + body: string; + isDisplayed: boolean; +}; + +export const LinkifiedTextBody = ({ + body, + isDisplayed, +}: LinkifiedTextBodyProps) => { + return ( + + + + {body} + + + + ); +}; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-2/2-2-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-2/2-2-upgrade-version-command.module.ts index c5672d56ef..d5143e98dd 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-2/2-2-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-2/2-2-upgrade-version-command.module.ts @@ -1,7 +1,18 @@ import { Module } from '@nestjs/common'; +import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; +import { SetCalendarEventDescriptionDisplayedMaxRowsCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-workspace-command-1786000000000-set-calendar-event-description-displayed-max-rows.command'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; + @Module({ - imports: [], - providers: [], + imports: [ + ApplicationModule, + WorkspaceCacheModule, + WorkspaceIteratorModule, + WorkspaceMigrationModule, + ], + providers: [SetCalendarEventDescriptionDisplayedMaxRowsCommand], }) export class V2_2_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-2/2-2-workspace-command-1786000000000-set-calendar-event-description-displayed-max-rows.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-2/2-2-workspace-command-1786000000000-set-calendar-event-description-displayed-max-rows.command.ts new file mode 100644 index 0000000000..c9e4cbb5de --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-2/2-2-workspace-command-1786000000000-set-calendar-event-description-displayed-max-rows.command.ts @@ -0,0 +1,120 @@ +import { Command } from 'nest-commander'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { + FieldMetadataType, + type FieldMetadataSettings, +} from 'twenty-shared/types'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; + +const DISPLAYED_MAX_ROWS = 99; + +@RegisteredWorkspaceCommand('2.2.0', 1786000000000) +@Command({ + name: 'upgrade:2-2:set-calendar-event-description-displayed-max-rows', + description: + 'Set displayedMaxRows setting on calendarEvent.description field', +}) +export class SetCalendarEventDescriptionDisplayedMaxRowsCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const { flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatFieldMetadataMaps', + ]); + + const descriptionField = + findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatFieldMetadataMaps, + universalIdentifier: + STANDARD_OBJECTS.calendarEvent.fields.description.universalIdentifier, + }); + + if (!descriptionField) { + this.logger.log( + `calendarEvent.description field not found for workspace ${workspaceId}, skipping`, + ); + + return; + } + + const textSettings = + descriptionField.settings as FieldMetadataSettings; + + if (textSettings?.displayedMaxRows === DISPLAYED_MAX_ROWS) { + this.logger.log( + `calendarEvent.description displayedMaxRows already set for workspace ${workspaceId}, skipping`, + ); + + return; + } + + if (isDryRun) { + this.logger.log( + `[DRY RUN] Would set displayedMaxRows on calendarEvent.description for workspace ${workspaceId}`, + ); + + return; + } + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const fieldToUpdate = { + ...descriptionField, + settings: { + ...textSettings, + displayedMaxRows: DISPLAYED_MAX_ROWS, + }, + }; + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + fieldMetadata: { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: [fieldToUpdate], + }, + }, + workspaceId, + applicationUniversalIdentifier: + twentyStandardFlatApplication.universalIdentifier, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + throw new Error( + `Failed to set displayedMaxRows on calendarEvent.description for workspace ${workspaceId}: ${JSON.stringify(validateAndBuildResult, null, 2)}`, + ); + } + + this.logger.log( + `Set displayedMaxRows on calendarEvent.description for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts index 125882755d..6345d46e79 100644 --- a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts @@ -16,6 +16,7 @@ import { TimelineCalendarEventService } from './timeline-calendar-event.service' type MockWorkspaceRepository = Partial< WorkspaceRepository > & { + count: jest.Mock; find: jest.Mock; findAndCount: jest.Mock; }; @@ -40,6 +41,7 @@ describe('TimelineCalendarEventService', () => { beforeEach(async () => { mockCalendarEventRepository = { + count: jest.fn().mockResolvedValue(1), find: jest.fn(), findAndCount: jest.fn(), }; diff --git a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts index f982c041e9..c384aaefe5 100644 --- a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts +++ b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts @@ -55,6 +55,16 @@ export class TimelineCalendarEventService { 'calendarEvent', ); + const totalNumberOfCalendarEvents = await calendarEventRepository.count( + { + where: { + calendarEventParticipants: { + personId: Any(personIds), + }, + }, + }, + ); + const calendarEventIds = await calendarEventRepository.find({ where: { calendarEventParticipants: { @@ -76,12 +86,12 @@ export class TimelineCalendarEventService { if (ids.length <= 0) { return { - totalNumberOfCalendarEvents: 0, + totalNumberOfCalendarEvents, timelineCalendarEvents: [], }; } - const [events, total] = await calendarEventRepository.findAndCount({ + const [events] = await calendarEventRepository.findAndCount({ where: { id: Any(ids), }, @@ -241,7 +251,7 @@ export class TimelineCalendarEventService { }); return { - totalNumberOfCalendarEvents: total, + totalNumberOfCalendarEvents, timelineCalendarEvents, }; }, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-calendar-event-standard-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-calendar-event-standard-flat-field-metadata.util.ts index c8201003b4..734b6a0d15 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-calendar-event-standard-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-calendar-event-standard-flat-field-metadata.util.ts @@ -326,6 +326,7 @@ export const buildCalendarEventStandardFlatFieldMetadatas = ({ icon: 'IconFileDescription', isNullable: true, isUIReadOnly: true, + settings: { displayedMaxRows: 99 }, }, standardObjectMetadataRelatedEntityIds, dependencyFlatEntityMaps, diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts index 97e862d676..e54157c4da 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/services/google-calendar-get-events.service.ts @@ -47,6 +47,7 @@ export class GoogleCalendarGetEventsService { .list({ calendarId: 'primary', maxResults: 500, + singleEvents: true, syncToken: syncCursor, pageToken: nextPageToken, showDeleted: true, diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/__tests__/calendar-save-events.service.spec.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/__tests__/calendar-save-events.service.spec.ts new file mode 100644 index 0000000000..5fbb1ecedc --- /dev/null +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/__tests__/calendar-save-events.service.spec.ts @@ -0,0 +1,200 @@ +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'; + +const RECURRING_ICAL_UID = 'recurring-ical-uid@google.com'; +const RECURRING_MASTER_ID = 'master_abc'; + +const mockCalendarEventRepository = { + insert: jest.fn(), + updateMany: jest.fn(), +}; + +const mockAssociationRepository = { + find: jest.fn(), + insert: jest.fn(), + updateMany: jest.fn(), +}; + +const mockCalendarEventParticipantService = { + upsertAndDeleteCalendarEventParticipants: jest.fn(), +}; + +const mockGlobalWorkspaceOrmManager = { + executeInWorkspaceContext: jest.fn(async (callback: () => Promise) => { + 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) => { + await callback({} as any); + }, + })), +}; + +const calendarChannel = { + id: 'channel-123', +} as unknown as CalendarChannelEntity; + +const connectedAccount = { + id: 'account-123', +} as unknown as ConnectedAccountEntity; + +const createFetchedEvent = ( + overrides: Partial = {}, +): FetchedCalendarEvent => ({ + id: 'event-1', + iCalUid: 'single-event-uid@google.com', + title: 'Team Meeting', + 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: '', + participants: [], + status: 'confirmed', + ...overrides, +}); + +describe('CalendarSaveEventsService', () => { + let service: CalendarSaveEventsService; + + const save = (events: FetchedCalendarEvent[]) => + service.saveCalendarEventsAndEnqueueContactCreationJob( + events, + calendarChannel, + connectedAccount, + 'workspace-123', + ); + + beforeEach(() => { + jest.clearAllMocks(); + + mockAssociationRepository.find.mockResolvedValue([]); + + service = new CalendarSaveEventsService( + mockGlobalWorkspaceOrmManager as any, + mockCalendarEventParticipantService as any, + ); + }); + + it('should insert each recurring instance as a separate event', async () => { + await save([ + createFetchedEvent({ + id: `${RECURRING_MASTER_ID}_20260403`, + iCalUid: RECURRING_ICAL_UID, + recurringEventExternalId: RECURRING_MASTER_ID, + startsAt: '2026-04-03T16:30:00+03:00', + }), + createFetchedEvent({ + id: `${RECURRING_MASTER_ID}_20260410`, + iCalUid: RECURRING_ICAL_UID, + recurringEventExternalId: RECURRING_MASTER_ID, + startsAt: '2026-04-10T16:30:00+03:00', + }), + createFetchedEvent({ + id: `${RECURRING_MASTER_ID}_20260417`, + iCalUid: RECURRING_ICAL_UID, + recurringEventExternalId: RECURRING_MASTER_ID, + startsAt: '2026-04-17T16:30:00+03:00', + }), + ]); + + const insertedEvents = mockCalendarEventRepository.insert.mock.calls[0][0]; + + expect(insertedEvents).toHaveLength(3); + expect(new Set(insertedEvents.map((e: any) => e.startsAt))).toEqual( + new Set([ + '2026-04-03T16:30:00+03:00', + '2026-04-10T16:30:00+03:00', + '2026-04-17T16:30:00+03:00', + ]), + ); + }); + + it('should update existing events and only insert new ones on incremental sync', async () => { + mockAssociationRepository.find.mockResolvedValueOnce([ + { + id: 'assoc-403', + eventExternalId: `${RECURRING_MASTER_ID}_20260403`, + calendarEventId: 'existing-db-id-403', + calendarChannelId: calendarChannel.id, + }, + ]); + + await save([ + createFetchedEvent({ + id: `${RECURRING_MASTER_ID}_20260403`, + iCalUid: RECURRING_ICAL_UID, + recurringEventExternalId: RECURRING_MASTER_ID, + title: 'Weekly Sync (Renamed)', + startsAt: '2026-04-03T16:30:00+03:00', + }), + createFetchedEvent({ + id: `${RECURRING_MASTER_ID}_20260410`, + iCalUid: RECURRING_ICAL_UID, + startsAt: '2026-04-10T16:30:00+03:00', + }), + ]); + + const insertedEvents = mockCalendarEventRepository.insert.mock.calls[0][0]; + + expect(insertedEvents).toHaveLength(1); + expect(insertedEvents[0].startsAt).toBe('2026-04-10T16:30:00+03:00'); + + const updatedEvents = + mockCalendarEventRepository.updateMany.mock.calls[0][0]; + + expect(updatedEvents).toHaveLength(1); + expect(updatedEvents[0].criteria).toBe('existing-db-id-403'); + expect(updatedEvents[0].partialEntity.title).toBe('Weekly Sync (Renamed)'); + + const insertedAssociations = + mockAssociationRepository.insert.mock.calls[0][0]; + + expect(insertedAssociations).toHaveLength(1); + expect(insertedAssociations[0].eventExternalId).toBe( + `${RECURRING_MASTER_ID}_20260410`, + ); + }); + + it('should only update without inserting when all events already exist', async () => { + mockAssociationRepository.find.mockResolvedValueOnce([ + { + id: 'assoc-1', + eventExternalId: 'event-1', + calendarEventId: 'db-id-1', + calendarChannelId: calendarChannel.id, + }, + { + id: 'assoc-2', + eventExternalId: 'event-2', + calendarEventId: 'db-id-2', + calendarChannelId: calendarChannel.id, + }, + ]); + + await save([ + createFetchedEvent({ id: 'event-1' }), + createFetchedEvent({ id: 'event-2' }), + ]); + + expect(mockCalendarEventRepository.insert).not.toHaveBeenCalled(); + expect(mockCalendarEventRepository.updateMany).toHaveBeenCalledTimes(1); + expect(mockAssociationRepository.insert).not.toHaveBeenCalled(); + expect(mockAssociationRepository.updateMany).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-save-events.service.ts b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-save-events.service.ts index bf8c903cae..17731a92e4 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-save-events.service.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-import-manager/services/calendar-save-events.service.ts @@ -52,56 +52,85 @@ export class CalendarSaveEventsService { await workspaceDataSource.transaction( async (transactionManager: WorkspaceEntityManager) => { - const existingCalendarEvents = await calendarEventRepository.find( - { - where: { - iCalUid: Any( - fetchedCalendarEvents.map((event) => event.iCalUid as string), - ), + const existingAssociations = + await calendarChannelEventAssociationRepository.find( + { + where: { + eventExternalId: Any( + fetchedCalendarEvents.map((event) => event.id), + ), + calendarChannelId: calendarChannel.id, + }, }, - }, - transactionManager, + transactionManager, + ); + + const existingCalendarEventIdByExternalId = new Map( + existingAssociations.map((association) => [ + association.eventExternalId, + association.calendarEventId, + ]), + ); + + const existingAssociationIdByExternalId = new Map( + existingAssociations.map((association) => [ + association.eventExternalId, + association.id, + ]), ); const fetchedCalendarEventsWithDBEvents: FetchedCalendarEventWithDBEvent[] = fetchedCalendarEvents.map( (event): FetchedCalendarEventWithDBEvent => { - const existingEventWithSameiCalUid = - existingCalendarEvents.find( - (existingEvent) => existingEvent.iCalUid === event.iCalUid, - ); + const existingCalendarEventId = + existingCalendarEventIdByExternalId.get(event.id); return { fetchedCalendarEvent: event, - existingCalendarEvent: existingEventWithSameiCalUid ?? null, + existingCalendarEvent: existingCalendarEventId + ? ({ + id: existingCalendarEventId, + } as CalendarEventWorkspaceEntity) + : null, newlyCreatedCalendarEvent: null, }; }, ); + const newCalendarEventIdByExternalId = new Map(); + const newCalendarEventsToInsert = fetchedCalendarEventsWithDBEvents .filter( ({ existingCalendarEvent }) => existingCalendarEvent === null, ) - .map(({ fetchedCalendarEvent }) => ({ - id: uuid(), - iCalUid: fetchedCalendarEvent.iCalUid, - title: fetchedCalendarEvent.title, - description: fetchedCalendarEvent.description, - startsAt: fetchedCalendarEvent.startsAt, - endsAt: fetchedCalendarEvent.endsAt, - location: fetchedCalendarEvent.location, - isFullDay: fetchedCalendarEvent.isFullDay, - isCanceled: fetchedCalendarEvent.isCanceled, - conferenceSolution: fetchedCalendarEvent.conferenceSolution, - conferenceLink: { - primaryLinkLabel: fetchedCalendarEvent.conferenceLinkLabel, - primaryLinkUrl: fetchedCalendarEvent.conferenceLinkUrl, - secondaryLinks: [], - }, - externalCreatedAt: fetchedCalendarEvent.externalCreatedAt, - externalUpdatedAt: fetchedCalendarEvent.externalUpdatedAt, - })); + .map(({ fetchedCalendarEvent }) => { + const calendarEventId = uuid(); + + newCalendarEventIdByExternalId.set( + fetchedCalendarEvent.id, + calendarEventId, + ); + + return { + id: calendarEventId, + iCalUid: fetchedCalendarEvent.iCalUid, + title: fetchedCalendarEvent.title, + description: fetchedCalendarEvent.description, + startsAt: fetchedCalendarEvent.startsAt, + endsAt: fetchedCalendarEvent.endsAt, + location: fetchedCalendarEvent.location, + isFullDay: fetchedCalendarEvent.isFullDay, + isCanceled: fetchedCalendarEvent.isCanceled, + conferenceSolution: fetchedCalendarEvent.conferenceSolution, + conferenceLink: { + primaryLinkLabel: fetchedCalendarEvent.conferenceLinkLabel, + primaryLinkUrl: fetchedCalendarEvent.conferenceLinkUrl, + secondaryLinks: [], + }, + externalCreatedAt: fetchedCalendarEvent.externalCreatedAt, + externalUpdatedAt: fetchedCalendarEvent.externalUpdatedAt, + }; + }); if (newCalendarEventsToInsert.length > 0) { await calendarEventRepository.insert( @@ -113,18 +142,16 @@ export class CalendarSaveEventsService { const fetchedCalendarEventsWithDBEventsEnrichedWithSavedEvents: FetchedCalendarEventWithDBEvent[] = fetchedCalendarEventsWithDBEvents.map( ({ fetchedCalendarEvent, existingCalendarEvent }) => { - const savedCalendarEvent = newCalendarEventsToInsert.find( - (inserted) => - inserted.iCalUid === fetchedCalendarEvent.iCalUid, + const savedCalendarEventId = newCalendarEventIdByExternalId.get( + fetchedCalendarEvent.id, ); return { fetchedCalendarEvent, - existingCalendarEvent: existingCalendarEvent, - newlyCreatedCalendarEvent: savedCalendarEvent + existingCalendarEvent, + newlyCreatedCalendarEvent: savedCalendarEventId ? ({ - id: savedCalendarEvent.id, - iCalUid: savedCalendarEvent.iCalUid, + id: savedCalendarEventId, } as CalendarEventWorkspaceEntity) : null, }; @@ -180,30 +207,26 @@ export class CalendarSaveEventsService { | 'eventExternalId' | 'calendarChannelId' | 'recurringEventExternalId' - >[] = fetchedCalendarEventsWithDBEventsEnrichedWithSavedEvents.map( - ({ - fetchedCalendarEvent, - existingCalendarEvent, - newlyCreatedCalendarEvent, - }) => { - const calendarEventId = - existingCalendarEvent?.id ?? newlyCreatedCalendarEvent?.id; - - if (!calendarEventId) { + >[] = fetchedCalendarEventsWithDBEventsEnrichedWithSavedEvents + .filter( + ({ newlyCreatedCalendarEvent }) => + newlyCreatedCalendarEvent !== null, + ) + .map(({ fetchedCalendarEvent, newlyCreatedCalendarEvent }) => { + if (!newlyCreatedCalendarEvent?.id) { throw new Error( `Calendar event id not found for event with iCalUid ${fetchedCalendarEvent.iCalUid} - should never happen`, ); } return { - calendarEventId, + calendarEventId: newlyCreatedCalendarEvent.id, eventExternalId: fetchedCalendarEvent.id, calendarChannelId: calendarChannel.id, recurringEventExternalId: fetchedCalendarEvent.recurringEventExternalId ?? '', }; - }, - ); + }); if (calendarChannelEventAssociationsToSave.length > 0) { await calendarChannelEventAssociationRepository.insert( @@ -212,6 +235,28 @@ export class CalendarSaveEventsService { ); } + const existingAssociationsToUpdate = + fetchedCalendarEventsWithDBEventsEnrichedWithSavedEvents + .filter( + ({ existingCalendarEvent }) => existingCalendarEvent !== null, + ) + .map(({ fetchedCalendarEvent }) => ({ + criteria: existingAssociationIdByExternalId.get( + fetchedCalendarEvent.id, + )!, + partialEntity: { + recurringEventExternalId: + fetchedCalendarEvent.recurringEventExternalId ?? '', + }, + })); + + if (existingAssociationsToUpdate.length > 0) { + await calendarChannelEventAssociationRepository.updateMany( + existingAssociationsToUpdate, + transactionManager, + ); + } + const participantsToCreate = fetchedCalendarEventsWithDBEventsEnrichedWithSavedEvents .filter( diff --git a/packages/twenty-ui/src/display/components/LinkifiedText.tsx b/packages/twenty-ui/src/display/components/LinkifiedText.tsx new file mode 100644 index 0000000000..9d8fcd5c46 --- /dev/null +++ b/packages/twenty-ui/src/display/components/LinkifiedText.tsx @@ -0,0 +1,44 @@ +import { styled } from '@linaria/react'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { themeCssVariables } from '@ui/theme-constants'; +import { linkifyText } from '@ui/utilities/utils/linkifyText'; + +const StyledLink = styled.a` + color: ${themeCssVariables.color.blue}; + text-decoration: underline; + + &:hover { + text-decoration-color: ${themeCssVariables.color.blue}; + } +`; + +type LinkifiedTextProps = { + text: string; +}; + +export const LinkifiedText = ({ text }: LinkifiedTextProps) => { + if (!isNonEmptyString(text)) { + return null; + } + + return ( + <> + {linkifyText(text).map((part, index) => + part.type === 'link' ? ( + e.stopPropagation()} + > + {part.content} + + ) : ( + part.content + ), + )} + + ); +}; diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index a9da5341ff..701f7fe7d9 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -32,6 +32,7 @@ export type { } from './color/components/ColorSample'; export { ColorSample } from './color/components/ColorSample'; export { CommandBlock } from './command-block/components/CommandBlock'; +export { LinkifiedText } from './components/LinkifiedText'; export type { IconProps } from './icon/components/Icon'; export { Icon } from './icon/components/Icon'; export { IconAddressBook } from './icon/components/IconAddressBook'; diff --git a/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx b/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx index 21303f0426..d804b9b60d 100644 --- a/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx +++ b/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { isNonEmptyString } from '@sniptt/guards'; +import { LinkifiedText } from '@ui/display/components/LinkifiedText'; import { themeCssVariables } from '@ui/theme-constants'; import { isDefined } from 'twenty-shared/utils'; import { AppTooltip, TooltipDelay } from './AppTooltip'; @@ -32,6 +33,7 @@ const StyledOverflowingMultilineText = styled.div<{ display: -webkit-box; -webkit-box-orient: vertical; white-space: pre-wrap; + overflow-wrap: break-word; `; const StyledOverflowingText = styled.div<{ @@ -131,7 +133,7 @@ export const OverflowingTextWithTooltip = ({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > - {text} + {isNonEmptyString(text) ? : text} ) : ( - {text} + {isNonEmptyString(text) ? : text} )} diff --git a/packages/twenty-ui/src/utilities/index.ts b/packages/twenty-ui/src/utilities/index.ts index f4e12cc18b..efb39f7796 100644 --- a/packages/twenty-ui/src/utilities/index.ts +++ b/packages/twenty-ui/src/utilities/index.ts @@ -38,3 +38,5 @@ export { createState } from './state/utils/createState'; export type { ClickOutsideAttributes } from './types/ClickOutsideAttributes'; export type { Nullable } from './types/Nullable'; export { getDisplayValueByUrlType } from './utils/getDisplayValueByUrlType'; +export type { LinkifyMatch } from './utils/linkifyText'; +export { linkifyText } from './utils/linkifyText'; diff --git a/packages/twenty-ui/src/utilities/utils/__tests__/linkifyText.test.ts b/packages/twenty-ui/src/utilities/utils/__tests__/linkifyText.test.ts new file mode 100644 index 0000000000..3548dae567 --- /dev/null +++ b/packages/twenty-ui/src/utilities/utils/__tests__/linkifyText.test.ts @@ -0,0 +1,51 @@ +import { linkifyText } from '../linkifyText'; + +describe('linkifyText', () => { + it('splits text around a URL into text and link segments', () => { + expect(linkifyText('visit https://example.com today')).toEqual([ + { type: 'text', content: 'visit ' }, + { type: 'link', content: 'https://example.com' }, + { type: 'text', content: ' today' }, + ]); + }); + + it('returns text as-is when no URLs are present', () => { + expect(linkifyText('no links here')).toEqual([ + { type: 'text', content: 'no links here' }, + ]); + }); + + it('parses Teams meeting descriptions with angle-bracket URLs and encoded characters', () => { + const teamsDescription = + 'Need help? | System reference'; + + const result = linkifyText(teamsDescription); + + expect(result).toEqual([ + { type: 'text', content: 'Need help?<' }, + { + type: 'link', + content: 'https://aka.ms/JoinTeamsMeeting?omkt=en-GB', + }, + { type: 'text', content: '> | System reference<' }, + { + type: 'link', + content: + 'https://teams.microsoft.com/l/meetup-join/19%3ameeting_abc%40thread.v2', + }, + { type: 'text', content: '>' }, + ]); + }); + + it('strips trailing punctuation from matched URLs', () => { + expect(linkifyText('see https://example.com, or this.')).toEqual([ + { type: 'text', content: 'see ' }, + { type: 'link', content: 'https://example.com' }, + { type: 'text', content: ', or this.' }, + ]); + }); + + it('returns empty array for empty string', () => { + expect(linkifyText('')).toEqual([]); + }); +}); diff --git a/packages/twenty-ui/src/utilities/utils/linkifyText.ts b/packages/twenty-ui/src/utilities/utils/linkifyText.ts new file mode 100644 index 0000000000..b03e14cf3e --- /dev/null +++ b/packages/twenty-ui/src/utilities/utils/linkifyText.ts @@ -0,0 +1,28 @@ +const URL_REGEX = /https?:\/\/[^\s<>[\]]+[^\s<>[\].,;:!?)]/g; + +export type LinkifyMatch = { + type: 'text' | 'link'; + content: string; +}; + +export const linkifyText = (text: string): LinkifyMatch[] => { + const parts: LinkifyMatch[] = []; + let lastIndex = 0; + + for (const match of text.matchAll(URL_REGEX)) { + const url = match[0]; + const index = match.index; + + if (index > lastIndex) { + parts.push({ type: 'text', content: text.slice(lastIndex, index) }); + } + parts.push({ type: 'link', content: url }); + lastIndex = index + url.length; + } + + if (lastIndex < text.length) { + parts.push({ type: 'text', content: text.slice(lastIndex) }); + } + + return parts; +};