From fc63c51d6d5565bdff3f90f8a400d5307610c506 Mon Sep 17 00:00:00 2001 From: bosiraphael <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 30 Jan 2024 11:39:51 +0100 Subject: [PATCH 01/54] Fix right drawer first bounce (#3689) fix-right-drawer-first-bounce --- .../modules/ui/layout/right-drawer/components/RightDrawer.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx index a8c70dfdd5c..6266ad045b8 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx @@ -85,6 +85,9 @@ export const RightDrawer = () => { return ( Date: Tue, 30 Jan 2024 11:56:26 +0100 Subject: [PATCH 02/54] Fix message opening (#3687) * fix * improve styling so we can close the message by clicking on the whole header * fix line overflowing * fix --- .../emails/components/EmailThreadMessage.tsx | 26 +++++++++++++------ .../components/EmailThreadMessageBody.tsx | 1 + 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessage.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessage.tsx index 20c936e4ae7..92a964807db 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessage.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessage.tsx @@ -11,7 +11,7 @@ const StyledThreadMessage = styled.div` border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; display: flex; flex-direction: column; - padding: ${({ theme }) => theme.spacing(4, 6)}; + padding: ${({ theme }) => theme.spacing(4, 0)}; `; const StyledThreadMessageHeader = styled.div` @@ -20,6 +20,11 @@ const StyledThreadMessageHeader = styled.div` flex-direction: column; justify-content: space-between; margin-bottom: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(0, 6)}; +`; + +const StyledThreadMessageBody = styled.div` + padding: ${({ theme }) => theme.spacing(0, 6)}; `; type EmailThreadMessageProps = { @@ -45,16 +50,21 @@ export const EmailThreadMessage = ({ } return ( - - setIsOpen(!isOpen)}> + !isOpen && setIsOpen(true)} + style={{ cursor: isOpen ? 'auto' : 'pointer' }} + > + isOpen && setIsOpen(false)}> {isOpen && } - {isOpen ? ( - - ) : ( - - )} + + {isOpen ? ( + + ) : ( + + )} + ); }; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx index cb48f07e98a..34b21812275 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx @@ -7,6 +7,7 @@ const StyledThreadMessageBody = styled.div` flex-direction: column; margin-top: ${({ theme }) => theme.spacing(4)}; white-space: pre-line; + overflow-wrap: break-word; `; type EmailThreadMessageBodyProps = { From 0bfc63161bd58fff1de1685ebc07209881cb08c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tha=C3=AFs?= Date: Tue, 30 Jan 2024 08:26:05 -0300 Subject: [PATCH 03/54] =?UTF-8?q?fix:=20fix=20delete=20records=20optimisti?= =?UTF-8?q?c=20effect=20not=20re-rendering=20queries=20af=E2=80=A6=20(#369?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: fix delete records optimistic effect not re-rendering queries after cache.evict --- .../utils/triggerDeleteRecordsOptimisticEffect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts index 51e6c3b9b3b..280ad5f7390 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts @@ -64,5 +64,5 @@ export const triggerDeleteRecordsOptimisticEffect = ({ }, }); - records.forEach((record) => cache.evict({ id: cache.identify(record) })); + cache.gc(); }; From 3a480f150646801d064133876e7371b5c300b7f1 Mon Sep 17 00:00:00 2001 From: bosiraphael <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 30 Jan 2024 13:38:34 +0100 Subject: [PATCH 04/54] fix-right-drawer-bounces-when-opening-a-different-thread (#3691) * fix-right-drawer-bounces-when-opening-a-different-thread * fix --- .../right-drawer/components/RightDrawer.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx index 6266ad045b8..2ec927823f5 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx @@ -83,14 +83,23 @@ export const RightDrawer = () => { return <>; } + const variants = { + fullScreen: { + width: '100%', + }, + normal: { + width: rightDrawerWidth, + }, + closed: { + width: 0, + }, + }; + return ( Date: Tue, 30 Jan 2024 14:40:55 +0100 Subject: [PATCH 05/54] feat: workspace sync (#3505) * feat: wip workspace sync * feat: wip lot of debugging * feat: refactor and fix sync * fix: clean fix: clean * feat: add simple comparator tests * fix: remove debug * feat: wip drop table * fix: main merge * fix: some issues, and prepare storage system to handle complex deletion * feat: wip clean and fix * fix: reflect issue when using array instead of map and clean * fix: test & sync * fix: yarn files * fix: unecesary if-else * fix: if condition not needed * fix: remove debug * fix: replace EQUAL by SKIP * fix: sync metadata relation not applied properly * fix: lint issues * fix: merge issue --- packages/twenty-server/package.json | 2 + .../src/core/analytics/analytics.resolver.ts | 4 +- .../src/core/auth/auth.resolver.ts | 4 +- .../interfaces/feature-flag-map.interface.ts | 3 + .../messaging/timeline-messaging.resolver.ts | 2 +- .../src/core/user/user.resolver.ts | 2 +- .../src/core/workspace/workspace.resolver.ts | 2 +- .../data-seed-dev-workspace.command.ts | 8 +- .../{ => ability}/user-ability.decorator.ts | 0 .../{ => auth}/auth-user.decorator.ts | 0 .../{ => auth}/auth-workspace.decorator.ts | 0 .../environment/environment.validation.ts | 6 +- .../field-metadata/field-metadata.resolver.ts | 2 +- .../field-metadata/field-metadata.service.ts | 3 + .../object-metadata.resolver.ts | 2 +- .../object-metadata.service.ts | 21 +- .../relation-metadata.service.ts | 2 + .../types/relation-to-delete.ts | 2 +- .../utils/generate-migration-name.util.ts | 3 + .../workspace-migration.entity.ts | 2 +- .../workspace-migration.service.ts | 2 + .../twenty-server/src/utils/typed-reflect.ts | 4 +- .../workspace-manager.service.ts | 14 +- .../workspace-migration-runner.service.ts | 22 +- .../sync-workspace-metadata.command.ts | 8 +- .../workspace-field.comparator.spec.ts | 106 ++++ .../workspace-object.comparator.spec.ts | 77 +++ .../workspace-relation.comparator.spec.ts | 60 ++ .../comparators/index.ts | 9 + .../order-object-properties.util.spec.ts | 52 ++ ...sform-metadata-for-comparison.util.spec.ts | 59 ++ .../utils/order-object-properties.util.ts | 21 + .../transform-metadata-for-comparison.util.ts | 82 +++ .../comparators/workspace-field.comparator.ts | 166 +++++ .../workspace-object.comparator.ts | 83 +++ .../workspace-relation.comparator.ts | 72 +++ .../decorators/field-metadata.decorator.ts | 16 +- .../decorators/relation-metadata.decorator.ts | 11 +- .../factories/feature-flags.factory.ts | 34 + .../factories/index.ts | 11 + .../factories/standard-object.factory.ts | 70 +++ .../factories/standard-relation.factory.ts | 116 ++++ .../factories/workspace-sync.factory.ts | 264 ++++++++ .../interfaces/comparator.interface.ts | 46 ++ .../partial-object-metadata.interface.ts | 1 + .../reflect-field-metadata.interface.ts | 7 +- .../workspace-sync-context.interface.ts | 4 + .../reflective-metadata.factory.ts | 163 ----- .../workspace-metadata-updater.service.ts | 189 ++++++ .../workspace-sync-object-metadata.service.ts | 154 +++++ ...orkspace-sync-relation-metadata.service.ts | 105 ++++ .../storage/workspace-sync.storage.ts | 93 +++ .../utils/is-gate-and-not-enabled.util.ts | 9 +- .../utils/sync-metadata.util.spec.ts | 65 +- .../utils/sync-metadata.util.ts | 71 +-- .../workspace-sync-metadata.module.ts | 17 +- .../workspace-sync-metadata.service.ts | 82 +++ .../workspace-sync.metadata.service.ts | 584 ------------------ yarn.lock | 11 + 59 files changed, 2103 insertions(+), 927 deletions(-) create mode 100644 packages/twenty-server/src/core/feature-flag/interfaces/feature-flag-map.interface.ts rename packages/twenty-server/src/decorators/{ => ability}/user-ability.decorator.ts (100%) rename packages/twenty-server/src/decorators/{ => auth}/auth-user.decorator.ts (100%) rename packages/twenty-server/src/decorators/{ => auth}/auth-workspace.decorator.ts (100%) create mode 100644 packages/twenty-server/src/metadata/workspace-migration/utils/generate-migration-name.util.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-field.comparator.spec.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-object.comparator.spec.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-relation.comparator.spec.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/index.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/order-object-properties.util.spec.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/transform-metadata-for-comparison.util.spec.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/order-object-properties.util.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-field.comparator.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-object.comparator.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-relation.comparator.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/factories/feature-flags.factory.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/factories/index.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-object.factory.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-relation.factory.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/factories/workspace-sync.factory.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/comparator.interface.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface.ts delete mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/reflective-metadata.factory.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-metadata-updater.service.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-sync-object-metadata.service.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-sync-relation-metadata.service.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/storage/workspace-sync.storage.ts create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync-metadata.service.ts delete mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync.metadata.service.ts diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index 88ed5893351..3b8e0762f1d 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -78,6 +78,7 @@ "lodash.isempty": "^4.4.0", "lodash.isobject": "^3.0.2", "lodash.kebabcase": "^4.1.1", + "lodash.omit": "^4.5.0", "lodash.snakecase": "^4.1.1", "lodash.upperfirst": "^4.3.1", "mailparser": "^3.6.5", @@ -101,6 +102,7 @@ "devDependencies": { "@types/lodash.isempty": "^4.4.7", "@types/lodash.isobject": "^3.0.7", + "@types/lodash.omit": "^4.5.9", "@types/lodash.snakecase": "^4.1.7", "@types/lodash.upperfirst": "^4.3.7", "@types/react": "^18.2.39", diff --git a/packages/twenty-server/src/core/analytics/analytics.resolver.ts b/packages/twenty-server/src/core/analytics/analytics.resolver.ts index c912dd52ca4..880708b3c18 100644 --- a/packages/twenty-server/src/core/analytics/analytics.resolver.ts +++ b/packages/twenty-server/src/core/analytics/analytics.resolver.ts @@ -2,8 +2,8 @@ import { Resolver, Mutation, Args } from '@nestjs/graphql'; import { UseGuards } from '@nestjs/common'; import { OptionalJwtAuthGuard } from 'src/guards/optional-jwt.auth.guard'; -import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; -import { AuthUser } from 'src/decorators/auth-user.decorator'; +import { AuthWorkspace } from 'src/decorators/auth/auth-workspace.decorator'; +import { AuthUser } from 'src/decorators/auth/auth-user.decorator'; import { Workspace } from 'src/core/workspace/workspace.entity'; import { User } from 'src/core/user/user.entity'; diff --git a/packages/twenty-server/src/core/auth/auth.resolver.ts b/packages/twenty-server/src/core/auth/auth.resolver.ts index 55bad8033a8..0283260bc52 100644 --- a/packages/twenty-server/src/core/auth/auth.resolver.ts +++ b/packages/twenty-server/src/core/auth/auth.resolver.ts @@ -11,10 +11,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; -import { AuthUser } from 'src/decorators/auth-user.decorator'; +import { AuthUser } from 'src/decorators/auth/auth-user.decorator'; import { assert } from 'src/utils/assert'; import { Workspace } from 'src/core/workspace/workspace.entity'; -import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { AuthWorkspace } from 'src/decorators/auth/auth-workspace.decorator'; import { User } from 'src/core/user/user.entity'; import { ApiKeyTokenInput } from 'src/core/auth/dto/api-key-token.input'; import { ValidatePasswordResetToken } from 'src/core/auth/dto/validate-password-reset-token.entity'; diff --git a/packages/twenty-server/src/core/feature-flag/interfaces/feature-flag-map.interface.ts b/packages/twenty-server/src/core/feature-flag/interfaces/feature-flag-map.interface.ts new file mode 100644 index 00000000000..9ec6c9b73e3 --- /dev/null +++ b/packages/twenty-server/src/core/feature-flag/interfaces/feature-flag-map.interface.ts @@ -0,0 +1,3 @@ +import { FeatureFlagKeys } from 'src/core/feature-flag/feature-flag.entity'; + +export type FeatureFlagMap = Record<`${FeatureFlagKeys}`, boolean>; diff --git a/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts b/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts index ef0199cb976..edb5a06d365 100644 --- a/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts +++ b/packages/twenty-server/src/core/messaging/timeline-messaging.resolver.ts @@ -13,7 +13,7 @@ import { Max } from 'class-validator'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { Workspace } from 'src/core/workspace/workspace.entity'; -import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { AuthWorkspace } from 'src/decorators/auth/auth-workspace.decorator'; import { TimelineMessagingService } from 'src/core/messaging/timeline-messaging.service'; import { TIMELINE_THREADS_MAX_PAGE_SIZE } from 'src/core/messaging/constants/messaging.constants'; import { TimelineThreadsWithTotal } from 'src/core/messaging/dtos/timeline-threads-with-total.dto'; diff --git a/packages/twenty-server/src/core/user/user.resolver.ts b/packages/twenty-server/src/core/user/user.resolver.ts index ef7ec59adad..a6e6c6cdfbf 100644 --- a/packages/twenty-server/src/core/user/user.resolver.ts +++ b/packages/twenty-server/src/core/user/user.resolver.ts @@ -15,7 +15,7 @@ import { FileUpload, GraphQLUpload } from 'graphql-upload'; import { SupportDriver } from 'src/integrations/environment/interfaces/support.interface'; import { FileFolder } from 'src/core/file/interfaces/file-folder.interface'; -import { AuthUser } from 'src/decorators/auth-user.decorator'; +import { AuthUser } from 'src/decorators/auth/auth-user.decorator'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; import { FileUploadService } from 'src/core/file/services/file-upload.service'; diff --git a/packages/twenty-server/src/core/workspace/workspace.resolver.ts b/packages/twenty-server/src/core/workspace/workspace.resolver.ts index e588fad936c..a093feaee40 100644 --- a/packages/twenty-server/src/core/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/core/workspace/workspace.resolver.ts @@ -7,7 +7,7 @@ import { FileFolder } from 'src/core/file/interfaces/file-folder.interface'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; import { FileUploadService } from 'src/core/file/services/file-upload.service'; -import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { AuthWorkspace } from 'src/decorators/auth/auth-workspace.decorator'; import { assert } from 'src/utils/assert'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { UpdateWorkspaceInput } from 'src/core/workspace/dtos/update-workspace-input'; diff --git a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts index 24c4e90e8b8..b2aa57ecd58 100644 --- a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts +++ b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts @@ -11,7 +11,7 @@ import { seedWorkspaceMember } from 'src/database/typeorm-seeds/workspace/worksp import { seedPeople } from 'src/database/typeorm-seeds/workspace/people'; import { seedCoreSchema } from 'src/database/typeorm-seeds/core'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; -import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync.metadata.service'; +import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync-metadata.service'; import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service'; @@ -62,8 +62,10 @@ export class DataSeedWorkspaceCommand extends CommandRunner { ); await this.workspaceSyncMetadataService.syncStandardObjectsAndFieldsMetadata( - dataSourceMetadata.id, - this.workspaceId, + { + workspaceId: this.workspaceId, + dataSourceId: dataSourceMetadata.id, + }, ); } catch (error) { console.error(error); diff --git a/packages/twenty-server/src/decorators/user-ability.decorator.ts b/packages/twenty-server/src/decorators/ability/user-ability.decorator.ts similarity index 100% rename from packages/twenty-server/src/decorators/user-ability.decorator.ts rename to packages/twenty-server/src/decorators/ability/user-ability.decorator.ts diff --git a/packages/twenty-server/src/decorators/auth-user.decorator.ts b/packages/twenty-server/src/decorators/auth/auth-user.decorator.ts similarity index 100% rename from packages/twenty-server/src/decorators/auth-user.decorator.ts rename to packages/twenty-server/src/decorators/auth/auth-user.decorator.ts diff --git a/packages/twenty-server/src/decorators/auth-workspace.decorator.ts b/packages/twenty-server/src/decorators/auth/auth-workspace.decorator.ts similarity index 100% rename from packages/twenty-server/src/decorators/auth-workspace.decorator.ts rename to packages/twenty-server/src/decorators/auth/auth-workspace.decorator.ts diff --git a/packages/twenty-server/src/integrations/environment/environment.validation.ts b/packages/twenty-server/src/integrations/environment/environment.validation.ts index c712632f2a2..ba2c3d59b81 100644 --- a/packages/twenty-server/src/integrations/environment/environment.validation.ts +++ b/packages/twenty-server/src/integrations/environment/environment.validation.ts @@ -64,7 +64,11 @@ export class EnvironmentVariables { PORT: number; // Database - @IsUrl({ protocols: ['postgres'], require_tld: false, allow_underscores: true }) + @IsUrl({ + protocols: ['postgres'], + require_tld: false, + allow_underscores: true, + }) PG_DATABASE_URL: string; // Frontend URL diff --git a/packages/twenty-server/src/metadata/field-metadata/field-metadata.resolver.ts b/packages/twenty-server/src/metadata/field-metadata/field-metadata.resolver.ts index 3b712968e9d..91b7dd61300 100644 --- a/packages/twenty-server/src/metadata/field-metadata/field-metadata.resolver.ts +++ b/packages/twenty-server/src/metadata/field-metadata/field-metadata.resolver.ts @@ -2,7 +2,7 @@ import { UseGuards } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { Workspace } from 'src/core/workspace/workspace.entity'; -import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; +import { AuthWorkspace } from 'src/decorators/auth/auth-workspace.decorator'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { CreateOneFieldMetadataInput } from 'src/metadata/field-metadata/dtos/create-field.input'; import { FieldMetadataDTO } from 'src/metadata/field-metadata/dtos/field-metadata.dto'; diff --git a/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts index 7b5bba5d732..723a8f99811 100644 --- a/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts +++ b/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts @@ -24,6 +24,7 @@ import { DataSourceService } from 'src/metadata/data-source/data-source.service' import { UpdateFieldInput } from 'src/metadata/field-metadata/dtos/update-field.input'; import { WorkspaceMigrationFactory } from 'src/metadata/workspace-migration/workspace-migration.factory'; import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util'; +import { generateMigrationName } from 'src/metadata/workspace-migration/utils/generate-migration-name.util'; import { FieldMetadataEntity, @@ -111,6 +112,7 @@ export class FieldMetadataService extends TypeOrmQueryService { + let comparator: WorkspaceFieldComparator; + + beforeEach(() => { + // Initialize the comparator before each test + comparator = new WorkspaceFieldComparator(); + }); + + function createMockFieldMetadata(values: any) { + return { + workspaceId: 'some-workspace-id', + type: 'TEXT', + name: 'DefaultFieldName', + label: 'Default Field Label', + targetColumnMap: 'default_column', + defaultValue: null, + description: 'Default description', + isCustom: false, + isSystem: false, + isNullable: true, + ...values, + }; + } + + it('should generate CREATE action for new fields', () => { + const original = { fields: [] } as any; + const standard = { + fields: [createMockFieldMetadata({ name: 'New Field' })], + } as any; + + const result = comparator.compare(original, standard); + + expect(result).toEqual([ + { + action: ComparatorAction.CREATE, + object: expect.objectContaining(standard.fields[0]), + }, + ]); + }); + + it('should generate UPDATE action for modified fields', () => { + const original = { + fields: [ + createMockFieldMetadata({ + id: '1', + isNullable: true, + }), + ], + } as any; + const standard = { + fields: [ + createMockFieldMetadata({ + isNullable: false, + }), + ], + } as any; + + const result = comparator.compare(original, standard); + + expect(result).toEqual([ + { + action: ComparatorAction.UPDATE, + object: expect.objectContaining({ id: '1', isNullable: false }), + }, + ]); + }); + + it('should generate DELETE action for removed fields', () => { + const original = { + fields: [ + createMockFieldMetadata({ + id: '1', + name: 'Removed Field', + isActive: true, + }), + ], + } as any; + const standard = { fields: [] } as any; + + const result = comparator.compare(original, standard); + + expect(result).toEqual([ + { + action: ComparatorAction.DELETE, + object: expect.objectContaining({ name: 'Removed Field' }), + }, + ]); + }); + + it('should not generate any action for identical fields', () => { + const original = { + fields: [createMockFieldMetadata({ id: '1', isActive: true })], + } as any; + const standard = { + fields: [createMockFieldMetadata({})], + } as any; + + const result = comparator.compare(original, standard); + + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-object.comparator.spec.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-object.comparator.spec.ts new file mode 100644 index 00000000000..a92a297cca3 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-object.comparator.spec.ts @@ -0,0 +1,77 @@ +import { ComparatorAction } from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; + +import { WorkspaceObjectComparator } from 'src/workspace/workspace-sync-metadata/comparators/workspace-object.comparator'; + +describe('WorkspaceObjectComparator', () => { + let comparator: WorkspaceObjectComparator; + + beforeEach(() => { + // Initialize the comparator before each test + comparator = new WorkspaceObjectComparator(); + }); + + function createMockObjectMetadata(values: any) { + return { + nameSingular: 'TestObject', + namePlural: 'TestObjects', + labelSingular: 'Test Object', + labelPlural: 'Test Objects', + ...values, + }; + } + + it('should generate CREATE action for new objects', () => { + const standardObjectMetadata = createMockObjectMetadata({ + description: 'A standard object', + }); + + const result = comparator.compare(undefined, standardObjectMetadata); + + expect(result).toEqual({ + action: ComparatorAction.CREATE, + object: standardObjectMetadata, + }); + }); + + it('should generate UPDATE action for objects with differences', () => { + const originalObjectMetadata = createMockObjectMetadata({ + id: '1', + description: 'Original description', + }); + const standardObjectMetadata = createMockObjectMetadata({ + description: 'Updated description', + }); + + const result = comparator.compare( + originalObjectMetadata, + standardObjectMetadata, + ); + + expect(result).toEqual({ + action: ComparatorAction.UPDATE, + object: expect.objectContaining({ + id: '1', + description: 'Updated description', + }), + }); + }); + + it('should generate SKIP action for identical objects', () => { + const originalObjectMetadata = createMockObjectMetadata({ + id: '1', + description: 'Same description', + }); + const standardObjectMetadata = createMockObjectMetadata({ + description: 'Same description', + }); + + const result = comparator.compare( + originalObjectMetadata, + standardObjectMetadata, + ); + + expect(result).toEqual({ + action: ComparatorAction.SKIP, + }); + }); +}); diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-relation.comparator.spec.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-relation.comparator.spec.ts new file mode 100644 index 00000000000..09b602617d9 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/__tests__/workspace-relation.comparator.spec.ts @@ -0,0 +1,60 @@ +import { ComparatorAction } from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; + +import { WorkspaceRelationComparator } from 'src/workspace/workspace-sync-metadata/comparators/workspace-relation.comparator'; + +describe('WorkspaceRelationComparator', () => { + let comparator: WorkspaceRelationComparator; + + beforeEach(() => { + comparator = new WorkspaceRelationComparator(); + }); + + function createMockRelationMetadata(values: any) { + return { + fromObjectMetadataId: 'object-1', + fromFieldMetadataId: 'field-1', + ...values, + }; + } + + it('should generate CREATE action for new relations', () => { + const original = []; + const standard = [createMockRelationMetadata({})]; + + const result = comparator.compare(original, standard); + + expect(result).toEqual([ + { + action: ComparatorAction.CREATE, + object: expect.objectContaining({ + fromObjectMetadataId: 'object-1', + fromFieldMetadataId: 'field-1', + }), + }, + ]); + }); + + it('should generate DELETE action for removed relations', () => { + const original = [createMockRelationMetadata({ id: '1' })]; + const standard = []; + + const result = comparator.compare(original, standard); + + expect(result).toEqual([ + { + action: ComparatorAction.DELETE, + object: expect.objectContaining({ id: '1' }), + }, + ]); + }); + + it('should not generate any action for identical relations', () => { + const relation = createMockRelationMetadata({}); + const original = [{ id: '1', ...relation }]; + const standard = [relation]; + + const result = comparator.compare(original, standard); + + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/index.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/index.ts new file mode 100644 index 00000000000..3c95f7babe3 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/index.ts @@ -0,0 +1,9 @@ +import { WorkspaceFieldComparator } from './workspace-field.comparator'; +import { WorkspaceObjectComparator } from './workspace-object.comparator'; +import { WorkspaceRelationComparator } from './workspace-relation.comparator'; + +export const workspaceSyncMetadataComparators = [ + WorkspaceFieldComparator, + WorkspaceObjectComparator, + WorkspaceRelationComparator, +]; diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/order-object-properties.util.spec.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/order-object-properties.util.spec.ts new file mode 100644 index 00000000000..60309e8b73a --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/order-object-properties.util.spec.ts @@ -0,0 +1,52 @@ +import { orderObjectProperties } from 'src/workspace/workspace-sync-metadata/comparators/utils/order-object-properties.util'; + +describe('orderObjectProperties', () => { + it('orders simple object properties', () => { + const input = { b: 2, a: 1 }; + const expected = { a: 1, b: 2 }; + + expect(orderObjectProperties(input)).toEqual(expected); + }); + + it('orders nested object properties', () => { + const input = { b: { d: 4, c: 3 }, a: 1 }; + const expected = { a: 1, b: { c: 3, d: 4 } }; + + expect(orderObjectProperties(input)).toEqual(expected); + }); + + it('orders properties in an array of objects', () => { + const input = [ + { b: 2, a: 1 }, + { d: 4, c: 3 }, + ]; + const expected = [ + { a: 1, b: 2 }, + { c: 3, d: 4 }, + ]; + + expect(orderObjectProperties(input)).toEqual(expected); + }); + + it('handles nested arrays within objects', () => { + const input = { b: [{ d: 4, c: 3 }], a: 1 }; + const expected = { a: 1, b: [{ c: 3, d: 4 }] }; + + expect(orderObjectProperties(input)).toEqual(expected); + }); + + it('handles complex nested structures', () => { + const input = { + c: 3, + a: { f: [{ j: 10, i: 9 }, 8], e: 5 }, + b: [7, { h: 6, g: 4 }], + }; + const expected = { + a: { e: 5, f: [{ i: 9, j: 10 }, 8] }, + b: [7, { g: 4, h: 6 }], + c: 3, + }; + + expect(orderObjectProperties(input)).toEqual(expected); + }); +}); diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/transform-metadata-for-comparison.util.spec.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/transform-metadata-for-comparison.util.spec.ts new file mode 100644 index 00000000000..eac21ff46fc --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/__tests__/transform-metadata-for-comparison.util.spec.ts @@ -0,0 +1,59 @@ +import { transformMetadataForComparison } from 'src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; // Adjust the import path as necessary + +describe('transformMetadataForComparison', () => { + // Test for a single object + it('transforms a single object correctly with nested objects', () => { + const input = { name: 'Test', details: { a: 1, nested: { b: 2 } } }; + const result = transformMetadataForComparison(input, { + propertiesToStringify: ['details'], + }); + + expect(result).toEqual({ + name: 'Test', + details: '{"a":1,"nested":{"b":2}}', + }); + }); + + // Test for an array of objects + it('transforms an array of objects correctly, ignoring and stringifying multiple properties', () => { + const input = [ + { name: 'Test1', value: { a: 1 }, ignored: 'ignoreMe' }, + { name: 'Test2', value: { c: 3 }, extra: 'keepMe' }, + ]; + const result = transformMetadataForComparison(input, { + propertiesToIgnore: ['ignored'], + propertiesToStringify: ['value'], + keyFactory: (datum) => datum.name, + }); + + expect(result).toEqual({ + Test1: { name: 'Test1', value: '{"a":1}' }, + Test2: { name: 'Test2', value: '{"c":3}', extra: 'keepMe' }, + }); + }); + + // Test with a custom keyFactory function + it('uses a custom keyFactory function to generate keys', () => { + const input = [{ id: 123, name: 'Test' }]; + const result = transformMetadataForComparison(input, { + keyFactory: (datum) => `key-${datum.id}`, + }); + + expect(result).toHaveProperty('key-123'); + expect(result['key-123']).toEqual({ id: 123, name: 'Test' }); + }); + + // Test with an empty array + it('handles an empty array gracefully', () => { + const result = transformMetadataForComparison([], {}); + + expect(result).toEqual({}); + }); + + // Test with an empty object + it('handles an empty object gracefully', () => { + const result = transformMetadataForComparison({}, {}); + + expect(result).toEqual({}); + }); +}); diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/order-object-properties.util.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/order-object-properties.util.ts new file mode 100644 index 00000000000..1ac4acdde9d --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/order-object-properties.util.ts @@ -0,0 +1,21 @@ +export function orderObjectProperties(data: T[]): T[]; + +export function orderObjectProperties(data: T): T; + +export function orderObjectProperties | object>( + data: T, +): T { + if (Array.isArray(data)) { + return data.map(orderObjectProperties) as T; + } + + if (data !== null && typeof data === 'object') { + return Object.fromEntries( + Object.entries(data) + .sort() + .map(([key, value]) => [key, orderObjectProperties(value)]), + ) as T; + } + + return data; +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util.ts new file mode 100644 index 00000000000..963b568125f --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util.ts @@ -0,0 +1,82 @@ +import { orderObjectProperties } from './order-object-properties.util'; + +type TransformToString = { + [P in keyof T]: P extends Keys ? string : T[P]; +}; + +// Overload for an array of T +export function transformMetadataForComparison( + fieldMetadataCollection: T[], + options: { + propertiesToIgnore?: readonly Keys[]; + propertiesToStringify?: readonly Keys[]; + keyFactory: (datum: T) => string; + }, +): Record>; + +// Overload for a single T object +export function transformMetadataForComparison( + fieldMetadataCollection: T, + options: { + propertiesToIgnore?: readonly Keys[]; + propertiesToStringify?: readonly Keys[]; + }, +): TransformToString; + +export function transformMetadataForComparison( + metadata: T[] | T, + options: { + propertiesToIgnore?: readonly Keys[]; + propertiesToStringify?: readonly Keys[]; + keyFactory?: (datum: T) => string; + }, +): Record> | TransformToString { + const propertiesToIgnore = (options.propertiesToIgnore ?? + []) as readonly string[]; + const propertiesToStringify = (options.propertiesToStringify ?? + []) as readonly string[]; + + const transformProperties = (datum: T): TransformToString => { + const transformedField = {} as TransformToString; + + for (const property in datum) { + if (propertiesToIgnore.includes(property)) { + continue; + } + if ( + propertiesToStringify.includes(property) && + datum[property] !== null && + typeof datum[property] === 'object' + ) { + const orderedValue = orderObjectProperties(datum[property] as object); + + transformedField[property as string] = JSON.stringify( + orderedValue, + ) as T[Keys]; + } else { + transformedField[property as string] = datum[property]; + } + } + + return transformedField; + }; + + if (Array.isArray(metadata)) { + return metadata.reduce>>( + (acc, datum) => { + const key = options.keyFactory?.(datum); + + if (!key) { + throw new Error('keyFactory must be implemented'); + } + + acc[key] = transformProperties(datum); + + return acc; + }, + {}, + ); + } else { + return transformProperties(metadata); + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-field.comparator.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-field.comparator.ts new file mode 100644 index 00000000000..8febe3a6d4c --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-field.comparator.ts @@ -0,0 +1,166 @@ +import { Injectable } from '@nestjs/common'; + +import diff from 'microdiff'; + +import { + ComparatorAction, + FieldComparatorResult, +} from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; +import { PartialFieldMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-field-metadata.interface'; +import { PartialObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface'; + +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { transformMetadataForComparison } from 'src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; + +const fieldPropertiesToIgnore = [ + 'id', + 'createdAt', + 'updatedAt', + 'objectMetadataId', + 'isActive', +] as const; + +const fieldPropertiesToStringify = [ + 'targetColumnMap', + 'defaultValue', + 'options', +] as const; + +@Injectable() +export class WorkspaceFieldComparator { + constructor() {} + + public compare( + originalObjectMetadata: ObjectMetadataEntity, + standardObjectMetadata: PartialObjectMetadata, + ): FieldComparatorResult[] { + const result: FieldComparatorResult[] = []; + const fieldPropertiesToUpdateMap: Record< + string, + Partial + > = {}; + const originalFieldMetadataMap = transformMetadataForComparison( + originalObjectMetadata.fields, + { + propertiesToIgnore: fieldPropertiesToIgnore, + propertiesToStringify: fieldPropertiesToStringify, + keyFactory(datum) { + return datum.name; + }, + }, + ); + const standardFieldMetadataMap = transformMetadataForComparison( + standardObjectMetadata.fields, + { + propertiesToIgnore: fieldPropertiesToStringify, + keyFactory(datum) { + return datum.name; + }, + }, + ); + + // Compare fields + const fieldMetadataDifference = diff( + originalFieldMetadataMap, + standardFieldMetadataMap, + ); + + for (const difference of fieldMetadataDifference) { + const fieldName = difference.path[0]; + // Object shouldn't have thousands of fields, so we can use find here + const standardFieldMetadata = standardObjectMetadata.fields.find( + (field) => field.name === fieldName, + ); + const originalFieldMetadata = originalObjectMetadata.fields.find( + (field) => field.name === fieldName, + ); + + switch (difference.type) { + case 'CREATE': { + if (!standardFieldMetadata) { + throw new Error( + `Field ${fieldName} not found in standardObjectMetadata`, + ); + } + + result.push({ + action: ComparatorAction.CREATE, + object: { + ...standardFieldMetadata, + objectMetadataId: originalObjectMetadata.id, + }, + }); + break; + } + case 'CHANGE': { + if (!originalFieldMetadata) { + throw new Error( + `Field ${fieldName} not found in originalObjectMetadata`, + ); + } + + const id = originalFieldMetadata.id; + const property = difference.path[difference.path.length - 1]; + + // If the old value and the new value are both null, skip + // Database is storing null, and we can get undefined here + if ( + difference.oldValue === null && + (difference.value === null || difference.value === undefined) + ) { + break; + } + + if (typeof property !== 'string') { + break; + } + + if (!fieldPropertiesToUpdateMap[id]) { + fieldPropertiesToUpdateMap[id] = {}; + } + + // If the property is a stringified JSON, parse it + if ( + (fieldPropertiesToStringify as readonly string[]).includes(property) + ) { + fieldPropertiesToUpdateMap[id][property] = JSON.parse( + difference.value, + ); + } else { + fieldPropertiesToUpdateMap[id][property] = difference.value; + } + break; + } + case 'REMOVE': { + if (!originalFieldMetadata) { + throw new Error( + `Field ${fieldName} not found in originalObjectMetadata`, + ); + } + + if (difference.path.length === 1) { + result.push({ + action: ComparatorAction.DELETE, + object: originalFieldMetadata, + }); + } + break; + } + } + } + + for (const [id, fieldPropertiesToUpdate] of Object.entries( + fieldPropertiesToUpdateMap, + )) { + result.push({ + action: ComparatorAction.UPDATE, + object: { + id, + ...fieldPropertiesToUpdate, + }, + }); + } + + return result; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-object.comparator.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-object.comparator.ts new file mode 100644 index 00000000000..bcbd562703f --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-object.comparator.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; + +import diff from 'microdiff'; +import omit from 'lodash.omit'; + +import { + ComparatorAction, + ObjectComparatorResult, +} from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; +import { PartialObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface'; + +import { transformMetadataForComparison } from 'src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; + +const objectPropertiesToIgnore = [ + 'id', + 'createdAt', + 'updatedAt', + 'labelIdentifierFieldMetadataId', + 'imageIdentifierFieldMetadataId', + 'isActive', + 'fields', +] as const; + +@Injectable() +export class WorkspaceObjectComparator { + constructor() {} + + public compare( + originalObjectMetadata: ObjectMetadataEntity | undefined, + standardObjectMetadata: PartialObjectMetadata, + ): ObjectComparatorResult { + // If the object doesn't exist in the original metadata, we need to create it + if (!originalObjectMetadata) { + return { + action: ComparatorAction.CREATE, + object: standardObjectMetadata, + }; + } + + const objectPropertiesToUpdate: Partial = {}; + + // Only compare properties that are not ignored + const partialOriginalObjectMetadata = transformMetadataForComparison( + originalObjectMetadata, + { + propertiesToIgnore: objectPropertiesToIgnore, + }, + ); + + // Compare objects + const objectMetadataDifference = diff( + partialOriginalObjectMetadata, + omit(standardObjectMetadata, 'fields'), + ); + + // Loop through the differences and create an object with the properties to update + for (const difference of objectMetadataDifference) { + // We only handle CHANGE here as REMOVE and CREATE are handled earlier. + if (difference.type === 'CHANGE') { + const property = difference.path[0]; + + objectPropertiesToUpdate[property] = difference.value; + } + } + + // If there are no properties to update, the objects are equal + if (Object.keys(objectPropertiesToUpdate).length === 0) { + return { + action: ComparatorAction.SKIP, + }; + } + + // If there are properties to update, we need to update the object + return { + action: ComparatorAction.UPDATE, + object: { + id: originalObjectMetadata.id, + ...objectPropertiesToUpdate, + }, + }; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-relation.comparator.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-relation.comparator.ts new file mode 100644 index 00000000000..727a8c5f516 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/comparators/workspace-relation.comparator.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; + +import diff from 'microdiff'; + +import { + ComparatorAction, + RelationComparatorResult, +} from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; + +import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation-metadata.entity'; +import { transformMetadataForComparison } from 'src/workspace/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; + +const relationPropertiesToIgnore = ['createdAt', 'updatedAt'] as const; + +@Injectable() +export class WorkspaceRelationComparator { + constructor() {} + + compare( + originalRelationMetadataCollection: RelationMetadataEntity[], + standardRelationMetadataCollection: Partial[], + ): RelationComparatorResult[] { + const results: RelationComparatorResult[] = []; + + // Create a map of standard relations + const standardRelationMetadataMap = transformMetadataForComparison( + standardRelationMetadataCollection, + { + keyFactory(relationMetadata) { + return `${relationMetadata.fromObjectMetadataId}->${relationMetadata.fromFieldMetadataId}`; + }, + }, + ); + + // Create a filtered map of original relations + // We filter out 'id' later because we need it to remove the relation from DB + const originalRelationMetadataMap = transformMetadataForComparison( + originalRelationMetadataCollection, + { + propertiesToIgnore: relationPropertiesToIgnore, + keyFactory(relationMetadata) { + return `${relationMetadata.fromObjectMetadataId}->${relationMetadata.fromFieldMetadataId}`; + }, + }, + ); + + // Compare relations + const relationMetadataDifference = diff( + originalRelationMetadataMap, + standardRelationMetadataMap, + ); + + for (const difference of relationMetadataDifference) { + if (difference.type === 'CREATE') { + results.push({ + action: ComparatorAction.CREATE, + object: difference.value, + }); + } else if ( + difference.type === 'REMOVE' && + difference.path[difference.path.length - 1] !== 'id' + ) { + results.push({ + action: ComparatorAction.DELETE, + object: difference.oldValue, + }); + } + } + + return results; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/field-metadata.decorator.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/field-metadata.decorator.ts index a2fc29f6069..351d428a502 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/field-metadata.decorator.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/field-metadata.decorator.ts @@ -3,6 +3,7 @@ import { ReflectFieldMetadata, } from 'src/workspace/workspace-sync-metadata/interfaces/reflect-field-metadata.interface'; import { GateDecoratorParams } from 'src/workspace/workspace-sync-metadata/interfaces/gate-decorator.interface'; +import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface'; import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/generate-target-column-map.util'; @@ -14,7 +15,7 @@ export function FieldMetadata( ): PropertyDecorator { return (target: object, fieldKey: string) => { const existingFieldMetadata = - TypedReflect.getMetadata('fieldMetadata', target.constructor) ?? {}; + TypedReflect.getMetadata('fieldMetadataMap', target.constructor) ?? {}; const isNullable = TypedReflect.getMetadata('isNullable', target, fieldKey) ?? false; const isSystem = @@ -23,7 +24,7 @@ export function FieldMetadata( const { joinColumn, ...restParams } = params; TypedReflect.defineMetadata( - 'fieldMetadata', + 'fieldMetadataMap', { ...existingFieldMetadata, [fieldKey]: generateFieldMetadata( @@ -65,19 +66,22 @@ function generateFieldMetadata( gate: GateDecoratorParams | undefined = undefined, ): ReflectFieldMetadata[string] { const targetColumnMap = generateTargetColumnMap(params.type, false, fieldKey); - const defaultValue = params.defaultValue ?? generateDefaultValue(params.type); + const defaultValue = (params.defaultValue ?? + generateDefaultValue( + params.type, + )) as FieldMetadataDefaultValue<'default'> | null; return { name: fieldKey, ...params, - targetColumnMap: JSON.stringify(targetColumnMap), + targetColumnMap, isNullable: params.type === FieldMetadataType.RELATION ? true : isNullable, isSystem, isCustom: false, - options: params.options ? JSON.stringify(params.options) : null, + options: params.options, description: params.description, icon: params.icon, - defaultValue: defaultValue ? JSON.stringify(defaultValue) : null, + defaultValue, gate, }; } diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/relation-metadata.decorator.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/relation-metadata.decorator.ts index 142061671cf..62c5b11a252 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/relation-metadata.decorator.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/decorators/relation-metadata.decorator.ts @@ -9,17 +9,20 @@ export function RelationMetadata( params: RelationMetadataDecoratorParams, ): PropertyDecorator { return (target: object, fieldKey: string) => { - const existingRelationMetadata = - TypedReflect.getMetadata('relationMetadata', target.constructor) ?? []; + const relationMetadataCollection = + TypedReflect.getMetadata( + 'relationMetadataCollection', + target.constructor, + ) ?? []; const gate = TypedReflect.getMetadata('gate', target, fieldKey); const objectName = convertClassNameToObjectMetadataName( target.constructor.name, ); Reflect.defineMetadata( - 'relationMetadata', + 'relationMetadataCollection', [ - ...existingRelationMetadata, + ...relationMetadataCollection, { type: params.type, fromObjectNameSingular: objectName, diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/feature-flags.factory.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/feature-flags.factory.ts new file mode 100644 index 00000000000..82904e5d8f7 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/feature-flags.factory.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { WorkspaceSyncContext } from 'src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; +import { FeatureFlagMap } from 'src/core/feature-flag/interfaces/feature-flag-map.interface'; + +import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; + +@Injectable() +export class FeatureFlagFactory { + constructor( + @InjectRepository(FeatureFlagEntity, 'core') + private readonly featureFlagRepository: Repository, + ) {} + + async create(context: WorkspaceSyncContext): Promise { + const workspaceFeatureFlags = await this.featureFlagRepository.find({ + where: { workspaceId: context.workspaceId }, + }); + + const workspaceFeatureFlagsMap = workspaceFeatureFlags.reduce( + (result, currentFeatureFlag) => { + result[currentFeatureFlag.key] = currentFeatureFlag.value; + + return result; + }, + {} as FeatureFlagMap, + ); + + return workspaceFeatureFlagsMap; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/index.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/index.ts new file mode 100644 index 00000000000..df6a4bfdb98 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/index.ts @@ -0,0 +1,11 @@ +import { FeatureFlagFactory } from './feature-flags.factory'; +import { StandardObjectFactory } from './standard-object.factory'; +import { StandardRelationFactory } from './standard-relation.factory'; +import { WorkspaceSyncFactory } from './workspace-sync.factory'; + +export const workspaceSyncMetadataFactories = [ + FeatureFlagFactory, + StandardObjectFactory, + StandardRelationFactory, + WorkspaceSyncFactory, +]; diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-object.factory.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-object.factory.ts new file mode 100644 index 00000000000..fd76c4e06ad --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-object.factory.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceSyncContext } from 'src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; +import { PartialObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface'; +import { FeatureFlagMap } from 'src/core/feature-flag/interfaces/feature-flag-map.interface'; +import { PartialFieldMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-field-metadata.interface'; + +import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata'; +import { standardObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects'; +import { TypedReflect } from 'src/utils/typed-reflect'; +import { isGatedAndNotEnabled } from 'src/workspace/workspace-sync-metadata/utils/is-gate-and-not-enabled.util'; + +@Injectable() +export class StandardObjectFactory { + create( + context: WorkspaceSyncContext, + workspaceFeatureFlagsMap: FeatureFlagMap, + ): PartialObjectMetadata[] { + return standardObjectMetadata + .map((metadata) => + this.createObjectMetadata(metadata, context, workspaceFeatureFlagsMap), + ) + .filter((metadata): metadata is PartialObjectMetadata => !!metadata); + } + + private createObjectMetadata( + metadata: typeof BaseObjectMetadata, + context: WorkspaceSyncContext, + workspaceFeatureFlagsMap: FeatureFlagMap, + ): PartialObjectMetadata | undefined { + const objectMetadata = TypedReflect.getMetadata('objectMetadata', metadata); + const fieldMetadataMap = + TypedReflect.getMetadata('fieldMetadataMap', metadata) ?? []; + + if (!objectMetadata) { + throw new Error( + `Object metadata decorator not found, can\'t parse ${metadata.name}`, + ); + } + + if (isGatedAndNotEnabled(objectMetadata.gate, workspaceFeatureFlagsMap)) { + return undefined; + } + + const fields = Object.values(fieldMetadataMap).reduce( + // Omit gate as we don't want to store it in the DB + (acc, { gate, ...fieldMetadata }) => { + if (isGatedAndNotEnabled(gate, workspaceFeatureFlagsMap)) { + return acc; + } + + acc.push({ + ...fieldMetadata, + workspaceId: context.workspaceId, + isSystem: objectMetadata.isSystem || fieldMetadata.isSystem, + }); + + return acc; + }, + [] as PartialFieldMetadata[], + ); + + return { + ...objectMetadata, + workspaceId: context.workspaceId, + dataSourceId: context.dataSourceId, + fields, + }; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-relation.factory.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-relation.factory.ts new file mode 100644 index 00000000000..8d98da4bcb4 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/standard-relation.factory.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceSyncContext } from 'src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; +import { FeatureFlagMap } from 'src/core/feature-flag/interfaces/feature-flag-map.interface'; + +import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata'; +import { standardObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects'; +import { TypedReflect } from 'src/utils/typed-reflect'; +import { isGatedAndNotEnabled } from 'src/workspace/workspace-sync-metadata/utils/is-gate-and-not-enabled.util'; +import { assert } from 'src/utils/assert'; +import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation-metadata.entity'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; + +@Injectable() +export class StandardRelationFactory { + create( + context: WorkspaceSyncContext, + originalObjectMetadataMap: Record, + workspaceFeatureFlagsMap: FeatureFlagMap, + ): Partial[] { + return standardObjectMetadata.flatMap((standardObjectMetadata) => + this.createRelationMetadata( + standardObjectMetadata, + context, + originalObjectMetadataMap, + workspaceFeatureFlagsMap, + ), + ); + } + + private createRelationMetadata( + standardObjectMetadata: typeof BaseObjectMetadata, + context: WorkspaceSyncContext, + originalObjectMetadataMap: Record, + workspaceFeatureFlagsMap: FeatureFlagMap, + ): Partial[] { + const objectMetadata = TypedReflect.getMetadata( + 'objectMetadata', + standardObjectMetadata, + ); + const relationMetadataCollection = TypedReflect.getMetadata( + 'relationMetadataCollection', + standardObjectMetadata, + ); + + if (!objectMetadata) { + throw new Error( + `Object metadata decorator not found, can\'t parse ${standardObjectMetadata.name}`, + ); + } + + if ( + !relationMetadataCollection || + isGatedAndNotEnabled(objectMetadata.gate, workspaceFeatureFlagsMap) + ) { + return []; + } + + return relationMetadataCollection + .filter( + (relationMetadata) => + !isGatedAndNotEnabled( + relationMetadata.gate, + workspaceFeatureFlagsMap, + ), + ) + .map((relationMetadata) => { + const fromObjectMetadata = + originalObjectMetadataMap[relationMetadata.fromObjectNameSingular]; + + assert( + fromObjectMetadata, + `Object ${relationMetadata.fromObjectNameSingular} not found in DB + for relation FROM defined in class ${objectMetadata.nameSingular}`, + ); + + const toObjectMetadata = + originalObjectMetadataMap[relationMetadata.toObjectNameSingular]; + + assert( + toObjectMetadata, + `Object ${relationMetadata.toObjectNameSingular} not found in DB + for relation TO defined in class ${objectMetadata.nameSingular}`, + ); + + const fromFieldMetadata = fromObjectMetadata?.fields.find( + (field) => field.name === relationMetadata.fromFieldMetadataName, + ); + + assert( + fromFieldMetadata, + `Field ${relationMetadata.fromFieldMetadataName} not found in object ${relationMetadata.fromObjectNameSingular} + for relation FROM defined in class ${objectMetadata.nameSingular}`, + ); + + const toFieldMetadata = toObjectMetadata?.fields.find( + (field) => field.name === relationMetadata.toFieldMetadataName, + ); + + assert( + toFieldMetadata, + `Field ${relationMetadata.toFieldMetadataName} not found in object ${relationMetadata.toObjectNameSingular} + for relation TO defined in class ${objectMetadata.nameSingular}`, + ); + + return { + relationType: relationMetadata.type, + fromObjectMetadataId: fromObjectMetadata?.id, + toObjectMetadataId: toObjectMetadata?.id, + fromFieldMetadataId: fromFieldMetadata?.id, + toFieldMetadataId: toFieldMetadata?.id, + workspaceId: context.workspaceId, + }; + }); + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/workspace-sync.factory.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/workspace-sync.factory.ts new file mode 100644 index 00000000000..3c893571a1a --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/factories/workspace-sync.factory.ts @@ -0,0 +1,264 @@ +import { Injectable } from '@nestjs/common'; + +import { + FieldMetadataEntity, + FieldMetadataType, +} from 'src/metadata/field-metadata/field-metadata.entity'; +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { + WorkspaceMigrationColumnActionType, + WorkspaceMigrationColumnRelation, + WorkspaceMigrationEntity, + WorkspaceMigrationTableAction, +} from 'src/metadata/workspace-migration/workspace-migration.entity'; +import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util'; +import { WorkspaceMigrationFactory } from 'src/metadata/workspace-migration/workspace-migration.factory'; +import { + RelationMetadataEntity, + RelationMetadataType, +} from 'src/metadata/relation-metadata/relation-metadata.entity'; +import { camelCase } from 'src/utils/camel-case'; +import { generateMigrationName } from 'src/metadata/workspace-migration/utils/generate-migration-name.util'; + +@Injectable() +export class WorkspaceSyncFactory { + constructor( + private readonly workspaceMigrationFactory: WorkspaceMigrationFactory, + ) {} + + async createObjectMigration( + originalObjectMetadataCollection: ObjectMetadataEntity[], + createdObjectMetadataCollection: ObjectMetadataEntity[], + objectMetadataDeleteCollection: ObjectMetadataEntity[], + createdFieldMetadataCollection: FieldMetadataEntity[], + fieldMetadataDeleteCollection: FieldMetadataEntity[], + ): Promise[]> { + const workspaceMigrations: Partial[] = []; + + /** + * Create object migrations + */ + if (createdObjectMetadataCollection.length > 0) { + for (const objectMetadata of createdObjectMetadataCollection) { + const migrations = [ + { + name: computeObjectTargetTable(objectMetadata), + action: 'create', + } satisfies WorkspaceMigrationTableAction, + ...objectMetadata.fields + .filter((field) => field.type !== FieldMetadataType.RELATION) + .map( + (field) => + ({ + name: computeObjectTargetTable(objectMetadata), + action: 'alter', + columns: this.workspaceMigrationFactory.createColumnActions( + WorkspaceMigrationColumnActionType.CREATE, + field, + ), + }) satisfies WorkspaceMigrationTableAction, + ), + ]; + + workspaceMigrations.push({ + workspaceId: objectMetadata.workspaceId, + name: generateMigrationName(`create-${objectMetadata.nameSingular}`), + isCustom: false, + migrations, + }); + } + } + + /** + * Delete object migrations + * TODO: handle object delete migrations. + * Note: we need to delete the relation first due to the DB constraint. + */ + // if (objectMetadataDeleteCollection.length > 0) { + // for (const objectMetadata of objectMetadataDeleteCollection) { + // const migrations = [ + // { + // name: computeObjectTargetTable(objectMetadata), + // action: 'drop', + // columns: [], + // } satisfies WorkspaceMigrationTableAction, + // ]; + + // workspaceMigrations.push({ + // workspaceId: objectMetadata.workspaceId, + // isCustom: false, + // migrations, + // }); + // } + // } + + /** + * Create field migrations + */ + const originalObjectMetadataMap = originalObjectMetadataCollection.reduce( + (result, currentObject) => { + result[currentObject.id] = currentObject; + + return result; + }, + {} as Record, + ); + + if (createdFieldMetadataCollection.length > 0) { + for (const fieldMetadata of createdFieldMetadataCollection) { + const migrations = [ + { + name: computeObjectTargetTable( + originalObjectMetadataMap[fieldMetadata.objectMetadataId], + ), + action: 'alter', + columns: this.workspaceMigrationFactory.createColumnActions( + WorkspaceMigrationColumnActionType.CREATE, + fieldMetadata, + ), + } satisfies WorkspaceMigrationTableAction, + ]; + + workspaceMigrations.push({ + workspaceId: fieldMetadata.workspaceId, + name: generateMigrationName(`create-${fieldMetadata.name}`), + isCustom: false, + migrations, + }); + } + } + + /** + * Delete field migrations + */ + if (fieldMetadataDeleteCollection.length > 0) { + for (const fieldMetadata of fieldMetadataDeleteCollection) { + const migrations = [ + { + name: computeObjectTargetTable( + originalObjectMetadataMap[fieldMetadata.objectMetadataId], + ), + action: 'alter', + columns: [ + { + action: WorkspaceMigrationColumnActionType.DROP, + columnName: fieldMetadata.name, + }, + ], + } satisfies WorkspaceMigrationTableAction, + ]; + + workspaceMigrations.push({ + workspaceId: fieldMetadata.workspaceId, + name: generateMigrationName(`delete-${fieldMetadata.name}`), + isCustom: false, + migrations, + }); + } + } + + return workspaceMigrations; + } + + async createRelationMigration( + originalObjectMetadataCollection: ObjectMetadataEntity[], + createdRelationMetadataCollection: RelationMetadataEntity[], + // TODO: handle relation deletion + // eslint-disable-next-line @typescript-eslint/no-unused-vars + relationMetadataDeleteCollection: RelationMetadataEntity[], + ): Promise[]> { + const workspaceMigrations: Partial[] = []; + + if (createdRelationMetadataCollection.length > 0) { + for (const relationMetadata of createdRelationMetadataCollection) { + const toObjectMetadata = originalObjectMetadataCollection.find( + (object) => object.id === relationMetadata.toObjectMetadataId, + ); + + const fromObjectMetadata = originalObjectMetadataCollection.find( + (object) => object.id === relationMetadata.fromObjectMetadataId, + ); + + if (!toObjectMetadata) { + throw new Error( + `ObjectMetadata with id ${relationMetadata.toObjectMetadataId} not found`, + ); + } + + if (!fromObjectMetadata) { + throw new Error( + `ObjectMetadata with id ${relationMetadata.fromObjectMetadataId} not found`, + ); + } + + const toFieldMetadata = toObjectMetadata.fields.find( + (field) => field.id === relationMetadata.toFieldMetadataId, + ); + + if (!toFieldMetadata) { + throw new Error( + `FieldMetadata with id ${relationMetadata.toFieldMetadataId} not found`, + ); + } + + const migrations = [ + { + name: computeObjectTargetTable(toObjectMetadata), + action: 'alter', + columns: [ + { + action: WorkspaceMigrationColumnActionType.RELATION, + columnName: `${camelCase(toFieldMetadata.name)}Id`, + referencedTableName: + computeObjectTargetTable(fromObjectMetadata), + referencedTableColumnName: 'id', + isUnique: + relationMetadata.relationType === + RelationMetadataType.ONE_TO_ONE, + } satisfies WorkspaceMigrationColumnRelation, + ], + } satisfies WorkspaceMigrationTableAction, + ]; + + workspaceMigrations.push({ + workspaceId: relationMetadata.workspaceId, + name: generateMigrationName( + `create-relation-from-${fromObjectMetadata.nameSingular}-to-${toObjectMetadata.nameSingular}`, + ), + isCustom: false, + migrations, + }); + } + } + + // if (relationMetadataDeleteCollection.length > 0) { + // for (const relationMetadata of relationMetadataDeleteCollection) { + // const toObjectMetadata = originalObjectMetadataCollection.find( + // (object) => object.id === relationMetadata.toObjectMetadataId, + // ); + + // if (!toObjectMetadata) { + // throw new Error( + // `ObjectMetadata with id ${relationMetadata.toObjectMetadataId} not found`, + // ); + // } + + // const migrations = [ + // { + // name: computeObjectTargetTable(toObjectMetadata), + // action: 'drop', + // columns: [], + // } satisfies WorkspaceMigrationTableAction, + // ]; + + // workspaceMigrations.push({ + // workspaceId: relationMetadata.workspaceId, + // isCustom: false, + // migrations, + // }); + // } + // } + + return workspaceMigrations; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/comparator.interface.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/comparator.interface.ts new file mode 100644 index 00000000000..a90201192bc --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/comparator.interface.ts @@ -0,0 +1,46 @@ +import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity'; +import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation-metadata.entity'; + +import { PartialFieldMetadata } from './partial-field-metadata.interface'; +import { PartialObjectMetadata } from './partial-object-metadata.interface'; + +export const enum ComparatorAction { + SKIP = 'SKIP', + CREATE = 'CREATE', + UPDATE = 'UPDATE', + DELETE = 'DELETE', +} + +export interface ComparatorSkipResult { + action: ComparatorAction.SKIP; +} + +export interface ComparatorCreateResult { + action: ComparatorAction.CREATE; + object: T; +} + +export interface ComparatorUpdateResult { + action: ComparatorAction.UPDATE; + object: T; +} + +export interface ComparatorDeleteResult { + action: ComparatorAction.DELETE; + object: T; +} + +export type ObjectComparatorResult = + | ComparatorSkipResult + | ComparatorCreateResult + | ComparatorUpdateResult>; + +export type FieldComparatorResult = + | ComparatorSkipResult + | ComparatorCreateResult + | ComparatorUpdateResult & { id: string }> + | ComparatorDeleteResult; + +export type RelationComparatorResult = + | ComparatorCreateResult> + | ComparatorDeleteResult; diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface.ts index 0e12d1bcd57..1080b34cf7b 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface.ts @@ -2,6 +2,7 @@ import { PartialFieldMetadata } from 'src/workspace/workspace-sync-metadata/inte import { ReflectObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/reflect-object-metadata.interface'; export type PartialObjectMetadata = ReflectObjectMetadata & { + id?: string; workspaceId: string; dataSourceId: string; fields: PartialFieldMetadata[]; diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/reflect-field-metadata.interface.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/reflect-field-metadata.interface.ts index 6d4c66ca218..873ed5b75e9 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/reflect-field-metadata.interface.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/reflect-field-metadata.interface.ts @@ -1,6 +1,7 @@ import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface'; import { GateDecoratorParams } from 'src/workspace/workspace-sync-metadata/interfaces/gate-decorator.interface'; import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/field-metadata-options.interface'; +import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface'; import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; @@ -23,13 +24,13 @@ export interface ReflectFieldMetadata { > & { name: string; type: FieldMetadataType; - targetColumnMap: string; + targetColumnMap: FieldMetadataTargetColumnMap<'default'>; isNullable: boolean; isSystem: boolean; isCustom: boolean; description?: string; - defaultValue: string | null; + defaultValue: FieldMetadataDefaultValue<'default'> | null; gate?: GateDecoratorParams; - options?: string | null; + options?: FieldMetadataOptions<'default'> | null; }; } diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface.ts new file mode 100644 index 00000000000..fc0c7b5c558 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface.ts @@ -0,0 +1,4 @@ +export interface WorkspaceSyncContext { + workspaceId: string; + dataSourceId: string; +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/reflective-metadata.factory.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/reflective-metadata.factory.ts deleted file mode 100644 index ab10138f303..00000000000 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/reflective-metadata.factory.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import assert from 'assert'; - -import { PartialObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface'; -import { MappedObjectMetadataEntity } from 'src/workspace/workspace-sync-metadata/interfaces/mapped-metadata.interface'; - -import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata'; -import { TypedReflect } from 'src/utils/typed-reflect'; -import { isGatedAndNotEnabled } from 'src/workspace/workspace-sync-metadata/utils/is-gate-and-not-enabled.util'; - -@Injectable() -export class ReflectiveMetadataFactory { - async createObjectMetadata( - metadata: typeof BaseObjectMetadata, - workspaceId: string, - defaultDataSourceId: string, - workspaceFeatureFlagsMap: Record, - ): Promise { - const objectMetadata = TypedReflect.getMetadata('objectMetadata', metadata); - const fieldMetadata = - TypedReflect.getMetadata('fieldMetadata', metadata) ?? {}; - - if (!objectMetadata) { - throw new Error( - `Object metadata decorator not found, can\'t parse ${metadata.name}`, - ); - } - - if (isGatedAndNotEnabled(objectMetadata, workspaceFeatureFlagsMap)) { - return undefined; - } - - const fields = Object.values(fieldMetadata).filter( - (field) => !isGatedAndNotEnabled(field, workspaceFeatureFlagsMap), - ); - - return { - ...objectMetadata, - workspaceId, - dataSourceId: defaultDataSourceId, - fields: fields.map((field) => ({ - ...field, - workspaceId, - isSystem: objectMetadata.isSystem || field.isSystem, - })), - }; - } - - async createObjectMetadataCollection( - metadataCollection: (typeof BaseObjectMetadata)[], - workspaceId: string, - dataSourceId: string, - workspaceFeatureFlagsMap: Record, - ) { - const metadataPromises = metadataCollection.map((metadata) => - this.createObjectMetadata( - metadata, - workspaceId, - dataSourceId, - workspaceFeatureFlagsMap, - ), - ); - const resolvedMetadata = await Promise.all(metadataPromises); - - return resolvedMetadata.filter( - (metadata): metadata is PartialObjectMetadata => !!metadata, - ); - } - - createRelationMetadata( - metadata: typeof BaseObjectMetadata, - workspaceId: string, - objectMetadataFromDB: Record, - workspaceFeatureFlagsMap: Record, - ) { - const objectMetadata = TypedReflect.getMetadata('objectMetadata', metadata); - const relationMetadata = TypedReflect.getMetadata( - 'relationMetadata', - metadata, - ); - - if (!objectMetadata) { - throw new Error( - `Object metadata decorator not found, can\'t parse ${metadata.name}`, - ); - } - - if ( - !relationMetadata || - isGatedAndNotEnabled(objectMetadata, workspaceFeatureFlagsMap) - ) { - return []; - } - - return relationMetadata - .filter( - (relation) => !isGatedAndNotEnabled(relation, workspaceFeatureFlagsMap), - ) - .map((relation) => { - const fromObjectMetadata = - objectMetadataFromDB[relation.fromObjectNameSingular]; - - assert( - fromObjectMetadata, - `Object ${relation.fromObjectNameSingular} not found in DB - for relation FROM defined in class ${objectMetadata.nameSingular}`, - ); - - const toObjectMetadata = - objectMetadataFromDB[relation.toObjectNameSingular]; - - assert( - toObjectMetadata, - `Object ${relation.toObjectNameSingular} not found in DB - for relation TO defined in class ${objectMetadata.nameSingular}`, - ); - - const fromFieldMetadata = - fromObjectMetadata?.fields[relation.fromFieldMetadataName]; - - assert( - fromFieldMetadata, - `Field ${relation.fromFieldMetadataName} not found in object ${relation.fromObjectNameSingular} - for relation FROM defined in class ${objectMetadata.nameSingular}`, - ); - - const toFieldMetadata = - toObjectMetadata?.fields[relation.toFieldMetadataName]; - - assert( - toFieldMetadata, - `Field ${relation.toFieldMetadataName} not found in object ${relation.toObjectNameSingular} - for relation TO defined in class ${objectMetadata.nameSingular}`, - ); - - return { - relationType: relation.type, - fromObjectMetadataId: fromObjectMetadata?.id, - toObjectMetadataId: toObjectMetadata?.id, - fromFieldMetadataId: fromFieldMetadata?.id, - toFieldMetadataId: toFieldMetadata?.id, - workspaceId, - }; - }); - } - - createRelationMetadataCollection( - metadataCollection: (typeof BaseObjectMetadata)[], - workspaceId: string, - objectMetadataFromDB: Record, - workspaceFeatureFlagsMap: Record, - ) { - return metadataCollection.flatMap((metadata) => - this.createRelationMetadata( - metadata, - workspaceId, - objectMetadataFromDB, - workspaceFeatureFlagsMap, - ), - ); - } -} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-metadata-updater.service.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-metadata-updater.service.ts new file mode 100644 index 00000000000..1b8f759a043 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-metadata-updater.service.ts @@ -0,0 +1,189 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { EntityManager, In } from 'typeorm'; +import { v4 as uuidV4 } from 'uuid'; +import omit from 'lodash.omit'; + +import { PartialFieldMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-field-metadata.interface'; + +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { + FieldMetadataEntity, + FieldMetadataType, +} from 'src/metadata/field-metadata/field-metadata.entity'; +import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation-metadata.entity'; +import { FieldMetadataComplexOption } from 'src/metadata/field-metadata/dtos/options.input'; +import { WorkspaceSyncStorage } from 'src/workspace/workspace-sync-metadata/storage/workspace-sync.storage'; + +@Injectable() +export class WorkspaceMetadataUpdaterService { + private readonly logger = new Logger(WorkspaceMetadataUpdaterService.name); + + async updateObjectMetadata( + manager: EntityManager, + storage: WorkspaceSyncStorage, + ): Promise<{ + createdObjectMetadataCollection: ObjectMetadataEntity[]; + updatedObjectMetadataCollection: ObjectMetadataEntity[]; + }> { + const objectMetadataRepository = + manager.getRepository(ObjectMetadataEntity); + + /** + * Create object metadata + */ + const createdPartialObjectMetadataCollection = + await objectMetadataRepository.save( + storage.objectMetadataCreateCollection.map((objectMetadata) => ({ + ...objectMetadata, + isActive: true, + fields: objectMetadata.fields.map((field) => + this.prepareFieldMetadataForCreation(field), + ), + })) as DeepPartial[], + ); + const identifiers = createdPartialObjectMetadataCollection.map( + (object) => object.id, + ); + const createdObjectMetadataCollection = await manager.find( + ObjectMetadataEntity, + { + where: { id: In(identifiers) }, + relations: ['dataSource', 'fields'], + }, + ); + + /** + * Update object metadata + */ + const updatedObjectMetadataCollection = await objectMetadataRepository.save( + storage.objectMetadataUpdateCollection.map((objectMetadata) => + omit(objectMetadata, ['fields']), + ), + ); + + /** + * Delete object metadata + */ + if (storage.objectMetadataDeleteCollection.length > 0) { + await objectMetadataRepository.delete( + storage.objectMetadataDeleteCollection.map((object) => object.id), + ); + } + + return { + createdObjectMetadataCollection, + updatedObjectMetadataCollection, + }; + } + + /** + * TODO: Refactor this + */ + private prepareFieldMetadataForCreation(field: PartialFieldMetadata) { + return { + ...field, + ...(field.type === FieldMetadataType.SELECT && field.options + ? { + options: this.generateUUIDForNewSelectFieldOptions( + field.options as FieldMetadataComplexOption[], + ), + } + : {}), + isActive: true, + }; + } + + private generateUUIDForNewSelectFieldOptions( + options: FieldMetadataComplexOption[], + ): FieldMetadataComplexOption[] { + return options.map((option) => ({ + ...option, + id: uuidV4(), + })); + } + + async updateFieldMetadata( + manager: EntityManager, + storage: WorkspaceSyncStorage, + ): Promise<{ + createdFieldMetadataCollection: FieldMetadataEntity[]; + updatedFieldMetadataCollection: FieldMetadataEntity[]; + }> { + const fieldMetadataRepository = manager.getRepository(FieldMetadataEntity); + + /** + * Create field metadata + */ + const createdFieldMetadataCollection = await fieldMetadataRepository.save( + storage.fieldMetadataCreateCollection.map((field) => + this.prepareFieldMetadataForCreation(field), + ) as DeepPartial[], + ); + + /** + * Update field metadata + */ + const updatedFieldMetadataCollection = await fieldMetadataRepository.save( + storage.fieldMetadataUpdateCollection as DeepPartial[], + ); + + /** + * Delete field metadata + */ + // TODO: handle relation fields deletion. We need to delete the relation metadata first due to the DB constraint. + const fieldMetadataDeleteCollectionWithoutRelationType = + storage.fieldMetadataDeleteCollection.filter( + (field) => field.type !== FieldMetadataType.RELATION, + ); + + if (fieldMetadataDeleteCollectionWithoutRelationType.length > 0) { + await fieldMetadataRepository.delete( + fieldMetadataDeleteCollectionWithoutRelationType.map( + (field) => field.id, + ), + ); + } + + return { + createdFieldMetadataCollection: + createdFieldMetadataCollection as FieldMetadataEntity[], + updatedFieldMetadataCollection: + updatedFieldMetadataCollection as FieldMetadataEntity[], + }; + } + + async updateRelationMetadata( + manager: EntityManager, + storage: WorkspaceSyncStorage, + ): Promise<{ + createdRelationMetadataCollection: RelationMetadataEntity[]; + }> { + const relationMetadataRepository = manager.getRepository( + RelationMetadataEntity, + ); + + /** + * Create relation metadata + */ + const createdRelationMetadataCollection = + await relationMetadataRepository.save( + storage.relationMetadataCreateCollection, + ); + + /** + * Delete relation metadata + */ + if (storage.relationMetadataDeleteCollection.length > 0) { + await relationMetadataRepository.delete( + storage.relationMetadataDeleteCollection.map( + (relationMetadata) => relationMetadata.id, + ), + ); + } + + return { + createdRelationMetadataCollection, + }; + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-sync-object-metadata.service.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-sync-object-metadata.service.ts new file mode 100644 index 00000000000..c268b72e51e --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-sync-object-metadata.service.ts @@ -0,0 +1,154 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { EntityManager } from 'typeorm'; + +import { WorkspaceSyncContext } from 'src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; +import { ComparatorAction } from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; +import { FeatureFlagMap } from 'src/core/feature-flag/interfaces/feature-flag-map.interface'; + +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { mapObjectMetadataByUniqueIdentifier } from 'src/workspace/workspace-sync-metadata/utils/sync-metadata.util'; +import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity'; +import { StandardObjectFactory } from 'src/workspace/workspace-sync-metadata/factories/standard-object.factory'; +import { WorkspaceObjectComparator } from 'src/workspace/workspace-sync-metadata/comparators/workspace-object.comparator'; +import { WorkspaceFieldComparator } from 'src/workspace/workspace-sync-metadata/comparators/workspace-field.comparator'; +import { WorkspaceMetadataUpdaterService } from 'src/workspace/workspace-sync-metadata/services/workspace-metadata-updater.service'; +import { WorkspaceSyncFactory } from 'src/workspace/workspace-sync-metadata/factories/workspace-sync.factory'; +import { WorkspaceSyncStorage } from 'src/workspace/workspace-sync-metadata/storage/workspace-sync.storage'; + +@Injectable() +export class WorkspaceSyncObjectMetadataService { + private readonly logger = new Logger(WorkspaceSyncObjectMetadataService.name); + + constructor( + private readonly standardObjectFactory: StandardObjectFactory, + private readonly workspaceObjectComparator: WorkspaceObjectComparator, + private readonly workspaceFieldComparator: WorkspaceFieldComparator, + private readonly workspaceMetadataUpdaterService: WorkspaceMetadataUpdaterService, + private readonly workspaceSyncFactory: WorkspaceSyncFactory, + ) {} + + async synchronize( + context: WorkspaceSyncContext, + manager: EntityManager, + storage: WorkspaceSyncStorage, + workspaceFeatureFlagsMap: FeatureFlagMap, + ): Promise { + const objectMetadataRepository = + manager.getRepository(ObjectMetadataEntity); + const workspaceMigrationRepository = manager.getRepository( + WorkspaceMigrationEntity, + ); + + // Retrieve object metadata collection from DB + const originalObjectMetadataCollection = + await objectMetadataRepository.find({ + where: { workspaceId: context.workspaceId, isCustom: false }, + relations: ['dataSource', 'fields'], + }); + + // Create standard object metadata collection + const standardObjectMetadataCollection = this.standardObjectFactory.create( + context, + workspaceFeatureFlagsMap, + ); + + // Create map of original and standard object metadata by unique identifier + const originalObjectMetadataMap = mapObjectMetadataByUniqueIdentifier( + originalObjectMetadataCollection, + ); + const standardObjectMetadataMap = mapObjectMetadataByUniqueIdentifier( + standardObjectMetadataCollection, + ); + + this.logger.log('Comparing standard objects and fields metadata'); + + // Store object that need to be deleted + for (const originalObjectMetadata of originalObjectMetadataCollection) { + if (!standardObjectMetadataMap[originalObjectMetadata.nameSingular]) { + storage.addDeleteObjectMetadata(originalObjectMetadata); + } + } + + // Loop over all standard objects and compare them with the objects in DB + for (const standardObjectName in standardObjectMetadataMap) { + const originalObjectMetadata = + originalObjectMetadataMap[standardObjectName]; + const standardObjectMetadata = + standardObjectMetadataMap[standardObjectName]; + + /** + * COMPARE OBJECT METADATA + */ + const objectComparatorResult = this.workspaceObjectComparator.compare( + originalObjectMetadata, + standardObjectMetadata, + ); + + if (objectComparatorResult.action === ComparatorAction.CREATE) { + storage.addCreateObjectMetadata(standardObjectMetadata); + continue; + } + + if (objectComparatorResult.action === ComparatorAction.UPDATE) { + storage.addUpdateObjectMetadata(objectComparatorResult.object); + } + + /** + * COMPARE FIELD METADATA + */ + const fieldComparatorResults = this.workspaceFieldComparator.compare( + originalObjectMetadata, + standardObjectMetadata, + ); + + for (const fieldComparatorResult of fieldComparatorResults) { + switch (fieldComparatorResult.action) { + case ComparatorAction.CREATE: { + storage.addCreateFieldMetadata(fieldComparatorResult.object); + break; + } + case ComparatorAction.UPDATE: { + storage.addUpdateFieldMetadata(fieldComparatorResult.object); + break; + } + case ComparatorAction.DELETE: { + storage.addDeleteFieldMetadata(fieldComparatorResult.object); + break; + } + } + } + } + + this.logger.log('Updating workspace metadata'); + + // Apply changes to DB + const metadataObjectUpdaterResult = + await this.workspaceMetadataUpdaterService.updateObjectMetadata( + manager, + storage, + ); + const metadataFieldUpdaterResult = + await this.workspaceMetadataUpdaterService.updateFieldMetadata( + manager, + storage, + ); + + this.logger.log('Generating migrations'); + + // Create migrations + const workspaceObjectMigrations = + await this.workspaceSyncFactory.createObjectMigration( + originalObjectMetadataCollection, + metadataObjectUpdaterResult.createdObjectMetadataCollection, + storage.objectMetadataDeleteCollection, + metadataFieldUpdaterResult.createdFieldMetadataCollection, + storage.fieldMetadataDeleteCollection, + ); + + this.logger.log('Saving migrations'); + + // Save migrations into DB + await workspaceMigrationRepository.save(workspaceObjectMigrations); + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-sync-relation-metadata.service.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-sync-relation-metadata.service.ts new file mode 100644 index 00000000000..d4cd9800315 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/services/workspace-sync-relation-metadata.service.ts @@ -0,0 +1,105 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { EntityManager } from 'typeorm'; + +import { WorkspaceSyncContext } from 'src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; +import { FeatureFlagMap } from 'src/core/feature-flag/interfaces/feature-flag-map.interface'; +import { ComparatorAction } from 'src/workspace/workspace-sync-metadata/interfaces/comparator.interface'; + +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation-metadata.entity'; +import { mapObjectMetadataByUniqueIdentifier } from 'src/workspace/workspace-sync-metadata/utils/sync-metadata.util'; +import { StandardRelationFactory } from 'src/workspace/workspace-sync-metadata/factories/standard-relation.factory'; +import { WorkspaceRelationComparator } from 'src/workspace/workspace-sync-metadata/comparators/workspace-relation.comparator'; +import { WorkspaceMetadataUpdaterService } from 'src/workspace/workspace-sync-metadata/services/workspace-metadata-updater.service'; +import { WorkspaceSyncFactory } from 'src/workspace/workspace-sync-metadata/factories/workspace-sync.factory'; +import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity'; +import { WorkspaceSyncStorage } from 'src/workspace/workspace-sync-metadata/storage/workspace-sync.storage'; + +@Injectable() +export class WorkspaceSyncRelationMetadataService { + private readonly logger = new Logger( + WorkspaceSyncRelationMetadataService.name, + ); + + constructor( + private readonly standardRelationFactory: StandardRelationFactory, + private readonly workspaceRelationComparator: WorkspaceRelationComparator, + private readonly workspaceMetadataUpdaterService: WorkspaceMetadataUpdaterService, + private readonly workspaceSyncFactory: WorkspaceSyncFactory, + ) {} + + async synchronize( + context: WorkspaceSyncContext, + manager: EntityManager, + storage: WorkspaceSyncStorage, + workspaceFeatureFlagsMap: FeatureFlagMap, + ): Promise { + const objectMetadataRepository = + manager.getRepository(ObjectMetadataEntity); + const workspaceMigrationRepository = manager.getRepository( + WorkspaceMigrationEntity, + ); + + // Retrieve object metadata collection from DB + const originalObjectMetadataCollection = + await objectMetadataRepository.find({ + where: { workspaceId: context.workspaceId, isCustom: false }, + relations: ['dataSource', 'fields'], + }); + + // Create map of object metadata & field metadata by unique identifier + const originalObjectMetadataMap = mapObjectMetadataByUniqueIdentifier( + originalObjectMetadataCollection, + ); + + const relationMetadataRepository = manager.getRepository( + RelationMetadataEntity, + ); + + // Retrieve relation metadata collection from DB + // TODO: filter out custom relations once isCustom has been added to relationMetadata table + const originalRelationMetadataCollection = + await relationMetadataRepository.find({ + where: { workspaceId: context.workspaceId }, + }); + + // Create standard relation metadata collection + const standardRelationMetadataCollection = + this.standardRelationFactory.create( + context, + originalObjectMetadataMap, + workspaceFeatureFlagsMap, + ); + + const relationComparatorResults = this.workspaceRelationComparator.compare( + originalRelationMetadataCollection, + standardRelationMetadataCollection, + ); + + for (const relationComparatorResult of relationComparatorResults) { + if (relationComparatorResult.action === ComparatorAction.CREATE) { + storage.addCreateRelationMetadata(relationComparatorResult.object); + } else if (relationComparatorResult.action === ComparatorAction.DELETE) { + storage.addDeleteRelationMetadata(relationComparatorResult.object); + } + } + + const metadataRelationUpdaterResult = + await this.workspaceMetadataUpdaterService.updateRelationMetadata( + manager, + storage, + ); + + // Create migrations + const workspaceRelationMigrations = + await this.workspaceSyncFactory.createRelationMigration( + originalObjectMetadataCollection, + metadataRelationUpdaterResult.createdRelationMetadataCollection, + storage.relationMetadataDeleteCollection, + ); + + // Save migrations into DB + await workspaceMigrationRepository.save(workspaceRelationMigrations); + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/storage/workspace-sync.storage.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/storage/workspace-sync.storage.ts new file mode 100644 index 00000000000..99791b9ac82 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/storage/workspace-sync.storage.ts @@ -0,0 +1,93 @@ +import { PartialObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface'; +import { PartialFieldMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-field-metadata.interface'; + +import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; +import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity'; +import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation-metadata.entity'; + +export class WorkspaceSyncStorage { + // Object metadata + private readonly _objectMetadataCreateCollection: PartialObjectMetadata[] = + []; + private readonly _objectMetadataUpdateCollection: Partial[] = + []; + private readonly _objectMetadataDeleteCollection: ObjectMetadataEntity[] = []; + + // Field metadata + private readonly _fieldMetadataCreateCollection: PartialFieldMetadata[] = []; + private readonly _fieldMetadataUpdateCollection: Partial[] = + []; + private readonly _fieldMetadataDeleteCollection: FieldMetadataEntity[] = []; + + // Relation metadata + private readonly _relationMetadataCreateCollection: Partial[] = + []; + private readonly _relationMetadataDeleteCollection: RelationMetadataEntity[] = + []; + + constructor() {} + + get objectMetadataCreateCollection() { + return this._objectMetadataCreateCollection; + } + + get objectMetadataUpdateCollection() { + return this._objectMetadataUpdateCollection; + } + + get objectMetadataDeleteCollection() { + return this._objectMetadataDeleteCollection; + } + + get fieldMetadataCreateCollection() { + return this._fieldMetadataCreateCollection; + } + + get fieldMetadataUpdateCollection() { + return this._fieldMetadataUpdateCollection; + } + + get fieldMetadataDeleteCollection() { + return this._fieldMetadataDeleteCollection; + } + + get relationMetadataCreateCollection() { + return this._relationMetadataCreateCollection; + } + + get relationMetadataDeleteCollection() { + return this._relationMetadataDeleteCollection; + } + + addCreateObjectMetadata(object: PartialObjectMetadata) { + this._objectMetadataCreateCollection.push(object); + } + + addUpdateObjectMetadata(object: Partial) { + this._objectMetadataUpdateCollection.push(object); + } + + addDeleteObjectMetadata(object: ObjectMetadataEntity) { + this._objectMetadataDeleteCollection.push(object); + } + + addCreateFieldMetadata(field: PartialFieldMetadata) { + this._fieldMetadataCreateCollection.push(field); + } + + addUpdateFieldMetadata(field: Partial) { + this._fieldMetadataUpdateCollection.push(field); + } + + addDeleteFieldMetadata(field: FieldMetadataEntity) { + this._fieldMetadataDeleteCollection.push(field); + } + + addCreateRelationMetadata(relation: Partial) { + this._relationMetadataCreateCollection.push(relation); + } + + addDeleteRelationMetadata(relation: RelationMetadataEntity) { + this._relationMetadataDeleteCollection.push(relation); + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/is-gate-and-not-enabled.util.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/is-gate-and-not-enabled.util.ts index 60348141cee..b0fe74feb49 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/is-gate-and-not-enabled.util.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/is-gate-and-not-enabled.util.ts @@ -1,10 +1,11 @@ +import { GateDecoratorParams } from 'src/workspace/workspace-sync-metadata/interfaces/gate-decorator.interface'; + export const isGatedAndNotEnabled = ( - metadata, + gate: GateDecoratorParams | undefined, workspaceFeatureFlagsMap: Record, ): boolean => { const featureFlagValue = - metadata.gate?.featureFlag && - workspaceFeatureFlagsMap[metadata.gate.featureFlag]; + gate?.featureFlag && workspaceFeatureFlagsMap[gate.featureFlag]; - return metadata.gate?.featureFlag !== undefined && !featureFlagValue; + return gate?.featureFlag !== undefined && !featureFlagValue; }; diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/sync-metadata.util.spec.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/sync-metadata.util.spec.ts index 3a4bdaac437..153d92dc871 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/sync-metadata.util.spec.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/sync-metadata.util.spec.ts @@ -1,52 +1,7 @@ import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; -import { - filterIgnoredProperties, - mapObjectMetadataByUniqueIdentifier, -} from './sync-metadata.util'; - -describe('filterIgnoredProperties', () => { - it('should filter out properties based on the ignore list', () => { - const obj = { - name: 'John', - age: 30, - email: 'john@example.com', - address: '123 Main St', - }; - const propertiesToIgnore = ['age', 'address']; - - const filteredObj = filterIgnoredProperties(obj, propertiesToIgnore); - - expect(filteredObj).toEqual({ - name: 'John', - email: 'john@example.com', - }); - }); - - it('should return the original object if ignore list is empty', () => { - const obj = { - name: 'John', - age: 30, - email: 'john@example.com', - address: '123 Main St', - }; - const propertiesToIgnore: string[] = []; - - const filteredObj = filterIgnoredProperties(obj, propertiesToIgnore); - - expect(filteredObj).toEqual(obj); - }); - - it('should return an empty object if the original object is empty', () => { - const obj = {}; - const propertiesToIgnore = ['age', 'address']; - - const filteredObj = filterIgnoredProperties(obj, propertiesToIgnore); - - expect(filteredObj).toEqual({}); - }); -}); +import { mapObjectMetadataByUniqueIdentifier } from './sync-metadata.util'; describe('mapObjectMetadataByUniqueIdentifier', () => { it('should convert an array of ObjectMetadataEntity objects into a map', () => { @@ -75,18 +30,18 @@ describe('mapObjectMetadataByUniqueIdentifier', () => { expect(mappedObject).toEqual({ user: { nameSingular: 'user', - fields: { - id: { name: 'id', type: FieldMetadataType.UUID }, - name: { name: 'name', type: FieldMetadataType.TEXT }, - }, + fields: [ + { name: 'id', type: FieldMetadataType.UUID }, + { name: 'name', type: FieldMetadataType.TEXT }, + ], }, product: { nameSingular: 'product', - fields: { - id: { name: 'id', type: FieldMetadataType.UUID }, - name: { name: 'name', type: FieldMetadataType.TEXT }, - price: { name: 'price', type: FieldMetadataType.UUID }, - }, + fields: [ + { name: 'id', type: FieldMetadataType.UUID }, + { name: 'name', type: FieldMetadataType.TEXT }, + { name: 'price', type: FieldMetadataType.UUID }, + ], }, }); }); diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/sync-metadata.util.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/sync-metadata.util.ts index 10daca2808b..b095c297e3f 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/sync-metadata.util.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/utils/sync-metadata.util.ts @@ -1,27 +1,3 @@ -import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface'; -import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/field-metadata-options.interface'; -import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface'; - -/** - * This utility function filters out properties from an object based on a list of properties to ignore. - * It returns a new object with only the properties that are not in the ignore list. - * - * @param obj - The object to filter. - * @param propertiesToIgnore - An array of property names to ignore. - * @returns A new object with filtered properties. - */ -export const filterIgnoredProperties = ( - obj: any, - propertiesToIgnore: string[], - mapFunction?: (value: any) => any, -) => { - return Object.fromEntries( - Object.entries(obj) - .filter(([key]) => !propertiesToIgnore.includes(key)) - .map(([key, value]) => [key, mapFunction ? mapFunction(value) : value]), - ); -}; - /** * This utility function converts an array of ObjectMetadataEntity objects into a map, * where the keys are the nameSingular properties of the objects. @@ -31,59 +7,18 @@ export const filterIgnoredProperties = ( * @returns A map of object metadata, with nameSingular as the key and the object as the value. */ export const mapObjectMetadataByUniqueIdentifier = < - T extends { nameSingular: string; fields: U[] }, - U extends { name: string }, + T extends { nameSingular: string }, >( arr: T[], -): Record & { fields: Record }> => { +): Record => { return arr.reduce( (acc, curr) => { acc[curr.nameSingular] = { ...curr, - fields: curr.fields.reduce( - (acc, curr) => { - acc[curr.name] = curr; - - return acc; - }, - {} as Record, - ), }; return acc; }, - {} as Record & { fields: Record }>, + {} as Record, ); }; - -export const convertStringifiedFieldsToJSON = < - T extends { - targetColumnMap?: string | null; - defaultValue?: string | null; - options?: string | null; - }, ->( - fieldMetadata: T, -): T & { - targetColumnMap?: FieldMetadataTargetColumnMap; - defaultValue?: FieldMetadataDefaultValue; - options?: FieldMetadataOptions; -} => { - if (fieldMetadata.targetColumnMap) { - fieldMetadata.targetColumnMap = JSON.parse(fieldMetadata.targetColumnMap); - } - - if (fieldMetadata.defaultValue) { - fieldMetadata.defaultValue = JSON.parse(fieldMetadata.defaultValue); - } - - if (fieldMetadata.options) { - fieldMetadata.options = JSON.parse(fieldMetadata.options); - } - - return fieldMetadata as T & { - targetColumnMap?: FieldMetadataTargetColumnMap; - defaultValue?: FieldMetadataDefaultValue; - options?: FieldMetadataOptions; - }; -}; diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync-metadata.module.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync-metadata.module.ts index dfbfb6fda1c..c7cf9f25da4 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync-metadata.module.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync-metadata.module.ts @@ -8,8 +8,12 @@ import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation- import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity'; import { WorkspaceMigrationModule } from 'src/metadata/workspace-migration/workspace-migration.module'; import { WorkspaceMigrationRunnerModule } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.module'; -import { ReflectiveMetadataFactory } from 'src/workspace/workspace-sync-metadata/reflective-metadata.factory'; -import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync.metadata.service'; +import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync-metadata.service'; +import { workspaceSyncMetadataFactories } from 'src/workspace/workspace-sync-metadata/factories'; +import { workspaceSyncMetadataComparators } from 'src/workspace/workspace-sync-metadata/comparators'; +import { WorkspaceMetadataUpdaterService } from 'src/workspace/workspace-sync-metadata/services/workspace-metadata-updater.service'; +import { WorkspaceSyncObjectMetadataService } from 'src/workspace/workspace-sync-metadata/services/workspace-sync-object-metadata.service'; +import { WorkspaceSyncRelationMetadataService } from 'src/workspace/workspace-sync-metadata/services/workspace-sync-relation-metadata.service'; @Module({ imports: [ @@ -26,7 +30,14 @@ import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metad ), TypeOrmModule.forFeature([FeatureFlagEntity], 'core'), ], - providers: [WorkspaceSyncMetadataService, ReflectiveMetadataFactory], + providers: [ + ...workspaceSyncMetadataFactories, + ...workspaceSyncMetadataComparators, + WorkspaceMetadataUpdaterService, + WorkspaceSyncObjectMetadataService, + WorkspaceSyncRelationMetadataService, + WorkspaceSyncMetadataService, + ], exports: [WorkspaceSyncMetadataService], }) export class WorkspaceSyncMetadataModule {} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync-metadata.service.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync-metadata.service.ts new file mode 100644 index 00000000000..59510304f5e --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync-metadata.service.ts @@ -0,0 +1,82 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; + +import { DataSource } from 'typeorm'; + +import { WorkspaceSyncContext } from 'src/workspace/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; + +import { WorkspaceMigrationRunnerService } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.service'; +import { FeatureFlagFactory } from 'src/workspace/workspace-sync-metadata/factories/feature-flags.factory'; +import { WorkspaceSyncObjectMetadataService } from 'src/workspace/workspace-sync-metadata/services/workspace-sync-object-metadata.service'; +import { WorkspaceSyncRelationMetadataService } from 'src/workspace/workspace-sync-metadata/services/workspace-sync-relation-metadata.service'; +import { WorkspaceSyncStorage } from 'src/workspace/workspace-sync-metadata/storage/workspace-sync.storage'; + +@Injectable() +export class WorkspaceSyncMetadataService { + private readonly logger = new Logger(WorkspaceSyncMetadataService.name); + + constructor( + @InjectDataSource('metadata') + private readonly metadataDataSource: DataSource, + private readonly featureFlagFactory: FeatureFlagFactory, + private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, + private readonly workspaceSyncObjectMetadataService: WorkspaceSyncObjectMetadataService, + private readonly workspaceSyncRelationMetadataService: WorkspaceSyncRelationMetadataService, + ) {} + + /** + * + * Sync all standard objects and fields metadata for a given workspace and data source + * This will update the metadata if it has changed and generate migrations based on the diff. + * + * @param dataSourceId + * @param workspaceId + */ + public async syncStandardObjectsAndFieldsMetadata( + context: WorkspaceSyncContext, + ) { + this.logger.log('Syncing standard objects and fields metadata'); + const queryRunner = this.metadataDataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + const manager = queryRunner.manager; + + try { + const storage = new WorkspaceSyncStorage(); + + // Retrieve feature flags + const workspaceFeatureFlagsMap = + await this.featureFlagFactory.create(context); + + this.logger.log('Syncing standard objects and fields metadata'); + + await this.workspaceSyncObjectMetadataService.synchronize( + context, + manager, + storage, + workspaceFeatureFlagsMap, + ); + + await this.workspaceSyncRelationMetadataService.synchronize( + context, + manager, + storage, + workspaceFeatureFlagsMap, + ); + + await queryRunner.commitTransaction(); + + // Execute migrations + await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( + context.workspaceId, + ); + } catch (error) { + console.error('Sync of standard objects failed with:', error); + await queryRunner.rollbackTransaction(); + } finally { + await queryRunner.release(); + } + } +} diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync.metadata.service.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync.metadata.service.ts deleted file mode 100644 index 3826c15ab40..00000000000 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync.metadata.service.ts +++ /dev/null @@ -1,584 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import diff from 'microdiff'; -import { In, Repository } from 'typeorm'; -import camelCase from 'lodash.camelcase'; -import { v4 as uuidV4 } from 'uuid'; - -import { PartialFieldMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-field-metadata.interface'; -import { PartialObjectMetadata } from 'src/workspace/workspace-sync-metadata/interfaces/partial-object-metadata.interface'; -import { - MappedFieldMetadataEntity, - MappedObjectMetadata, -} from 'src/workspace/workspace-sync-metadata/interfaces/mapped-metadata.interface'; - -import { - FieldMetadataEntity, - FieldMetadataType, -} from 'src/metadata/field-metadata/field-metadata.entity'; -import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity'; -import { - RelationMetadataEntity, - RelationMetadataType, -} from 'src/metadata/relation-metadata/relation-metadata.entity'; -import { - filterIgnoredProperties, - mapObjectMetadataByUniqueIdentifier, - convertStringifiedFieldsToJSON, -} from 'src/workspace/workspace-sync-metadata/utils/sync-metadata.util'; -import { standardObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects'; -import { - WorkspaceMigrationColumnActionType, - WorkspaceMigrationColumnRelation, - WorkspaceMigrationEntity, - WorkspaceMigrationTableAction, -} from 'src/metadata/workspace-migration/workspace-migration.entity'; -import { WorkspaceMigrationFactory } from 'src/metadata/workspace-migration/workspace-migration.factory'; -import { WorkspaceMigrationRunnerService } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.service'; -import { ReflectiveMetadataFactory } from 'src/workspace/workspace-sync-metadata/reflective-metadata.factory'; -import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; -import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util'; -import { FieldMetadataComplexOption } from 'src/metadata/field-metadata/dtos/options.input'; - -@Injectable() -export class WorkspaceSyncMetadataService { - constructor( - private readonly workspaceMigrationFactory: WorkspaceMigrationFactory, - private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService, - private readonly reflectiveMetadataFactory: ReflectiveMetadataFactory, - - @InjectRepository(ObjectMetadataEntity, 'metadata') - private readonly objectMetadataRepository: Repository, - @InjectRepository(FieldMetadataEntity, 'metadata') - private readonly fieldMetadataRepository: Repository, - @InjectRepository(RelationMetadataEntity, 'metadata') - private readonly relationMetadataRepository: Repository, - @InjectRepository(WorkspaceMigrationEntity, 'metadata') - private readonly workspaceMigrationRepository: Repository, - @InjectRepository(FeatureFlagEntity, 'core') - private readonly featureFlagRepository: Repository, - ) {} - - /** - * - * Sync all standard objects and fields metadata for a given workspace and data source - * This will update the metadata if it has changed and generate migrations based on the diff. - * - * @param dataSourceId - * @param workspaceId - */ - public async syncStandardObjectsAndFieldsMetadata( - dataSourceId: string, - workspaceId: string, - ) { - try { - const workspaceFeatureFlags = await this.featureFlagRepository.find({ - where: { workspaceId }, - }); - - const workspaceFeatureFlagsMap = workspaceFeatureFlags.reduce( - (result, currentFeatureFlag) => { - result[currentFeatureFlag.key] = currentFeatureFlag.value; - - return result; - }, - {}, - ); - - const standardObjects = - await this.reflectiveMetadataFactory.createObjectMetadataCollection( - standardObjectMetadata, - workspaceId, - dataSourceId, - workspaceFeatureFlagsMap, - ); - - const objectsInDB = await this.objectMetadataRepository.find({ - where: { workspaceId, isCustom: false }, - relations: ['dataSource', 'fields'], - }); - - const objectsInDBByName = mapObjectMetadataByUniqueIdentifier< - ObjectMetadataEntity, - FieldMetadataEntity - >(objectsInDB); - const standardObjectsByName = mapObjectMetadataByUniqueIdentifier< - PartialObjectMetadata, - PartialFieldMetadata - >(standardObjects); - - const objectsToCreate: MappedObjectMetadata[] = []; - const objectsToDelete = objectsInDB.filter( - (objectInDB) => !standardObjectsByName[objectInDB.nameSingular], - ); - const objectsToUpdate: Record = {}; - - const fieldsToCreate: PartialFieldMetadata[] = []; - const fieldsToDelete: FieldMetadataEntity[] = []; - const fieldsToUpdate: Record = {}; - - for (const standardObjectName in standardObjectsByName) { - const standardObject = standardObjectsByName[standardObjectName]; - const objectInDB = objectsInDBByName[standardObjectName]; - - if (!objectInDB) { - objectsToCreate.push(standardObject); - continue; - } - - // Deconstruct fields and compare objects and fields independently - const { fields: objectInDBFields, ...objectInDBWithoutFields } = - objectInDB; - const { fields: standardObjectFields, ...standardObjectWithoutFields } = - standardObject; - - const objectPropertiesToIgnore = [ - 'id', - 'createdAt', - 'updatedAt', - 'labelIdentifierFieldMetadataId', - 'imageIdentifierFieldMetadataId', - 'isActive', - ]; - const objectDiffWithoutIgnoredProperties = filterIgnoredProperties( - objectInDBWithoutFields, - objectPropertiesToIgnore, - ); - - const fieldPropertiesToIgnore = [ - 'id', - 'createdAt', - 'updatedAt', - 'objectMetadataId', - 'isActive', - ]; - const objectInDBFieldsWithoutDefaultFields = Object.fromEntries( - Object.entries(objectInDBFields).map(([key, value]) => { - if (value === null || typeof value !== 'object') { - return [key, value]; - } - - return [ - key, - filterIgnoredProperties( - value, - fieldPropertiesToIgnore, - (property) => { - if (property !== null && typeof property === 'object') { - return JSON.stringify(property); - } - - return property; - }, - ), - ]; - }), - ); - - // Compare objects - const objectDiff = diff( - objectDiffWithoutIgnoredProperties, - standardObjectWithoutFields, - ); - - // Compare fields - const fieldsDiff = diff( - objectInDBFieldsWithoutDefaultFields, - standardObjectFields, - ); - - for (const diff of objectDiff) { - // We only handle CHANGE here as REMOVE and CREATE are handled earlier. - if (diff.type === 'CHANGE') { - const property = diff.path[0]; - - objectsToUpdate[objectInDB.id] = { - ...objectsToUpdate[objectInDB.id], - [property]: diff.value, - }; - } - } - - for (const diff of fieldsDiff) { - const fieldName = diff.path[0]; - - if (diff.type === 'CREATE') { - fieldsToCreate.push({ - ...standardObjectFields[fieldName], - objectMetadataId: objectInDB.id, - }); - } - if (diff.type === 'REMOVE' && diff.path.length === 1) { - fieldsToDelete.push(objectInDBFields[fieldName]); - } - if (diff.type === 'CHANGE') { - const property = diff.path[diff.path.length - 1]; - - fieldsToUpdate[objectInDBFields[fieldName].id] = { - ...fieldsToUpdate[objectInDBFields[fieldName].id], - [property]: diff.value, - }; - } - } - } - - // CREATE OBJECTS - const createdObjectMetadataCollection = - await this.objectMetadataRepository.save( - objectsToCreate.map((object) => ({ - ...object, - isActive: true, - fields: Object.values(object.fields).map((field) => - this.prepareFieldMetadataForCreation(field), - ), - })), - ); - const identifiers = createdObjectMetadataCollection.map( - (object) => object.id, - ); - const createdObjects = await this.objectMetadataRepository.find({ - where: { id: In(identifiers) }, - relations: ['dataSource', 'fields'], - }); - - // UPDATE OBJECTS, this is not optimal as we are running n queries here. - for (const [key, value] of Object.entries(objectsToUpdate)) { - await this.objectMetadataRepository.update(key, value); - } - // DELETE OBJECTS - if (objectsToDelete.length > 0) { - await this.objectMetadataRepository.delete( - objectsToDelete.map((object) => object.id), - ); - } - - // CREATE FIELDS - const createdFields = await this.fieldMetadataRepository.save( - fieldsToCreate.map((field) => - this.prepareFieldMetadataForCreation(field), - ), - ); - - // UPDATE FIELDS - for (const [key, value] of Object.entries(fieldsToUpdate)) { - await this.fieldMetadataRepository.update( - key, - convertStringifiedFieldsToJSON(value), - ); - } - // DELETE FIELDS - // TODO: handle relation fields deletion. We need to delete the relation metadata first due to the DB constraint. - const fieldsToDeleteWithoutRelationType = fieldsToDelete.filter( - (field) => field.type !== FieldMetadataType.RELATION, - ); - - if (fieldsToDeleteWithoutRelationType.length > 0) { - await this.fieldMetadataRepository.delete( - fieldsToDeleteWithoutRelationType.map((field) => field.id), - ); - } - - // Generate migrations - await this.generateMigrationsFromSync( - createdObjects, - objectsToDelete, - createdFields, - fieldsToDelete, - objectsInDB, - ); - - // We run syncRelationMetadata after everything to ensure that all objects and fields are - // in the DB before creating relations. - await this.syncRelationMetadata( - workspaceId, - dataSourceId, - workspaceFeatureFlagsMap, - ); - - // Execute migrations - await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations( - workspaceId, - ); - } catch (error) { - console.error('Sync of standard objects failed with:', error); - } - } - - private prepareFieldMetadataForCreation(field: PartialFieldMetadata) { - const convertedField = convertStringifiedFieldsToJSON(field); - - return { - ...convertedField, - ...(convertedField.type === FieldMetadataType.SELECT && - convertedField.options - ? { - options: this.generateUUIDForNewSelectFieldOptions( - convertedField.options as FieldMetadataComplexOption[], - ), - } - : {}), - isActive: true, - }; - } - - private generateUUIDForNewSelectFieldOptions( - options: FieldMetadataComplexOption[], - ): FieldMetadataComplexOption[] { - return options.map((option) => ({ - ...option, - id: uuidV4(), - })); - } - - private async syncRelationMetadata( - workspaceId: string, - dataSourceId: string, - workspaceFeatureFlagsMap: Record, - ) { - const objectsInDB = await this.objectMetadataRepository.find({ - where: { workspaceId, isCustom: false }, - relations: ['dataSource', 'fields'], - }); - const objectsInDBByName = mapObjectMetadataByUniqueIdentifier< - ObjectMetadataEntity, - FieldMetadataEntity - >(objectsInDB); - const standardRelations = - this.reflectiveMetadataFactory.createRelationMetadataCollection( - standardObjectMetadata, - workspaceId, - objectsInDBByName, - workspaceFeatureFlagsMap, - ); - - // TODO: filter out custom relations once isCustom has been added to relationMetadata table - const relationsInDB = await this.relationMetadataRepository.find({ - where: { workspaceId }, - }); - - // We filter out 'id' later because we need it to remove the relation from DB - const relationsInDBWithoutIgnoredProperties = relationsInDB - .map((relation) => - filterIgnoredProperties(relation, ['createdAt', 'updatedAt']), - ) - .reduce((result, currentObject) => { - const key = `${currentObject.fromObjectMetadataId}->${currentObject.fromFieldMetadataId}`; - - result[key] = currentObject; - - return result; - }, {}); - - // Compare relations - const relationsDiff = diff( - relationsInDBWithoutIgnoredProperties, - standardRelations, - ); - - const relationsToCreate: RelationMetadataEntity[] = []; - const relationsToDelete: RelationMetadataEntity[] = []; - - for (const diff of relationsDiff) { - if (diff.type === 'CREATE') { - relationsToCreate.push(diff.value); - } - if (diff.type === 'REMOVE' && diff.path[diff.path.length - 1] !== 'id') { - relationsToDelete.push(diff.oldValue); - } - } - - try { - // CREATE RELATIONS - await this.relationMetadataRepository.save(relationsToCreate); - // DELETE RELATIONS - if (relationsToDelete.length > 0) { - await this.relationMetadataRepository.delete( - relationsToDelete.map((relation) => relation.id), - ); - } - - await this.generateRelationMigrationsFromSync( - relationsToCreate, - relationsToDelete, - objectsInDB, - ); - } catch (error) { - console.error('Sync of standard relations failed with:', error); - } - } - - private async generateMigrationsFromSync( - objectsToCreate: ObjectMetadataEntity[], - _objectsToDelete: ObjectMetadataEntity[], - fieldsToCreate: FieldMetadataEntity[], - fieldsToDelete: FieldMetadataEntity[], - objectsInDB: ObjectMetadataEntity[], - ) { - const migrationsToSave: Partial[] = []; - - if (objectsToCreate.length > 0) { - objectsToCreate.map((object) => { - const migrations = [ - { - name: computeObjectTargetTable(object), - action: 'create', - } satisfies WorkspaceMigrationTableAction, - ...Object.values(object.fields) - .filter((field) => field.type !== FieldMetadataType.RELATION) - .map( - (field) => - ({ - name: computeObjectTargetTable(object), - action: 'alter', - columns: this.workspaceMigrationFactory.createColumnActions( - WorkspaceMigrationColumnActionType.CREATE, - field, - ), - }) satisfies WorkspaceMigrationTableAction, - ), - ]; - - migrationsToSave.push({ - workspaceId: object.workspaceId, - isCustom: false, - migrations, - }); - }); - } - - // TODO: handle object delete migrations. - // Note: we need to delete the relation first due to the DB constraint. - - const objectsInDbById = objectsInDB.reduce( - (result, currentObject) => { - result[currentObject.id] = currentObject; - - return result; - }, - {} as Record, - ); - - if (fieldsToCreate.length > 0) { - fieldsToCreate.map((field) => { - const migrations = [ - { - name: computeObjectTargetTable( - objectsInDbById[field.objectMetadataId], - ), - action: 'alter', - columns: this.workspaceMigrationFactory.createColumnActions( - WorkspaceMigrationColumnActionType.CREATE, - field, - ), - } satisfies WorkspaceMigrationTableAction, - ]; - - migrationsToSave.push({ - workspaceId: field.workspaceId, - isCustom: false, - migrations, - }); - }); - } - - if (fieldsToDelete.length > 0) { - fieldsToDelete.map((field) => { - const migrations = [ - { - name: computeObjectTargetTable( - objectsInDbById[field.objectMetadataId], - ), - action: 'alter', - columns: [ - { - action: WorkspaceMigrationColumnActionType.DROP, - columnName: field.name, - }, - ], - } satisfies WorkspaceMigrationTableAction, - ]; - - migrationsToSave.push({ - workspaceId: field.workspaceId, - isCustom: false, - migrations, - }); - }); - } - - await this.workspaceMigrationRepository.save(migrationsToSave); - - // TODO: handle delete migrations - } - - private async generateRelationMigrationsFromSync( - relationsToCreate: RelationMetadataEntity[], - _relationsToDelete: RelationMetadataEntity[], - objectsInDB: ObjectMetadataEntity[], - ) { - const relationsMigrationsToSave: Partial[] = []; - - if (relationsToCreate.length > 0) { - relationsToCreate.map((relation) => { - const toObjectMetadata = objectsInDB.find( - (object) => object.id === relation.toObjectMetadataId, - ); - - const fromObjectMetadata = objectsInDB.find( - (object) => object.id === relation.fromObjectMetadataId, - ); - - if (!toObjectMetadata) { - throw new Error( - `ObjectMetadata with id ${relation.toObjectMetadataId} not found`, - ); - } - - if (!fromObjectMetadata) { - throw new Error( - `ObjectMetadata with id ${relation.fromObjectMetadataId} not found`, - ); - } - - const toFieldMetadata = toObjectMetadata.fields.find( - (field) => field.id === relation.toFieldMetadataId, - ); - - if (!toFieldMetadata) { - throw new Error( - `FieldMetadata with id ${relation.toFieldMetadataId} not found`, - ); - } - - const migrations = [ - { - name: computeObjectTargetTable(toObjectMetadata), - action: 'alter', - columns: [ - { - action: WorkspaceMigrationColumnActionType.RELATION, - columnName: `${camelCase(toFieldMetadata.name)}Id`, - referencedTableName: - computeObjectTargetTable(fromObjectMetadata), - referencedTableColumnName: 'id', - isUnique: - relation.relationType === RelationMetadataType.ONE_TO_ONE, - } satisfies WorkspaceMigrationColumnRelation, - ], - } satisfies WorkspaceMigrationTableAction, - ]; - - relationsMigrationsToSave.push({ - workspaceId: relation.workspaceId, - isCustom: false, - migrations, - }); - }); - } - - await this.workspaceMigrationRepository.save(relationsMigrationsToSave); - - // TODO: handle delete migrations - } -} diff --git a/yarn.lock b/yarn.lock index f49727562ec..f29e29e4ce2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15371,6 +15371,15 @@ __metadata: languageName: node linkType: hard +"@types/lodash.omit@npm:^4.5.9": + version: 4.5.9 + resolution: "@types/lodash.omit@npm:4.5.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 3b60c8ee8e9a691392d9a3ceabb32c85f888784bd3307eac3de01aeb7ff37383dc8899f027fe852641f5e0f56158fb19785cc3d20a4922e85b5810f14cba23f6 + languageName: node + linkType: hard + "@types/lodash.snakecase@npm:^4.1.7": version: 4.1.9 resolution: "@types/lodash.snakecase@npm:4.1.9" @@ -43153,6 +43162,7 @@ __metadata: "@sentry/profiling-node": "npm:^1.3.4" "@types/lodash.isempty": "npm:^4.4.7" "@types/lodash.isobject": "npm:^3.0.7" + "@types/lodash.omit": "npm:^4.5.9" "@types/lodash.snakecase": "npm:^4.1.7" "@types/lodash.upperfirst": "npm:^4.3.7" "@types/react": "npm:^18.2.39" @@ -43176,6 +43186,7 @@ __metadata: lodash.isempty: "npm:^4.4.0" lodash.isobject: "npm:^3.0.2" lodash.kebabcase: "npm:^4.1.1" + lodash.omit: "npm:^4.5.0" lodash.snakecase: "npm:^4.1.1" lodash.upperfirst: "npm:^4.3.1" mailparser: "npm:^3.6.5" From 84b6bea2b9efd01914ef378be84342343d69f139 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Tue, 30 Jan 2024 14:50:33 +0100 Subject: [PATCH 06/54] Split back fetch more loader for record table and emails (#3693) * Split back fetch more loader * Rename loader --------- Co-authored-by: Thomas Trompette --- .../EmailThreadFetchMoreLoader.tsx} | 6 ++-- .../emails/components/EmailThreads.tsx | 4 +-- .../components/RightDrawerEmailThread.tsx | 7 ++-- .../RecordTableBodyFetchMoreLoader.tsx | 34 +++++++++++++++---- 4 files changed, 38 insertions(+), 13 deletions(-) rename packages/twenty-front/src/modules/{ui/utilities/loading-state/components/FetchMoreLoader.tsx => activities/emails/components/EmailThreadFetchMoreLoader.tsx} (84%) diff --git a/packages/twenty-front/src/modules/ui/utilities/loading-state/components/FetchMoreLoader.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadFetchMoreLoader.tsx similarity index 84% rename from packages/twenty-front/src/modules/ui/utilities/loading-state/components/FetchMoreLoader.tsx rename to packages/twenty-front/src/modules/activities/emails/components/EmailThreadFetchMoreLoader.tsx index fb45a23711f..94f0b39029f 100644 --- a/packages/twenty-front/src/modules/ui/utilities/loading-state/components/FetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadFetchMoreLoader.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; import { grayScale } from '@/ui/theme/constants/colors'; -type FetchMoreLoaderProps = { +type EmailThreadFetchMoreLoaderProps = { loading: boolean; onLastRowVisible: (...args: any[]) => any; }; @@ -18,10 +18,10 @@ const StyledText = styled.div` padding-left: ${({ theme }) => theme.spacing(2)}; `; -export const FetchMoreLoader = ({ +export const EmailThreadFetchMoreLoader = ({ loading, onLastRowVisible, -}: FetchMoreLoaderProps) => { +}: EmailThreadFetchMoreLoaderProps) => { const { ref: tbodyRef } = useInView({ onChange: onLastRowVisible, }); diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx index e17909f75a6..1edc77b4dfb 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx @@ -3,6 +3,7 @@ import { useQuery } from '@apollo/client'; import styled from '@emotion/styled'; import { useRecoilState } from 'recoil'; +import { EmailThreadFetchMoreLoader } from '@/activities/emails/components/EmailThreadFetchMoreLoader'; import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview'; import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/messaging.constants'; import { useEmailThread } from '@/activities/emails/hooks/useEmailThread'; @@ -21,7 +22,6 @@ import { import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { Card } from '@/ui/layout/card/components/Card'; import { Section } from '@/ui/layout/section/components/Section'; -import { FetchMoreLoader } from '@/ui/utilities/loading-state/components/FetchMoreLoader'; import { GetTimelineThreadsFromPersonIdQueryVariables, TimelineThread, @@ -146,7 +146,7 @@ export const EmailThreads = ({ ))} )} - diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx index 13f8d8f2fd5..639f2cf9965 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx @@ -2,13 +2,13 @@ import React, { useCallback } from 'react'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; +import { EmailThreadFetchMoreLoader } from '@/activities/emails/components/EmailThreadFetchMoreLoader'; import { EmailThreadHeader } from '@/activities/emails/components/EmailThreadHeader'; import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage'; import { viewableEmailThreadState } from '@/activities/emails/state/viewableEmailThreadState'; import { EmailThreadMessage as EmailThreadMessageType } from '@/activities/emails/types/EmailThreadMessage'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { FetchMoreLoader } from '@/ui/utilities/loading-state/components/FetchMoreLoader'; const StyledContainer = styled.div` box-sizing: border-box; @@ -67,7 +67,10 @@ export const RightDrawerEmailThread = () => { sentAt={message.receivedAt} /> ))} - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyFetchMoreLoader.tsx index 611dfc9fa46..bbd4f911372 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyFetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyFetchMoreLoader.tsx @@ -1,21 +1,33 @@ +import { useInView } from 'react-intersection-observer'; +import styled from '@emotion/styled'; import { useRecoilCallback, useRecoilValue } from 'recoil'; import { useLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLoadRecordIndexTable'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState'; -import { FetchMoreLoader } from '@/ui/utilities/loading-state/components/FetchMoreLoader'; +import { grayScale } from '@/ui/theme/constants/colors'; type RecordTableBodyFetchMoreLoaderProps = { objectNameSingular: string; }; +const StyledText = styled.div` + align-items: center; + box-shadow: none; + color: ${grayScale.gray40}; + display: flex; + height: 32px; + margin-left: ${({ theme }) => theme.spacing(8)}; + padding-left: ${({ theme }) => theme.spacing(2)}; +`; + export const RecordTableBodyFetchMoreLoader = ({ objectNameSingular, }: RecordTableBodyFetchMoreLoaderProps) => { const { queryStateIdentifier } = useLoadRecordIndexTable(objectNameSingular); const { setRecordTableLastRowVisible } = useRecordTable(); - const isFetchingMoreObjects = useRecoilValue( + const isFetchingMoreRecords = useRecoilValue( isFetchingMoreRecordsFamilyState(queryStateIdentifier), ); @@ -26,10 +38,20 @@ export const RecordTableBodyFetchMoreLoader = ({ [setRecordTableLastRowVisible], ); + const { ref: tbodyRef } = useInView({ + onChange: onLastRowVisible, + }); + return ( - + + {isFetchingMoreRecords && ( + + + Loading more... + + + + )} + ); }; From 511627ccb8aab828495374c93fd117ac284d422b Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Tue, 30 Jan 2024 15:01:12 +0100 Subject: [PATCH 07/54] Fix count avatar color + align thread preview items (#3695) Fix count avatar and align items Co-authored-by: Thomas Trompette --- .../activities/comment/CommentHeader.tsx | 2 +- .../emails/components/EmailThreadPreview.tsx | 56 +++++++++---------- .../command-menu/components/CommandMenu.tsx | 4 +- .../favorites/components/Favorites.tsx | 2 +- .../MultipleObjectRecordSelectItem.tsx | 2 +- .../components/SelectableMenuItemSelect.tsx | 2 +- .../MultipleRecordSelectDropdown.tsx | 2 +- .../ui/display/chip/components/EntityChip.tsx | 2 +- .../components/ShowPageSummaryCard.tsx | 2 +- .../src/modules/users/components/Avatar.tsx | 28 +++++++--- .../components/WorkspaceMemberCard.tsx | 2 +- 11 files changed, 56 insertions(+), 48 deletions(-) diff --git a/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx b/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx index a8cbb6b19b5..390851d5276 100644 --- a/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx +++ b/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx @@ -77,7 +77,7 @@ export const CommentHeader = ({ comment, actionBar }: CommentHeaderProps) => { {authorName} diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx index 431c6ef0a03..cd3e8b08b9d 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import { CardContent } from '@/ui/layout/card/components/CardContent'; +import { grayScale } from '@/ui/theme/constants/colors'; import { Avatar } from '@/users/components/Avatar'; import { TimelineThread } from '~/generated/graphql'; import { formatToHumanReadableDate } from '~/utils'; @@ -15,29 +16,17 @@ const StyledCardContent = styled(CardContent)` `; const StyledHeading = styled.div<{ unread: boolean }>` - align-items: center; + display: flex; color: ${({ theme, unread }) => unread ? theme.font.color.primary : theme.font.color.secondary}; - display: flex; font-weight: ${({ theme, unread }) => unread ? theme.font.weight.medium : theme.font.weight.regular}; - gap: ${({ theme }) => theme.spacing(1)}; overflow: hidden; - width: 160px; - - :before { - background-color: ${({ theme, unread }) => - unread ? theme.color.blue : 'transparent'}; - border-radius: ${({ theme }) => theme.border.radius.rounded}; - content: ''; - display: block; - height: 6px; - width: 6px; - } + width: 20%; `; const StyledParticipantsContainer = styled.div` - align-items: center; + align-items: flex-start; display: flex; `; @@ -46,6 +35,7 @@ const StyledAvatar = styled(Avatar)` `; const StyledSenderNames = styled.span` + display: flex; margin: ${({ theme }) => theme.spacing(0, 1)}; overflow: hidden; text-overflow: ellipsis; @@ -56,26 +46,27 @@ const StyledThreadCount = styled.span` color: ${({ theme }) => theme.font.color.tertiary}; `; -const StyledSubject = styled.span<{ unread: boolean }>` - color: ${({ theme, unread }) => - unread ? theme.font.color.primary : theme.font.color.secondary}; - white-space: nowrap; -`; - -const StyledBody = styled.span` - color: ${({ theme }) => theme.font.color.tertiary}; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - const StyledSubjectAndBody = styled.div` display: flex; flex: 1; gap: ${({ theme }) => theme.spacing(2)}; overflow: hidden; +`; + +const StyledSubject = styled.span<{ unread: boolean }>` + color: ${({ theme, unread }) => + unread ? theme.font.color.primary : theme.font.color.secondary}; + white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; + flex-shrink: 0; +`; + +const StyledBody = styled.span` + color: ${({ theme }) => theme.font.color.tertiary}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; const StyledReceivedAt = styled.div` @@ -104,12 +95,13 @@ export const EmailThreadPreview = ({ ? `, ${thread.lastTwoParticipants?.[1]?.displayName}` : ''); - const [finalDisplayedName, finalAvatarUrl] = + const [finalDisplayedName, finalAvatarUrl, isCountIcon] = thread.participantCount > 3 - ? [`${thread.participantCount}`, ''] + ? [`${thread.participantCount}`, '', true] : [ thread?.lastTwoParticipants?.[1]?.displayName, thread?.lastTwoParticipants?.[1]?.avatarUrl, + false, ]; return ( @@ -133,6 +125,8 @@ export const EmailThreadPreview = ({ avatarUrl={finalAvatarUrl} placeholder={finalDisplayedName} type="rounded" + color={isCountIcon ? grayScale.gray50 : undefined} + backgroundColor={isCountIcon ? grayScale.gray10 : undefined} /> )} diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index 1e3a4722437..68fdaac2493 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -328,7 +328,7 @@ export const CommandMenu = () => { { to={`object/company/${company.id}`} Icon={() => ( { label={labelIdentifier} Icon={() => ( diff --git a/packages/twenty-front/src/modules/users/components/Avatar.tsx b/packages/twenty-front/src/modules/users/components/Avatar.tsx index 12dc109fd54..9097c3ddcea 100644 --- a/packages/twenty-front/src/modules/users/components/Avatar.tsx +++ b/packages/twenty-front/src/modules/users/components/Avatar.tsx @@ -16,21 +16,24 @@ export type AvatarProps = { className?: string; size?: AvatarSize; placeholder: string | undefined; - colorId?: string; + entityId?: string; type?: Nullable; + color?: string; + backgroundColor?: string; onClick?: () => void; }; -const StyledAvatar = styled.div` +export const StyledAvatar = styled.div< + AvatarProps & { color: string; backgroundColor: string } +>` align-items: center; - background-color: ${({ avatarUrl, colorId }) => - !isNonEmptyString(avatarUrl) ? stringToHslColor(colorId, 75, 85) : 'none'}; + background-color: ${({ backgroundColor }) => backgroundColor}; ${({ avatarUrl }) => isNonEmptyString(avatarUrl) ? `background-image: url(${avatarUrl});` : ''} background-position: center; background-size: cover; border-radius: ${(props) => (props.type === 'rounded' ? '50%' : '2px')}; - color: ${({ colorId }) => stringToHslColor(colorId, 75, 25)}; + color: ${({ color }) => color}; cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')}; display: flex; @@ -95,9 +98,11 @@ export const Avatar = ({ className, size = 'md', placeholder, - colorId = placeholder, + entityId = placeholder, onClick, type = 'squared', + color, + backgroundColor, }: AvatarProps) => { const noAvatarUrl = !isNonEmptyString(avatarUrl); const [isInvalidAvatarUrl, setIsInvalidAvatarUrl] = useState(false); @@ -114,6 +119,13 @@ export const Avatar = ({ } }, [avatarUrl]); + const fixedColor = color ?? stringToHslColor(entityId ?? '', 75, 25); + const fixedBackgroundColor = + backgroundColor ?? + (!isNonEmptyString(avatarUrl) + ? stringToHslColor(entityId ?? '', 75, 85) + : 'none'); + return ( {(noAvatarUrl || isInvalidAvatarUrl) && placeholder?.[0]?.toLocaleUpperCase()} diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceMemberCard.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceMemberCard.tsx index 3c5e3acab9b..c6529442a6e 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceMemberCard.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceMemberCard.tsx @@ -40,7 +40,7 @@ export const WorkspaceMemberCard = ({ Date: Tue, 30 Jan 2024 15:24:03 +0100 Subject: [PATCH 08/54] Board improvements (#3694) * New board improvements * Improve board * Fix --- package.json | 2 +- .../command-menu/components/CommandMenu.tsx | 3 +- .../components/KeyboardShortcutMenu.tsx | 12 ++--- .../components/RecordBoardDeprecated.tsx | 8 ---- ...ecordBoardDeprecatedColumnDropdownMenu.tsx | 4 +- ...dBoardDeprecatedOptionsDropdownContent.tsx | 2 +- .../record-board/components/RecordBoard.tsx | 25 ++++++++--- .../contexts/RecordBoardContext.ts | 2 + ...election.ts => useRecordBoardSelection.ts} | 20 ++++++++- .../RecordBoardCardDraggableContainer.tsx | 2 +- .../components/RecordBoardColumn.tsx | 2 + .../RecordBoardColumnCardsContainer.tsx | 29 +++++++++++- .../components/RecordBoardColumnHeader.tsx | 7 +-- .../components/RecordBoardColumnNewButton.tsx | 44 +++++++++++++++++++ .../contexts/RecordBoardColumnContext.ts | 1 + .../input/hooks/useRegisterInputEvents.ts | 4 +- .../components/RecordIndexBoardContainer.tsx | 10 +++++ .../RecordIndexBoardContainerEffect.tsx | 24 +++++++--- .../hooks/useLoadRecordIndexBoard.ts | 24 ++++++++-- .../components/RecordTableInternalEffect.tsx | 4 +- .../TableOptionsDropdownContent.tsx | 2 +- .../SingleEntitySelectMenuItems.tsx | 2 +- .../input/components/DoubleTextInput.tsx | 2 +- .../ui/input/components/AutosizeTextInput.tsx | 3 +- .../layout/dropdown/components/Dropdown.tsx | 2 +- .../right-drawer/components/RightDrawer.tsx | 5 ++- .../utilities/hotkey/types/AppHotkeyScope.ts | 1 + .../src/modules/views/hooks/useViewBar.ts | 3 +- yarn.lock | 10 ++--- 29 files changed, 205 insertions(+), 54 deletions(-) rename packages/twenty-front/src/modules/object-record/record-board/hooks/{useResetBoardRecordSelection.ts => useRecordBoardSelection.ts} (53%) create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton.tsx diff --git a/package.json b/package.json index e5dc52ef816..1755205a00e 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "react-error-boundary": "^4.0.11", "react-helmet-async": "^1.3.0", "react-hook-form": "^7.45.1", - "react-hotkeys-hook": "^4.4.3", + "react-hotkeys-hook": "^4.4.4", "react-icons": "^4.12.0", "react-intersection-observer": "^9.5.2", "react-loading-skeleton": "^3.3.1", diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index 68fdaac2493..430301c54ee 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef, useState } from 'react'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; +import { Key } from 'ts-key-enum'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; import { Activity } from '@/activities/types/Activity'; @@ -124,7 +125,7 @@ export const CommandMenu = () => { ); useScopedHotkeys( - 'esc', + [Key.Escape], () => { closeCommandMenu(); }, diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenu.tsx b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenu.tsx index 978d3967ec1..7b2d0bd6a9f 100644 --- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenu.tsx +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenu.tsx @@ -1,4 +1,5 @@ import { useRecoilValue } from 'recoil'; +import { Key } from 'ts-key-enum'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; @@ -22,6 +23,7 @@ export const KeyboardShortcutMenu = () => { isKeyboardShortcutMenuOpenedState, ); const { closeCommandMenu } = useCommandMenu(); + useScopedHotkeys( 'shift+?,meta+?', () => { @@ -33,12 +35,12 @@ export const KeyboardShortcutMenu = () => { ); useScopedHotkeys( - 'esc', + [Key.Escape], () => { closeKeyboardShortcutMenu(); }, - AppHotkeyScope.KeyboardShortcutMenu, - [toggleKeyboardShortcutMenu], + AppHotkeyScope.KeyboardShortcutMenuOpen, + [closeKeyboardShortcutMenu], ); return ( @@ -46,8 +48,8 @@ export const KeyboardShortcutMenu = () => { {isKeyboardShortcutMenuOpened && ( - {keyboardShortcutsTable.map((TableShortcut) => ( - + {keyboardShortcutsTable.map((TableShortcut, index) => ( + ))} diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecated.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecated.tsx index 61bbd75f39d..854a138297d 100644 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecated.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecated.tsx @@ -12,9 +12,7 @@ import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-boa import { useSetRecordBoardDeprecatedCardSelectedInternal } from '@/object-record/record-board-deprecated/hooks/internal/useSetRecordBoardDeprecatedCardSelectedInternal'; import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope'; import { Opportunity } from '@/pipeline/types/Opportunity'; -import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { logError } from '~/utils/logError'; @@ -129,12 +127,6 @@ export const RecordBoardDeprecated = ({ const boardRef = useRef(null); - useScopedHotkeys( - 'escape', - unselectAllActiveCards, - PageHotkeyScope.OpportunitiesPage, - ); - return ( diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnDropdownMenu.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnDropdownMenu.tsx index 4c20960063a..69e99b95d02 100644 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnDropdownMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnDropdownMenu.tsx @@ -69,7 +69,9 @@ export const RecordBoardDeprecatedColumnDropdownMenu = ({ useScopedHotkeys( [Key.Escape, Key.Enter], - closeMenu, + () => { + closeMenu(); + }, BoardColumnHotkeyScope.BoardColumn, [], ); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownContent.tsx index 58514c6c814..e673f1de3d0 100644 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownContent.tsx @@ -123,7 +123,7 @@ export const RecordBoardDeprecatedOptionsDropdownContent = ({ ); useScopedHotkeys( - Key.Escape, + [Key.Escape], () => { setViewEditMode('none'); closeDropdown(); diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx index 3cfddff76c0..3fff6daa518 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx @@ -2,15 +2,19 @@ import { useContext, useRef } from 'react'; import styled from '@emotion/styled'; import { DragDropContext, OnDragEndResponder } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350 import { useRecoilCallback, useRecoilValue } from 'recoil'; +import { Key } from 'ts-key-enum'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; +import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; import { RecordBoardColumn } from '@/object-record/record-board/record-board-column/components/RecordBoardColumn'; import { RecordBoardScope } from '@/object-record/record-board/scopes/RecordBoardScope'; +import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; export type RecordBoardProps = { recordBoardId: string; @@ -40,17 +44,25 @@ const StyledBoardHeader = styled.div` `; export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => { - const { updateOneRecord, objectMetadataItem } = + const { updateOneRecord, selectFieldMetadataItem } = useContext(RecordBoardContext); const boardRef = useRef(null); const { getColumnIdsState, columnsFamilySelector } = useRecordBoardStates(recordBoardId); + const columnIds = useRecoilValue(getColumnIdsState()); - const selectFieldMetadataItem = objectMetadataItem.fields.find( - (field) => field.type === FieldMetadataType.Select, - ); + const { resetRecordSelection, setRecordAsSelected } = + useRecordBoardSelection(recordBoardId); + + useListenClickOutsideByClassName({ + classNames: ['record-board-card'], + excludeClassNames: ['action-bar', 'context-menu'], + callback: resetRecordSelection, + }); + + useScopedHotkeys([Key.Escape], resetRecordSelection, TableHotkeyScope.Table); const onDragEnd: OnDragEndResponder = useRecoilCallback( ({ snapshot }) => @@ -106,7 +118,8 @@ export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => { {}} + onDragSelectionStart={resetRecordSelection} + onDragSelectionChange={setRecordAsSelected} /> diff --git a/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts b/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts index 8043ec985b3..d8d7f149073 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts @@ -1,10 +1,12 @@ import { createContext } from 'react'; +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; type RecordBoardContextProps = { objectMetadataItem: ObjectMetadataItem; + selectFieldMetadataItem: FieldMetadataItem; createOneRecord: (recordInput: Partial) => void; updateOneRecord: ({ idToUpdate, diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useResetBoardRecordSelection.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts similarity index 53% rename from packages/twenty-front/src/modules/object-record/record-board/hooks/useResetBoardRecordSelection.ts rename to packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts index 6266eb5367d..ae89223c79e 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useResetBoardRecordSelection.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts @@ -2,7 +2,7 @@ import { useRecoilCallback } from 'recoil'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; -export const useResetBoardRecordSelection = (recordBoardId?: string) => { +export const useRecordBoardSelection = (recordBoardId?: string) => { const { getSelectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState } = useRecordBoardStates(recordBoardId); @@ -20,5 +20,21 @@ export const useResetBoardRecordSelection = (recordBoardId?: string) => { [getSelectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState], ); - return { resetRecordSelection }; + const setRecordAsSelected = useRecoilCallback( + ({ snapshot, set }) => + (recordId: string, isSelected: boolean) => { + const isRecordCurrentlySelected = snapshot + .getLoadable(isRecordBoardCardSelectedFamilyState(recordId)) + .getValue(); + + if (isRecordCurrentlySelected === isSelected) { + return; + } + + set(isRecordBoardCardSelectedFamilyState(recordId), isSelected); + }, + [isRecordBoardCardSelectedFamilyState], + ); + + return { resetRecordSelection, setRecordAsSelected }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx index f8f0b9c8dea..af763fa60eb 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer.tsx @@ -18,7 +18,7 @@ export const RecordBoardCardDraggableContainer = ({ {...draggableProvided?.dragHandleProps} // eslint-disable-next-line react/jsx-props-no-spreading {...draggableProvided?.draggableProps} - className="entity-board-card" + className="record-board-card" data-selectable-id={recordId} data-select-disable > diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx index 5a048c78f6a..769f261ddcf 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx @@ -14,6 +14,7 @@ const StyledColumn = styled.div<{ isFirstColumn: boolean }>` isFirstColumn ? 'none' : theme.border.color.light}; display: flex; flex-direction: column; + height: fit-content; max-width: 200px; min-width: 200px; @@ -60,6 +61,7 @@ export const RecordBoardColumn = ({ columnDefinition: columnDefinition, isFirstColumn: isFirstColumn, isLastColumn: isLastColumn, + recordCount: recordIds.length, }} > diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx index d9bc202be9a..236a4314557 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useContext } from 'react'; import styled from '@emotion/styled'; -import { DroppableProvided } from '@hello-pangea/dnd'; +import { Draggable, DroppableProvided } from '@hello-pangea/dnd'; import { RecordBoardColumnCardsMemo } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnCardsMemo'; +import { RecordBoardColumnNewButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton'; +import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; const StyledPlaceholder = styled.div` min-height: 1px; @@ -14,6 +16,10 @@ const StyledColumnCardsContainer = styled.div` flex-direction: column; `; +const StyledNewButtonContainer = styled.div` + padding-bottom: ${({ theme }) => theme.spacing(4)}; +`; + type RecordBoardColumnCardsContainerProps = { recordIds: string[]; droppableProvided: DroppableProvided; @@ -23,6 +29,8 @@ export const RecordBoardColumnCardsContainer = ({ recordIds, droppableProvided, }: RecordBoardColumnCardsContainerProps) => { + const { columnDefinition } = useContext(RecordBoardColumnContext); + return ( {droppableProvided?.placeholder} + + {(draggableProvided) => ( +
+ + + +
+ )} +
); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx index 31d47a39a34..d1e36a3ecc6 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx @@ -46,7 +46,9 @@ export const RecordBoardColumnHeader = () => { const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false); const [isHeaderHovered, setIsHeaderHovered] = useState(false); - const { columnDefinition } = useContext(RecordBoardColumnContext); + const { columnDefinition, recordCount } = useContext( + RecordBoardColumnContext, + ); const { setHotkeyScopeAndMemorizePreviousScope, @@ -66,7 +68,6 @@ export const RecordBoardColumnHeader = () => { }; const boardColumnTotal = 0; - const cardIds = []; return ( <> @@ -81,7 +82,7 @@ export const RecordBoardColumnHeader = () => { /> {!!boardColumnTotal && ${boardColumnTotal}} {!isHeaderHovered && ( - {cardIds.length} + {recordCount} )} {isHeaderHovered && ( diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton.tsx new file mode 100644 index 00000000000..deac52c76d3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton.tsx @@ -0,0 +1,44 @@ +import { useContext } from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; +import { IconPlus } from '@/ui/display/icon/index'; + +const StyledButton = styled.button` + align-items: center; + align-self: baseline; + background-color: ${({ theme }) => theme.background.primary}; + border: none; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.font.color.tertiary}; + cursor: pointer; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(1)}; + + &:hover { + background-color: ${({ theme }) => theme.background.tertiary}; + } +`; + +export const RecordBoardColumnNewButton = () => { + const theme = useTheme(); + const { columnDefinition } = useContext(RecordBoardColumnContext); + const { createOneRecord, selectFieldMetadataItem } = + useContext(RecordBoardContext); + + const onNewClick = () => { + createOneRecord({ + [selectFieldMetadataItem.name]: columnDefinition.value, + }); + }; + + return ( + + + New + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts index 7016dcce81d..8a9ced3eb00 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts @@ -6,6 +6,7 @@ type RecordBoardColumnContextProps = { columnDefinition: RecordBoardColumnDefinition; isFirstColumn: boolean; isLastColumn: boolean; + recordCount: number; }; export const RecordBoardColumnContext = diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts index 36e180652b2..c4aa0b30851 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts @@ -1,3 +1,5 @@ +import { Key } from 'ts-key-enum'; + import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { isDefined } from '~/utils/isDefined'; @@ -41,7 +43,7 @@ export const useRegisterInputEvents = ({ ); useScopedHotkeys( - 'esc', + [Key.Escape], () => { onEscape?.(inputValue); }, diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx index 80f8ad7c662..d11fcb4ac4c 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx @@ -6,6 +6,7 @@ import { RecordBoardActionBar } from '@/object-record/record-board/action-bar/co import { RecordBoard } from '@/object-record/record-board/components/RecordBoard'; import { RecordBoardContextMenu } from '@/object-record/record-board/context-menu/components/RecordBoardContextMenu'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; type RecordIndexBoardContainerProps = { recordBoardId: string; @@ -20,14 +21,23 @@ export const RecordIndexBoardContainer = ({ }: RecordIndexBoardContainerProps) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular }); + const selectFieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.type === FieldMetadataType.Select, + ); + const { deleteOneRecord } = useDeleteOneRecord({ objectNameSingular }); const { updateOneRecord } = useUpdateOneRecord({ objectNameSingular }); const { createOneRecord } = useCreateOneRecord({ objectNameSingular }); + if (!selectFieldMetadataItem) { + return; + } + return ( { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); - useLoadRecordIndexBoard(objectNameSingular, recordBoardId); + useLoadRecordIndexBoard({ objectNameSingular, recordBoardId, viewBarId }); const navigate = useNavigate(); @@ -31,9 +33,13 @@ export const RecordIndexBoardContainerEffect = ({ navigate(`/settings/objects/${objectMetadataItem.namePlural}`); }, [navigate, objectMetadataItem.namePlural]); - const { setColumns, setObjectSingularName, getSelectedRecordIdsSelector } = - useRecordBoard(recordBoardId); - const { resetRecordSelection } = useResetBoardRecordSelection(recordBoardId); + const { + setColumns, + setObjectSingularName, + getSelectedRecordIdsSelector, + setFieldDefinitions, + } = useRecordBoard(recordBoardId); + const { resetRecordSelection } = useRecordBoardSelection(recordBoardId); useEffect(() => { setObjectSingularName(objectNameSingular); @@ -53,6 +59,14 @@ export const RecordIndexBoardContainerEffect = ({ setColumns, ]); + const recordIndexFieldDefinitions = useRecoilValue( + recordIndexFieldDefinitionsState, + ); + + useEffect(() => { + setFieldDefinitions(recordIndexFieldDefinitions); + }, [objectMetadataItem, setFieldDefinitions, recordIndexFieldDefinitions]); + const selectedRecordIds = useRecoilValue(getSelectedRecordIdsSelector()); const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({ diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts index f7a394f8ed5..208c48d6117 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts @@ -10,11 +10,19 @@ import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/s import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { useViewBar } from '@/views/hooks/useViewBar'; -export const useLoadRecordIndexBoard = ( - objectNameSingular: string, - recordBoardId: string, -) => { +type UseLoadRecordIndexBoardProps = { + objectNameSingular: string; + viewBarId: string; + recordBoardId: string; +}; + +export const useLoadRecordIndexBoard = ({ + objectNameSingular, + viewBarId, + recordBoardId, +}: UseLoadRecordIndexBoardProps) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); @@ -47,6 +55,10 @@ export const useLoadRecordIndexBoard = ( orderBy, }); + const { setEntityCountInCurrentView } = useViewBar({ + viewBarId, + }); + useEffect(() => { setRecordIdsInBoard(records); }, [records, setRecordIdsInBoard]); @@ -55,6 +67,10 @@ export const useLoadRecordIndexBoard = ( setRecordsInStore(records); }, [records, setRecordsInStore]); + useEffect(() => { + setEntityCountInCurrentView(records.length); + }, [records.length, setEntityCountInCurrentView]); + return { records, loading, diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx index 6f74a8aa34f..cb8be2bbca7 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableInternalEffect.tsx @@ -1,3 +1,5 @@ +import { Key } from 'ts-key-enum'; + import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; @@ -28,7 +30,7 @@ export const RecordTableInternalEffect = ({ }); useScopedHotkeys( - 'escape', + [Key.Escape], () => { resetTableRowSelection(); }, diff --git a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownContent.tsx index 94bb6eb15f6..2cfe4c81125 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownContent.tsx @@ -80,7 +80,7 @@ export const TableOptionsDropdownContent = ({ const resetMenu = () => setCurrentMenu(undefined); useScopedHotkeys( - Key.Escape, + [Key.Escape], () => { closeDropdown(); }, diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItems.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItems.tsx index 8d6c4d99817..6385b06c426 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItems.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItems.tsx @@ -59,7 +59,7 @@ export const SingleEntitySelectMenuItems = ({ ); useScopedHotkeys( - Key.Escape, + [Key.Escape], () => { onCancel?.(); }, diff --git a/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx index 9333403956a..3a5b387aa0d 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/DoubleTextInput.tsx @@ -94,7 +94,7 @@ export const DoubleTextInput = ({ ); useScopedHotkeys( - Key.Escape, + [Key.Escape], () => { onEscape({ firstValue: firstInternalValue, diff --git a/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx b/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx index c347c8158b8..95d40a4d031 100644 --- a/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/AutosizeTextInput.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { HotkeysEvent } from 'react-hotkeys-hook/dist/types'; import TextareaAutosize from 'react-textarea-autosize'; import styled from '@emotion/styled'; +import { Key } from 'ts-key-enum'; import { IconArrowRight } from '@/ui/display/icon/index'; import { Button } from '@/ui/input/button/components/Button'; @@ -151,7 +152,7 @@ export const AutosizeTextInput = ({ ); useScopedHotkeys( - 'esc', + Key.Escape, (event: KeyboardEvent) => { if (!isFocused) { return; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx index 57f1bc0b424..1169b1587a2 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx @@ -94,7 +94,7 @@ export const Dropdown = ({ }); useScopedHotkeys( - Key.Escape, + [Key.Escape], () => { closeDropdown(); }, diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx index 2ec927823f5..8d5419819bf 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx @@ -66,7 +66,10 @@ export const RightDrawer = () => { useScopedHotkeys( [Key.Escape], - () => closeRightDrawer(), + + () => { + closeRightDrawer(); + }, RightDrawerHotkeyScope.RightDrawer, [setIsRightDrawerOpen], ); diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/types/AppHotkeyScope.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/types/AppHotkeyScope.ts index cd063563e5d..9fc1a460ec6 100644 --- a/packages/twenty-front/src/modules/ui/utilities/hotkey/types/AppHotkeyScope.ts +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/types/AppHotkeyScope.ts @@ -4,4 +4,5 @@ export enum AppHotkeyScope { CommandMenu = 'command-menu', CommandMenuOpen = 'command-menu-open', KeyboardShortcutMenu = 'keyboard-shortcut-menu', + KeyboardShortcutMenuOpen = 'keyboard-shortcut-menu-open', } diff --git a/packages/twenty-front/src/modules/views/hooks/useViewBar.ts b/packages/twenty-front/src/modules/views/hooks/useViewBar.ts index a4b63545797..2111217a2e5 100644 --- a/packages/twenty-front/src/modules/views/hooks/useViewBar.ts +++ b/packages/twenty-front/src/modules/views/hooks/useViewBar.ts @@ -127,8 +127,9 @@ export const useViewBar = (props?: UseViewProps) => { if (!isDeeplyEqual(savedViewFields, queriedViewFields)) { set(currentViewFieldsState, queriedViewFields); set(savedViewFieldsState, queriedViewFields); - onViewFieldsChange?.(queriedViewFields); } + + onViewFieldsChange?.(queriedViewFields); }, [scopeId], ); diff --git a/yarn.lock b/yarn.lock index f29e29e4ce2..45da2979c57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -38381,13 +38381,13 @@ __metadata: languageName: node linkType: hard -"react-hotkeys-hook@npm:^4.4.3": - version: 4.4.3 - resolution: "react-hotkeys-hook@npm:4.4.3" +"react-hotkeys-hook@npm:^4.4.4": + version: 4.4.4 + resolution: "react-hotkeys-hook@npm:4.4.4" peerDependencies: react: ">=16.8.1" react-dom: ">=16.8.1" - checksum: ef79e279129f6e55d81c8762b1da214d9c6ee4617b9597dbc3f93057cba8d166831508967b8e3f763a0c4ce0af3b59e6888c6fc94d152deac9335020b2ba80df + checksum: afe7418c8bd0ecd3a3f315648b84d9978d06a02e55f93df23aaa00e56613fca839f9ff4d10387e8336454702b4aa03cef97f3e67bfe3e121e42f16d84685f55a languageName: node linkType: hard @@ -43453,7 +43453,7 @@ __metadata: react-error-boundary: "npm:^4.0.11" react-helmet-async: "npm:^1.3.0" react-hook-form: "npm:^7.45.1" - react-hotkeys-hook: "npm:^4.4.3" + react-hotkeys-hook: "npm:^4.4.4" react-icons: "npm:^4.12.0" react-intersection-observer: "npm:^9.5.2" react-loading-skeleton: "npm:^3.3.1" From 2f7f6d3241182e835215df5ab2e79adca92991d1 Mon Sep 17 00:00:00 2001 From: bosiraphael <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 30 Jan 2024 15:40:14 +0100 Subject: [PATCH 09/54] Fix empty state flashing on SettingsAccountsEmailsSyncSection (#3698) fix --- .../SettingsAccountEmailsSkeletonCard.tsx | 10 +++++ .../SettingsAccountsEmailsSyncSection.tsx | 38 +++++++++++-------- 2 files changed, 33 insertions(+), 15 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountEmailsSkeletonCard.tsx diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountEmailsSkeletonCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountEmailsSkeletonCard.tsx new file mode 100644 index 00000000000..b30fbbec56d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountEmailsSkeletonCard.tsx @@ -0,0 +1,10 @@ +import styled from '@emotion/styled'; + +import { Card } from '@/ui/layout/card/components/Card'; + +const StyledCard = styled(Card)` + background-color: ${({ theme }) => theme.background.secondary}; + height: 40px; +`; + +export const SettingsAccountEmailsSkeletonCard = () => ; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsEmailsSyncSection.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsEmailsSyncSection.tsx index ba6a3a0a46d..198e474203d 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsEmailsSyncSection.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsEmailsSyncSection.tsx @@ -4,6 +4,7 @@ import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; import { MessageChannel } from '@/accounts/types/MessageChannel'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { SettingsAccountEmailsSkeletonCard } from '@/settings/accounts/components/SettingsAccountEmailsSkeletonCard'; import { H2Title } from '@/ui/display/typography/components/H2Title'; import { Section } from '@/ui/layout/section/components/Section'; @@ -13,23 +14,25 @@ import { SettingsAccountsEmptyStateCard } from './SettingsAccountsEmptyStateCard export const SettingsAccountsEmailsSyncSection = () => { const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); - const accounts = useFindManyRecords({ - objectNameSingular: 'connectedAccount', - filter: { - accountOwnerId: { - eq: currentWorkspaceMember?.id, + const { records: accounts, loading: accountsLoading } = + useFindManyRecords({ + objectNameSingular: 'connectedAccount', + filter: { + accountOwnerId: { + eq: currentWorkspaceMember?.id, + }, }, - }, - }).records; + }); - const messageChannels = useFindManyRecords({ - objectNameSingular: 'messageChannel', - filter: { - connectedAccountId: { - in: accounts.map((account) => account.id), + const { records: messageChannels, loading: messageChannelsLoading } = + useFindManyRecords({ + objectNameSingular: 'messageChannel', + filter: { + connectedAccountId: { + in: accounts.map((account) => account.id), + }, }, - }, - }).records; + }); const messageChannelsWithSyncedEmails = messageChannels.map( (messageChannel) => ({ @@ -38,13 +41,18 @@ export const SettingsAccountsEmailsSyncSection = () => { }), ); + const loading = accountsLoading || messageChannelsLoading; + return (
- {accounts.length ? ( + + {loading ? ( + + ) : accounts.length ? ( From 1838d8e6fb6fcf63bbcd15aaf030097b80ca69ce Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Tue, 30 Jan 2024 17:08:34 +0100 Subject: [PATCH 10/54] Use scoped recoil state for email thread page (#3699) Use scoped recoild state for email thread page Co-authored-by: Thomas Trompette --- .../emails/components/EmailThreads.tsx | 24 +++++++++++++------ .../hooks/internal/useEmailThreadStates.ts | 22 +++++++++++++++++ .../emails/state/emailThreadsPageState.ts | 11 --------- .../state/emailThreadsPageStateScopeMap.ts | 12 ++++++++++ 4 files changed, 51 insertions(+), 18 deletions(-) create mode 100644 packages/twenty-front/src/modules/activities/emails/hooks/internal/useEmailThreadStates.ts delete mode 100644 packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageState.ts create mode 100644 packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageStateScopeMap.ts diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx index 1edc77b4dfb..816b63219b6 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx @@ -6,13 +6,10 @@ import { useRecoilState } from 'recoil'; import { EmailThreadFetchMoreLoader } from '@/activities/emails/components/EmailThreadFetchMoreLoader'; import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview'; import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/messaging.constants'; +import { useEmailThreadStates } from '@/activities/emails/hooks/internal/useEmailThreadStates'; import { useEmailThread } from '@/activities/emails/hooks/useEmailThread'; import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId'; import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId'; -import { - emailThreadsPageState, - EmailThreadsPageType, -} from '@/activities/emails/state/emailThreadsPageState'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { @@ -22,6 +19,7 @@ import { import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { Card } from '@/ui/layout/card/components/Card'; import { Section } from '@/ui/layout/section/components/Section'; +import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { GetTimelineThreadsFromPersonIdQueryVariables, TimelineThread, @@ -54,8 +52,13 @@ export const EmailThreads = ({ const { openEmailThread } = useEmailThread(); const { enqueueSnackBar } = useSnackBar(); - const [emailThreadsPage, setEmailThreadsPage] = - useRecoilState(emailThreadsPageState); + const { getEmailThreadsPageState } = useEmailThreadStates({ + emailThreadScopeId: getScopeIdFromComponentId(entity.id), + }); + + const [emailThreadsPage, setEmailThreadsPage] = useRecoilState( + getEmailThreadsPageState(), + ); const [isFetchingMoreEmails, setIsFetchingMoreEmails] = useState(false); @@ -91,7 +94,14 @@ export const EmailThreads = ({ ...emailThreadsPage, hasNextPage: false, })); - return prev; + return { + [queryName]: { + ...prev?.[queryName], + timelineThreads: [ + ...(prev?.[queryName]?.timelineThreads ?? []), + ], + }, + }; } return { diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/internal/useEmailThreadStates.ts b/packages/twenty-front/src/modules/activities/emails/hooks/internal/useEmailThreadStates.ts new file mode 100644 index 00000000000..dc40a314dfb --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/hooks/internal/useEmailThreadStates.ts @@ -0,0 +1,22 @@ +import { emailThreadsPageStateScopeMap } from '@/activities/emails/state/emailThreadsPageStateScopeMap'; +import { TabListScopeInternalContext } from '@/ui/layout/tab/scopes/scope-internal-context/TabListScopeInternalContext'; +import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; +import { getState } from '@/ui/utilities/recoil-scope/utils/getState'; + +type useEmailThreadStatesProps = { + emailThreadScopeId?: string; +}; + +export const useEmailThreadStates = ({ + emailThreadScopeId, +}: useEmailThreadStatesProps) => { + const scopeId = useAvailableScopeIdOrThrow( + TabListScopeInternalContext, + emailThreadScopeId, + ); + + return { + scopeId, + getEmailThreadsPageState: getState(emailThreadsPageStateScopeMap, scopeId), + }; +}; diff --git a/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageState.ts b/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageState.ts deleted file mode 100644 index 641cf92bb83..00000000000 --- a/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageState.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { atom } from 'recoil'; - -export type EmailThreadsPageType = { - pageNumber: number; - hasNextPage: boolean; -}; - -export const emailThreadsPageState = atom({ - key: 'EmailThreadsPageState', - default: { pageNumber: 1, hasNextPage: true }, -}); diff --git a/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageStateScopeMap.ts b/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageStateScopeMap.ts new file mode 100644 index 00000000000..f7761acc793 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageStateScopeMap.ts @@ -0,0 +1,12 @@ +import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; + +export type EmailThreadsPageType = { + pageNumber: number; + hasNextPage: boolean; +}; + +export const emailThreadsPageStateScopeMap = + createStateScopeMap({ + key: 'emailThreadsPageStateScopeMap', + defaultValue: { pageNumber: 1, hasNextPage: true }, + }); From 8b9d62e42564d19072dc0cc5210c19a26b50d0d0 Mon Sep 17 00:00:00 2001 From: bosiraphael <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 30 Jan 2024 17:46:25 +0100 Subject: [PATCH 11/54] 3681 avatars are not appearing in the messages and people name should be bold (#3692) * update font weight * fix picture not appearing --- .../emails/components/EmailThreadMessageSender.tsx | 7 ++++++- .../object-metadata/utils/getObjectRecordIdentifier.ts | 5 ++++- .../src/modules/object-record/components/RecordChip.tsx | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx index cb54fc90cbc..58e2663a8f0 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx @@ -24,6 +24,7 @@ const StyledAvatar = styled(Avatar)` const StyledSenderName = styled.span` font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; overflow: hidden; text-overflow: ellipsis; `; @@ -35,6 +36,10 @@ const StyledThreadMessageSentAt = styled.div` font-size: ${({ theme }) => theme.font.size.sm}; `; +const StyledRecordChip = styled(RecordChip)` + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + type EmailThreadMessageSenderProps = { sender: EmailThreadMessageParticipant; sentAt: string; @@ -57,7 +62,7 @@ export const EmailThreadMessageSender = ({ {person ? ( - diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts index 655b80860fa..f0217bc3ce2 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts @@ -52,10 +52,13 @@ export const getObjectRecordIdentifier = ({ ? 'squared' : 'rounded'; + // TODO: This is a temporary solution before we seed imageIdentifierFieldMetadataId in the database const avatarUrl = (objectMetadataItem.nameSingular === CoreObjectNameSingular.Company ? getLogoUrlFromDomainName(record['domainName'] ?? '') - : imageIdentifierFieldValue) ?? ''; + : objectMetadataItem.nameSingular === CoreObjectNameSingular.Person + ? record['avatarUrl'] ?? '' + : imageIdentifierFieldValue) ?? ''; const basePathToShowPage = getBasePathToShowPage({ objectMetadataItem, diff --git a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx index 8b09dc9476d..0da8ecf3b79 100644 --- a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx +++ b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx @@ -9,12 +9,14 @@ export type RecordChipProps = { objectNameSingular: string; record: ObjectRecord; maxWidth?: number; + className?: string; }; export const RecordChip = ({ objectNameSingular, record, maxWidth, + className, }: RecordChipProps) => { const { objectMetadataItem } = useObjectMetadataItemOnly({ objectNameSingular, @@ -34,6 +36,7 @@ export const RecordChip = ({ avatarUrl={objectRecordIdentifier.avatarUrl} linkToEntity={objectRecordIdentifier.linkToShowPage} maxWidth={maxWidth} + className={className} /> ); }; From 64b2ef3dc29a21ddf94d2f9156d737eab09099d2 Mon Sep 17 00:00:00 2001 From: Weiko Date: Tue, 30 Jan 2024 17:58:36 +0100 Subject: [PATCH 12/54] Delete message when no more association (#3701) * Delete message when no more association * remove unused injections * rename methods * fix after review --- ...etch-workspace-messages-commands.module.ts | 9 +- .../commands/gmail-full-sync.command.ts | 13 +- .../commands/gmail-partial-sync.command.ts | 13 +- .../connected-account.module.ts | 11 ++ .../connected-account.service.ts | 61 ++++++ ...ssage-channel-message-assocation.module.ts | 11 ++ ...age-channel-message-association.service.ts | 116 ++++++++++++ .../message-channel/message-channel.module.ts | 11 ++ .../message-channel.service.ts | 43 +++++ .../messaging/message/message.module.ts | 11 ++ .../messaging/message/message.service.ts | 63 ++++++ .../fetch-messages-by-batches.service.ts | 6 +- .../fetch-workspace-messages.module.ts | 16 +- .../services/gmail-full-sync.service.ts | 45 +++-- .../services/gmail-partial-sync.service.ts | 60 +++--- .../gmail-refresh-access-token.service.ts | 13 +- .../services/messaging-utils.service.ts | 179 +++++++----------- ...se.ts => gmail-message-parsed-response.ts} | 0 .../{gmailMessage.ts => gmail-message.ts} | 0 .../types/{gmailThread.ts => gmail-thread.ts} | 0 ...eadQuery.ts => message-or-thread-query.ts} | 0 .../workspace-datasource.service.ts | 11 +- .../types/object-record.ts | 10 + 23 files changed, 501 insertions(+), 201 deletions(-) create mode 100644 packages/twenty-server/src/workspace/messaging/connected-account/connected-account.module.ts create mode 100644 packages/twenty-server/src/workspace/messaging/connected-account/connected-account.service.ts create mode 100644 packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-assocation.module.ts create mode 100644 packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-association.service.ts create mode 100644 packages/twenty-server/src/workspace/messaging/message-channel/message-channel.module.ts create mode 100644 packages/twenty-server/src/workspace/messaging/message-channel/message-channel.service.ts create mode 100644 packages/twenty-server/src/workspace/messaging/message/message.module.ts create mode 100644 packages/twenty-server/src/workspace/messaging/message/message.service.ts rename packages/twenty-server/src/workspace/messaging/types/{gmailMessageParsedResponse.ts => gmail-message-parsed-response.ts} (100%) rename packages/twenty-server/src/workspace/messaging/types/{gmailMessage.ts => gmail-message.ts} (100%) rename packages/twenty-server/src/workspace/messaging/types/{gmailThread.ts => gmail-thread.ts} (100%) rename packages/twenty-server/src/workspace/messaging/types/{messageOrThreadQuery.ts => message-or-thread-query.ts} (100%) create mode 100644 packages/twenty-server/src/workspace/workspace-sync-metadata/types/object-record.ts diff --git a/packages/twenty-server/src/workspace/messaging/commands/fetch-workspace-messages-commands.module.ts b/packages/twenty-server/src/workspace/messaging/commands/fetch-workspace-messages-commands.module.ts index b8b7fce8a2c..bad683a35ea 100644 --- a/packages/twenty-server/src/workspace/messaging/commands/fetch-workspace-messages-commands.module.ts +++ b/packages/twenty-server/src/workspace/messaging/commands/fetch-workspace-messages-commands.module.ts @@ -6,18 +6,15 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { DataSourceModule } from 'src/metadata/data-source/data-source.module'; import { GmailFullSyncCommand } from 'src/workspace/messaging/commands/gmail-full-sync.command'; import { GmailPartialSyncCommand } from 'src/workspace/messaging/commands/gmail-partial-sync.command'; -import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service'; +import { ConnectedAccountModule } from 'src/workspace/messaging/connected-account/connected-account.module'; @Module({ imports: [ DataSourceModule, TypeORMModule, TypeOrmModule.forFeature([FeatureFlagEntity], 'core'), + ConnectedAccountModule, ], - providers: [ - GmailFullSyncCommand, - GmailPartialSyncCommand, - MessagingUtilsService, - ], + providers: [GmailFullSyncCommand, GmailPartialSyncCommand], }) export class FetchWorkspaceMessagesCommandsModule {} diff --git a/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts b/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts index 24a5c131cf2..81837964ccc 100644 --- a/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts +++ b/packages/twenty-server/src/workspace/messaging/commands/gmail-full-sync.command.ts @@ -8,13 +8,13 @@ import { FeatureFlagEntity, FeatureFlagKeys, } from 'src/core/feature-flag/feature-flag.entity'; -import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service'; import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service'; import { GmailFullSyncJobData, GmailFullSyncJob, } from 'src/workspace/messaging/jobs/gmail-full-sync.job'; +import { ConnectedAccountService } from 'src/workspace/messaging/connected-account/connected-account.service'; interface GmailFullSyncOptions { workspaceId: string; @@ -26,11 +26,11 @@ interface GmailFullSyncOptions { }) export class GmailFullSyncCommand extends CommandRunner { constructor( - private readonly utils: MessagingUtilsService, @InjectRepository(FeatureFlagEntity, 'core') private readonly featureFlagRepository: Repository, @Inject(MessageQueue.messagingQueue) private readonly messageQueueService: MessageQueueService, + private readonly connectedAccountService: ConnectedAccountService, ) { super(); } @@ -64,13 +64,8 @@ export class GmailFullSyncCommand extends CommandRunner { } private async fetchWorkspaceMessages(workspaceId: string): Promise { - const { workspaceDataSource, dataSourceMetadata } = - await this.utils.getDataSourceMetadataWorkspaceMetadata(workspaceId); - - const connectedAccounts = await this.utils.getConnectedAccounts( - dataSourceMetadata, - workspaceDataSource, - ); + const connectedAccounts = + await this.connectedAccountService.getAll(workspaceId); for (const connectedAccount of connectedAccounts) { await this.messageQueueService.add( diff --git a/packages/twenty-server/src/workspace/messaging/commands/gmail-partial-sync.command.ts b/packages/twenty-server/src/workspace/messaging/commands/gmail-partial-sync.command.ts index 1119d263b92..ddadd8c2543 100644 --- a/packages/twenty-server/src/workspace/messaging/commands/gmail-partial-sync.command.ts +++ b/packages/twenty-server/src/workspace/messaging/commands/gmail-partial-sync.command.ts @@ -8,13 +8,13 @@ import { FeatureFlagEntity, FeatureFlagKeys, } from 'src/core/feature-flag/feature-flag.entity'; -import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service'; import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service'; import { GmailPartialSyncJob, GmailPartialSyncJobData, } from 'src/workspace/messaging/jobs/gmail-partial-sync.job'; +import { ConnectedAccountService } from 'src/workspace/messaging/connected-account/connected-account.service'; interface GmailPartialSyncOptions { workspaceId: string; @@ -26,11 +26,11 @@ interface GmailPartialSyncOptions { }) export class GmailPartialSyncCommand extends CommandRunner { constructor( - private readonly utils: MessagingUtilsService, @InjectRepository(FeatureFlagEntity, 'core') private readonly featureFlagRepository: Repository, @Inject(MessageQueue.messagingQueue) private readonly messageQueueService: MessageQueueService, + private readonly connectedAccountService: ConnectedAccountService, ) { super(); } @@ -64,13 +64,8 @@ export class GmailPartialSyncCommand extends CommandRunner { } private async fetchWorkspaceMessages(workspaceId: string): Promise { - const { workspaceDataSource, dataSourceMetadata } = - await this.utils.getDataSourceMetadataWorkspaceMetadata(workspaceId); - - const connectedAccounts = await this.utils.getConnectedAccounts( - dataSourceMetadata, - workspaceDataSource, - ); + const connectedAccounts = + await this.connectedAccountService.getAll(workspaceId); for (const connectedAccount of connectedAccounts) { await this.messageQueueService.add( diff --git a/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.module.ts b/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.module.ts new file mode 100644 index 00000000000..376c7c6fede --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { ConnectedAccountService } from 'src/workspace/messaging/connected-account/connected-account.service'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; + +@Module({ + imports: [WorkspaceDataSourceModule], + providers: [ConnectedAccountService], + exports: [ConnectedAccountService], +}) +export class ConnectedAccountModule {} diff --git a/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.service.ts b/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.service.ts new file mode 100644 index 00000000000..0a7ef2278fd --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/connected-account/connected-account.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; +import { ConnectedAccountObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/connected-account.object-metadata'; +import { ObjectRecord } from 'src/workspace/workspace-sync-metadata/types/object-record'; + +@Injectable() +export class ConnectedAccountService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + public async getAll( + workspaceId: string, + ): Promise[]> { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + return await workspaceDataSource?.query( + `SELECT * FROM ${dataSourceMetadata.schema}."connectedAccount" WHERE "provider" = 'google'`, + ); + } + + public async getByIdOrFail( + connectedAccountId: string, + workspaceId: string, + ): Promise> { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + const connectedAccounts = await workspaceDataSource?.query( + `SELECT * FROM ${dataSourceMetadata.schema}."connectedAccount" WHERE "id" = $1 LIMIT 1`, + [connectedAccountId], + ); + + if (!connectedAccounts || connectedAccounts.length === 0) { + throw new Error('No connected account found'); + } + + return connectedAccounts[0]; + } + + public async saveLastSyncHistoryId( + historyId: string, + connectedAccountId: string, + workspaceId: string, + ) { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + await workspaceDataSource?.query( + `UPDATE ${dataSourceMetadata.schema}."connectedAccount" SET "lastSyncHistoryId" = $1 WHERE "id" = $2`, + [historyId, connectedAccountId], + ); + } +} diff --git a/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-assocation.module.ts b/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-assocation.module.ts new file mode 100644 index 00000000000..6a6b22d9737 --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-assocation.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { MessageChannelMessageAssociationService } from 'src/workspace/messaging/message-channel-message-association/message-channel-message-association.service'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; + +@Module({ + imports: [WorkspaceDataSourceModule], + providers: [MessageChannelMessageAssociationService], + exports: [MessageChannelMessageAssociationService], +}) +export class MessageChannelMessageAssociationModule {} diff --git a/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-association.service.ts b/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-association.service.ts new file mode 100644 index 00000000000..96168d66829 --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message-channel-message-association/message-channel-message-association.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; +import { MessageChannelMessageAssociationObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/message-channel-message-association.object-metadata'; +import { ObjectRecord } from 'src/workspace/workspace-sync-metadata/types/object-record'; + +@Injectable() +export class MessageChannelMessageAssociationService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + public async getByMessageExternalIdsAndMessageChannelId( + messageExternalIds: string[], + messageChannelId: string, + workspaceId: string, + ): Promise[]> { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + return await workspaceDataSource?.query( + `SELECT * FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" + WHERE "messageExternalId" = ANY($1) AND "messageChannelId" = $2`, + [messageExternalIds, messageChannelId], + ); + } + + public async countByMessageExternalIdsAndMessageChannelId( + messageExternalIds: string[], + messageChannelId: string, + workspaceId: string, + ): Promise { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + const result = await workspaceDataSource?.query( + `SELECT COUNT(*) FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" + WHERE "messageExternalId" = ANY($1) AND "messageChannelId" = $2`, + [messageExternalIds, messageChannelId], + ); + + return result[0]?.count; + } + + public async deleteByMessageExternalIdsAndMessageChannelId( + messageExternalIds: string[], + messageChannelId: string, + workspaceId: string, + ) { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + await workspaceDataSource?.query( + `DELETE FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" WHERE "messageExternalId" = ANY($1) AND "messageChannelId" = $2`, + [messageExternalIds, messageChannelId], + ); + } + + public async getByMessageThreadExternalIds( + messageThreadExternalIds: string[], + workspaceId: string, + ): Promise[]> { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + return await workspaceDataSource?.query( + `SELECT * FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" + WHERE "messageThreadExternalId" = ANY($1)`, + [messageThreadExternalIds], + ); + } + + public async getFirstByMessageThreadExternalId( + messageThreadExternalId: string, + workspaceId: string, + ): Promise | null> { + const existingMessageChannelMessageAssociations = + await this.getByMessageThreadExternalIds( + [messageThreadExternalId], + workspaceId, + ); + + if ( + !existingMessageChannelMessageAssociations || + existingMessageChannelMessageAssociations.length === 0 + ) { + return null; + } + + return existingMessageChannelMessageAssociations[0]; + } + + public async getByMessageIds( + messageIds: string[], + workspaceId: string, + ): Promise[]> { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + return await workspaceDataSource?.query( + `SELECT * FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" + WHERE "messageId" = ANY($1)`, + [messageIds], + ); + } +} diff --git a/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.module.ts b/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.module.ts new file mode 100644 index 00000000000..ef8d77674b8 --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { MessageChannelService } from 'src/workspace/messaging/message-channel/message-channel.service'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; + +@Module({ + imports: [WorkspaceDataSourceModule], + providers: [MessageChannelService], + exports: [MessageChannelService], +}) +export class MessageChannelModule {} diff --git a/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.service.ts b/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.service.ts new file mode 100644 index 00000000000..0749f1ce62b --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message-channel/message-channel.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; +import { MessageChannelObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/message-channel.object-metadata'; +import { ObjectRecord } from 'src/workspace/workspace-sync-metadata/types/object-record'; + +@Injectable() +export class MessageChannelService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + public async getByConnectedAccountId( + workspaceId: string, + connectedAccountId: string, + ): Promise[]> { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + return await workspaceDataSource?.query( + `SELECT * FROM ${dataSourceMetadata.schema}."messageChannel" WHERE "connectedAccountId" = $1 AND "type" = 'email' LIMIT 1`, + [connectedAccountId], + ); + } + + public async getFirstByConnectedAccountIdOrFail( + workspaceId: string, + connectedAccountId: string, + ): Promise> { + const messageChannels = await this.getByConnectedAccountId( + workspaceId, + connectedAccountId, + ); + + if (!messageChannels || messageChannels.length === 0) { + throw new Error('No message channel found'); + } + + return messageChannels[0]; + } +} diff --git a/packages/twenty-server/src/workspace/messaging/message/message.module.ts b/packages/twenty-server/src/workspace/messaging/message/message.module.ts new file mode 100644 index 00000000000..b9380587f6b --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message/message.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { MessageService } from 'src/workspace/messaging/message/message.service'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; + +@Module({ + imports: [WorkspaceDataSourceModule], + providers: [MessageService], + exports: [MessageService], +}) +export class MessageModule {} diff --git a/packages/twenty-server/src/workspace/messaging/message/message.service.ts b/packages/twenty-server/src/workspace/messaging/message/message.service.ts new file mode 100644 index 00000000000..d5a444d268a --- /dev/null +++ b/packages/twenty-server/src/workspace/messaging/message/message.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; +import { MessageObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/message.object-metadata'; +import { ObjectRecord } from 'src/workspace/workspace-sync-metadata/types/object-record'; + +@Injectable() +export class MessageService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + ) {} + + public async getFirstByHeaderMessageId( + workspaceId: string, + headerMessageId: string, + ): Promise | null> { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + const messages = await workspaceDataSource?.query( + `SELECT * FROM ${dataSourceMetadata.schema}."message" WHERE "headerMessageId" = $1 LIMIT 1`, + [headerMessageId], + ); + + if (!messages || messages.length === 0) { + return null; + } + + return messages[0]; + } + + public async getByIds( + workspaceId: string, + messageIds: string[], + ): Promise[]> { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + return await workspaceDataSource?.query( + `SELECT * FROM ${dataSourceMetadata.schema}."message" WHERE "id" = ANY($1)`, + [messageIds], + ); + } + + public async deleteByIds( + workspaceId: string, + messageIds: string[], + ): Promise { + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); + + await workspaceDataSource?.query( + `DELETE FROM ${dataSourceMetadata.schema}."message" WHERE "id" = ANY($1)`, + [messageIds], + ); + } +} diff --git a/packages/twenty-server/src/workspace/messaging/services/fetch-messages-by-batches.service.ts b/packages/twenty-server/src/workspace/messaging/services/fetch-messages-by-batches.service.ts index c7768682601..66006a906da 100644 --- a/packages/twenty-server/src/workspace/messaging/services/fetch-messages-by-batches.service.ts +++ b/packages/twenty-server/src/workspace/messaging/services/fetch-messages-by-batches.service.ts @@ -7,9 +7,9 @@ import planer from 'planer'; import { GmailMessage, Participant, -} from 'src/workspace/messaging/types/gmailMessage'; -import { MessageQuery } from 'src/workspace/messaging/types/messageOrThreadQuery'; -import { GmailMessageParsedResponse } from 'src/workspace/messaging/types/gmailMessageParsedResponse'; +} from 'src/workspace/messaging/types/gmail-message'; +import { MessageQuery } from 'src/workspace/messaging/types/message-or-thread-query'; +import { GmailMessageParsedResponse } from 'src/workspace/messaging/types/gmail-message-parsed-response'; @Injectable() export class FetchMessagesByBatchesService { diff --git a/packages/twenty-server/src/workspace/messaging/services/fetch-workspace-messages.module.ts b/packages/twenty-server/src/workspace/messaging/services/fetch-workspace-messages.module.ts index 7238712fa41..92994bd4aff 100644 --- a/packages/twenty-server/src/workspace/messaging/services/fetch-workspace-messages.module.ts +++ b/packages/twenty-server/src/workspace/messaging/services/fetch-workspace-messages.module.ts @@ -1,17 +1,27 @@ import { Module } from '@nestjs/common'; -import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { EnvironmentModule } from 'src/integrations/environment/environment.module'; -import { DataSourceModule } from 'src/metadata/data-source/data-source.module'; +import { ConnectedAccountModule } from 'src/workspace/messaging/connected-account/connected-account.module'; +import { MessageChannelMessageAssociationModule } from 'src/workspace/messaging/message-channel-message-association/message-channel-message-assocation.module'; +import { MessageChannelModule } from 'src/workspace/messaging/message-channel/message-channel.module'; +import { MessageModule } from 'src/workspace/messaging/message/message.module'; import { GmailClientProvider } from 'src/workspace/messaging/providers/gmail/gmail-client.provider'; import { FetchMessagesByBatchesService } from 'src/workspace/messaging/services/fetch-messages-by-batches.service'; import { GmailFullSyncService } from 'src/workspace/messaging/services/gmail-full-sync.service'; import { GmailPartialSyncService } from 'src/workspace/messaging/services/gmail-partial-sync.service'; import { GmailRefreshAccessTokenService } from 'src/workspace/messaging/services/gmail-refresh-access-token.service'; import { MessagingUtilsService } from 'src/workspace/messaging/services/messaging-utils.service'; +import { WorkspaceDataSourceModule } from 'src/workspace/workspace-datasource/workspace-datasource.module'; @Module({ - imports: [TypeORMModule, DataSourceModule, EnvironmentModule], + imports: [ + EnvironmentModule, + WorkspaceDataSourceModule, + ConnectedAccountModule, + MessageChannelModule, + MessageChannelMessageAssociationModule, + MessageModule, + ], providers: [ GmailFullSyncService, GmailPartialSyncService, diff --git a/packages/twenty-server/src/workspace/messaging/services/gmail-full-sync.service.ts b/packages/twenty-server/src/workspace/messaging/services/gmail-full-sync.service.ts index bfdf62f22f6..816c5fec72e 100644 --- a/packages/twenty-server/src/workspace/messaging/services/gmail-full-sync.service.ts +++ b/packages/twenty-server/src/workspace/messaging/services/gmail-full-sync.service.ts @@ -9,6 +9,10 @@ import { GmailFullSyncJobData, GmailFullSyncJob, } from 'src/workspace/messaging/jobs/gmail-full-sync.job'; +import { ConnectedAccountService } from 'src/workspace/messaging/connected-account/connected-account.service'; +import { MessageChannelService } from 'src/workspace/messaging/message-channel/message-channel.service'; +import { MessageChannelMessageAssociationService } from 'src/workspace/messaging/message-channel-message-association/message-channel-message-association.service'; +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; @Injectable() export class GmailFullSyncService { @@ -18,6 +22,10 @@ export class GmailFullSyncService { private readonly utils: MessagingUtilsService, @Inject(MessageQueue.messagingQueue) private readonly messageQueueService: MessageQueueService, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + private readonly connectedAccountService: ConnectedAccountService, + private readonly messageChannelService: MessageChannelService, + private readonly messageChannelMessageAssociationService: MessageChannelMessageAssociationService, ) {} public async fetchConnectedAccountThreads( @@ -25,13 +33,14 @@ export class GmailFullSyncService { connectedAccountId: string, nextPageToken?: string, ): Promise { - const { workspaceDataSource, dataSourceMetadata } = - await this.utils.getDataSourceMetadataWorkspaceMetadata(workspaceId); + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); - const connectedAccount = await this.utils.getConnectedAcountByIdOrFail( + const connectedAccount = await this.connectedAccountService.getByIdOrFail( connectedAccountId, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); const accessToken = connectedAccount.accessToken; @@ -41,18 +50,13 @@ export class GmailFullSyncService { throw new Error('No refresh token found'); } - const gmailMessageChannel = await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."messageChannel" WHERE "connectedAccountId" = $1 AND "type" = 'email' LIMIT 1`, - [connectedAccountId], - ); - - if (!gmailMessageChannel.length) { - throw new Error( - `No gmail message channel found for connected account ${connectedAccountId}`, + const gmailMessageChannel = + await this.messageChannelService.getFirstByConnectedAccountIdOrFail( + workspaceId, + connectedAccountId, ); - } - const gmailMessageChannelId = gmailMessageChannel[0].id; + const gmailMessageChannelId = gmailMessageChannel.id; const gmailClient = await this.gmailClientProvider.getGmailClient(refreshToken); @@ -74,11 +78,10 @@ export class GmailFullSyncService { } const existingMessageChannelMessageAssociations = - await this.utils.getMessageChannelMessageAssociations( + await this.messageChannelMessageAssociationService.getByMessageExternalIdsAndMessageChannelId( messageExternalIds, gmailMessageChannelId, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); const existingMessageChannelMessageAssociationsExternalIds = @@ -113,6 +116,7 @@ export class GmailFullSyncService { workspaceDataSource, connectedAccount, gmailMessageChannelId, + workspaceId, ); if (errors.length) throw new Error('Error fetching messages'); @@ -125,11 +129,10 @@ export class GmailFullSyncService { if (!historyId) throw new Error('No history id found'); - await this.utils.saveLastSyncHistoryId( + await this.connectedAccountService.saveLastSyncHistoryId( historyId, connectedAccount.id, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); if (messages.data.nextPageToken) { diff --git a/packages/twenty-server/src/workspace/messaging/services/gmail-partial-sync.service.ts b/packages/twenty-server/src/workspace/messaging/services/gmail-partial-sync.service.ts index 1f1c1f29fca..f816c0e10be 100644 --- a/packages/twenty-server/src/workspace/messaging/services/gmail-partial-sync.service.ts +++ b/packages/twenty-server/src/workspace/messaging/services/gmail-partial-sync.service.ts @@ -11,6 +11,9 @@ import { GmailFullSyncJob, GmailFullSyncJobData, } from 'src/workspace/messaging/jobs/gmail-full-sync.job'; +import { ConnectedAccountService } from 'src/workspace/messaging/connected-account/connected-account.service'; +import { MessageChannelService } from 'src/workspace/messaging/message-channel/message-channel.service'; +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; @Injectable() export class GmailPartialSyncService { @@ -20,21 +23,20 @@ export class GmailPartialSyncService { private readonly utils: MessagingUtilsService, @Inject(MessageQueue.messagingQueue) private readonly messageQueueService: MessageQueueService, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + private readonly connectedAccountService: ConnectedAccountService, + private readonly messageChannelService: MessageChannelService, ) {} - private async getHistory( + private async getHistoryFromGmail( workspaceId: string, connectedAccountId: string, lastSyncHistoryId: string, maxResults: number, ) { - const { workspaceDataSource, dataSourceMetadata } = - await this.utils.getDataSourceMetadataWorkspaceMetadata(workspaceId); - - const connectedAccount = await this.utils.getConnectedAcountByIdOrFail( + const connectedAccount = await this.connectedAccountService.getByIdOrFail( connectedAccountId, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); const gmailClient = await this.gmailClientProvider.getGmailClient( @@ -56,13 +58,14 @@ export class GmailPartialSyncService { connectedAccountId: string, maxResults = 500, ): Promise { - const { workspaceDataSource, dataSourceMetadata } = - await this.utils.getDataSourceMetadataWorkspaceMetadata(workspaceId); + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId, + ); - const connectedAccount = await this.utils.getConnectedAcountByIdOrFail( + const connectedAccount = await this.connectedAccountService.getByIdOrFail( connectedAccountId, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); const lastSyncHistoryId = connectedAccount.lastSyncHistoryId; @@ -88,7 +91,7 @@ export class GmailPartialSyncService { throw new Error('No refresh token found'); } - const history = await this.getHistory( + const history = await this.getHistoryFromGmail( workspaceId, connectedAccountId, lastSyncHistoryId, @@ -106,28 +109,22 @@ export class GmailPartialSyncService { } if (!history.history) { - await this.utils.saveLastSyncHistoryId( + await this.connectedAccountService.saveLastSyncHistoryId( historyId, connectedAccountId, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); return; } - const gmailMessageChannel = await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."messageChannel" WHERE "connectedAccountId" = $1 AND "type" = 'email' LIMIT 1`, - [connectedAccountId], - ); - - if (!gmailMessageChannel.length) { - throw new Error( - `No gmail message channel found for connected account ${connectedAccountId}`, + const gmailMessageChannel = + await this.messageChannelService.getFirstByConnectedAccountIdOrFail( + workspaceId, + connectedAccountId, ); - } - const gmailMessageChannelId = gmailMessageChannel[0].id; + const gmailMessageChannelId = gmailMessageChannel.id; const { messagesAdded, messagesDeleted } = await this.getMessageIdsFromHistory(history); @@ -147,22 +144,21 @@ export class GmailPartialSyncService { workspaceDataSource, connectedAccount, gmailMessageChannelId, + workspaceId, ); - await this.utils.deleteMessageChannelMessageAssociations( + await this.utils.deleteMessages( messagesDeleted, gmailMessageChannelId, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); if (errors.length) throw new Error('Error fetching messages'); - await this.utils.saveLastSyncHistoryId( + await this.connectedAccountService.saveLastSyncHistoryId( historyId, connectedAccount.id, - dataSourceMetadata, - workspaceDataSource, + workspaceId, ); } diff --git a/packages/twenty-server/src/workspace/messaging/services/gmail-refresh-access-token.service.ts b/packages/twenty-server/src/workspace/messaging/services/gmail-refresh-access-token.service.ts index cd08d00cbc0..faca5245d51 100644 --- a/packages/twenty-server/src/workspace/messaging/services/gmail-refresh-access-token.service.ts +++ b/packages/twenty-server/src/workspace/messaging/services/gmail-refresh-access-token.service.ts @@ -2,30 +2,25 @@ import { Injectable } from '@nestjs/common'; import axios from 'axios'; -import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; -import { DataSourceService } from 'src/metadata/data-source/data-source.service'; +import { WorkspaceDataSourceService } from 'src/workspace/workspace-datasource/workspace-datasource.service'; @Injectable() export class GmailRefreshAccessTokenService { constructor( private readonly environmentService: EnvironmentService, - private readonly dataSourceService: DataSourceService, - private readonly typeORMService: TypeORMService, + private readonly workspaceDataSourceService: WorkspaceDataSourceService, ) {} async refreshAndSaveAccessToken( workspaceId: string, connectedAccountId: string, ): Promise { - const dataSourceMetadata = - await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + const { dataSource: workspaceDataSource, dataSourceMetadata } = + await this.workspaceDataSourceService.connectedToWorkspaceDataSourceAndReturnMetadata( workspaceId, ); - const workspaceDataSource = - await this.typeORMService.connectToDataSource(dataSourceMetadata); - if (!workspaceDataSource) { throw new Error('No workspace data source found'); } diff --git a/packages/twenty-server/src/workspace/messaging/services/messaging-utils.service.ts b/packages/twenty-server/src/workspace/messaging/services/messaging-utils.service.ts index 722414a14b7..bc92959b10a 100644 --- a/packages/twenty-server/src/workspace/messaging/services/messaging-utils.service.ts +++ b/packages/twenty-server/src/workspace/messaging/services/messaging-utils.service.ts @@ -3,20 +3,20 @@ import { Injectable } from '@nestjs/common'; import { EntityManager, DataSource } from 'typeorm'; import { v4 } from 'uuid'; -import { TypeORMService } from 'src/database/typeorm/typeorm.service'; -import { DataSourceService } from 'src/metadata/data-source/data-source.service'; import { DataSourceEntity } from 'src/metadata/data-source/data-source.entity'; import { GmailMessage, Participant, -} from 'src/workspace/messaging/types/gmailMessage'; -import { MessageQuery } from 'src/workspace/messaging/types/messageOrThreadQuery'; +} from 'src/workspace/messaging/types/gmail-message'; +import { MessageQuery } from 'src/workspace/messaging/types/message-or-thread-query'; +import { MessageChannelMessageAssociationService } from 'src/workspace/messaging/message-channel-message-association/message-channel-message-association.service'; +import { MessageService } from 'src/workspace/messaging/message/message.service'; @Injectable() export class MessagingUtilsService { constructor( - private readonly dataSourceService: DataSourceService, - private readonly typeORMService: TypeORMService, + private readonly messageChannelMessageAssociationService: MessageChannelMessageAssociationService, + private readonly messageService: MessageService, ) {} public createQueriesFromMessageIds( @@ -33,16 +33,18 @@ export class MessagingUtilsService { workspaceDataSource: DataSource, connectedAccount, gmailMessageChannelId: string, + workspaceId: string, ) { for (const message of messages) { await workspaceDataSource?.transaction(async (manager) => { - const existingMessageChannelMessageAssociations = await manager.query( - `SELECT COUNT(*) FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" - WHERE "messageExternalId" = $1 AND "messageChannelId" = $2`, - [message.externalId, gmailMessageChannelId], - ); + const existingMessageChannelMessageAssociationsCount = + await this.messageChannelMessageAssociationService.countByMessageExternalIdsAndMessageChannelId( + [message.externalId], + gmailMessageChannelId, + workspaceId, + ); - if (existingMessageChannelMessageAssociations[0]?.count > 0) { + if (existingMessageChannelMessageAssociationsCount > 0) { return; } @@ -50,7 +52,8 @@ export class MessagingUtilsService { await this.saveMessageThreadOrReturnExistingMessageThread( message.messageThreadExternalId, dataSourceMetadata, - workspaceDataSource, + manager, + workspaceId, ); const savedOrExistingMessageId = @@ -60,6 +63,7 @@ export class MessagingUtilsService { connectedAccount, dataSourceMetadata, manager, + workspaceId, ); await manager.query( @@ -82,12 +86,13 @@ export class MessagingUtilsService { connectedAccount, dataSourceMetadata: DataSourceEntity, manager: EntityManager, + workspaceId: string, ): Promise { - const existingMessages = await manager.query( - `SELECT "message"."id" FROM ${dataSourceMetadata.schema}."message" WHERE ${dataSourceMetadata.schema}."message"."headerMessageId" = $1 LIMIT 1`, - [message.headerMessageId], + const existingMessage = await this.messageService.getFirstByHeaderMessageId( + workspaceId, + message.headerMessageId, ); - const existingMessageId: string = existingMessages[0]?.id; + const existingMessageId = existingMessage?.id; if (existingMessageId) { return Promise.resolve(existingMessageId); @@ -127,14 +132,17 @@ export class MessagingUtilsService { private async saveMessageThreadOrReturnExistingMessageThread( messageThreadExternalId: string, dataSourceMetadata: DataSourceEntity, - workspaceDataSource: DataSource, + manager: EntityManager, + workspaceId: string, ) { - const existingMessageThreads = await workspaceDataSource?.query( - `SELECT "messageChannelMessageAssociation"."messageThreadId" FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" WHERE "messageThreadExternalId" = $1 LIMIT 1`, - [messageThreadExternalId], - ); + const existingMessageChannelMessageAssociationByMessageThreadExternalId = + await this.messageChannelMessageAssociationService.getFirstByMessageThreadExternalId( + messageThreadExternalId, + workspaceId, + ); - const existingMessageThread = existingMessageThreads[0]?.messageThreadId; + const existingMessageThread = + existingMessageChannelMessageAssociationByMessageThreadExternalId?.messageThreadId; if (existingMessageThread) { return Promise.resolve(existingMessageThread); @@ -142,7 +150,7 @@ export class MessagingUtilsService { const newMessageThreadId = v4(); - await workspaceDataSource?.query( + await manager.query( `INSERT INTO ${dataSourceMetadata.schema}."messageThread" ("id") VALUES ($1)`, [newMessageThreadId], ); @@ -190,95 +198,50 @@ export class MessagingUtilsService { } } - public async deleteMessageChannelMessageAssociations( - messageExternalIds: string[], - connectedAccountId: string, - dataSourceMetadata: DataSourceEntity, - workspaceDataSource: DataSource, - ) { - await workspaceDataSource?.query( - `DELETE FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" WHERE "messageExternalId" = ANY($1) AND "messageChannelId" = $2`, - [messageExternalIds, connectedAccountId], - ); - } - - public async getConnectedAccounts( - dataSourceMetadata: DataSourceEntity, - workspaceDataSource: DataSource, - ): Promise { - const connectedAccounts = await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."connectedAccount" WHERE "provider" = 'google'`, - ); - - return connectedAccounts; - } - - public async getConnectedAcountByIdOrFail( - connectedAccountId: string, - dataSourceMetadata: DataSourceEntity, - workspaceDataSource: DataSource, - ): Promise { - const connectedAccounts = await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."connectedAccount" WHERE "id" = $1`, - [connectedAccountId], - ); - - if (!connectedAccounts || connectedAccounts.length === 0) { - throw new Error('No connected account found'); - } - - return connectedAccounts[0]; - } - - public async getDataSourceMetadataWorkspaceMetadata( + public async deleteMessages( + messagesDeleted: string[], + gmailMessageChannelId: string, workspaceId: string, - ): Promise<{ - dataSourceMetadata: DataSourceEntity; - workspaceDataSource: DataSource; - }> { - const dataSourceMetadata = - await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( + ) { + const messageChannelMessageAssociationsToDelete = + await this.messageChannelMessageAssociationService.getByMessageExternalIdsAndMessageChannelId( + messagesDeleted, + gmailMessageChannelId, workspaceId, ); - const workspaceDataSource = - await this.typeORMService.connectToDataSource(dataSourceMetadata); - - if (!workspaceDataSource) { - throw new Error('No workspace data source found'); - } - - return { - dataSourceMetadata, - workspaceDataSource, - }; - } - - public async saveLastSyncHistoryId( - historyId: string, - connectedAccountId: string, - dataSourceMetadata: DataSourceEntity, - workspaceDataSource: DataSource, - ) { - await workspaceDataSource?.query( - `UPDATE ${dataSourceMetadata.schema}."connectedAccount" SET "lastSyncHistoryId" = $1 WHERE "id" = $2`, - [historyId, connectedAccountId], - ); - } - - public async getMessageChannelMessageAssociations( - messageExternalIds: string[], - gmailMessageChannelId: string, - dataSourceMetadata: DataSourceEntity, - workspaceDataSource: DataSource, - ) { - const existingMessageChannelMessageAssociation = - await workspaceDataSource?.query( - `SELECT * FROM ${dataSourceMetadata.schema}."messageChannelMessageAssociation" - WHERE "messageExternalId" = ANY($1) AND "messageChannelId" = $2`, - [messageExternalIds, gmailMessageChannelId], + const messageIdsFromMessageChannelMessageAssociationsToDelete = + messageChannelMessageAssociationsToDelete.map( + (messageChannelMessageAssociationToDelete) => + messageChannelMessageAssociationToDelete.messageId, ); - return existingMessageChannelMessageAssociation; + await this.messageChannelMessageAssociationService.deleteByMessageExternalIdsAndMessageChannelId( + messagesDeleted, + gmailMessageChannelId, + workspaceId, + ); + + const messageChannelMessageAssociationByMessageIds = + await this.messageChannelMessageAssociationService.getByMessageIds( + messageIdsFromMessageChannelMessageAssociationsToDelete, + workspaceId, + ); + + const messageIdsFromMessageChannelMessageAssociationByMessageIds = + messageChannelMessageAssociationByMessageIds.map( + (messageChannelMessageAssociation) => + messageChannelMessageAssociation.messageId, + ); + + const messageIdsToDelete = + messageIdsFromMessageChannelMessageAssociationsToDelete.filter( + (messageId) => + !messageIdsFromMessageChannelMessageAssociationByMessageIds.includes( + messageId, + ), + ); + + await this.messageService.deleteByIds(workspaceId, messageIdsToDelete); } } diff --git a/packages/twenty-server/src/workspace/messaging/types/gmailMessageParsedResponse.ts b/packages/twenty-server/src/workspace/messaging/types/gmail-message-parsed-response.ts similarity index 100% rename from packages/twenty-server/src/workspace/messaging/types/gmailMessageParsedResponse.ts rename to packages/twenty-server/src/workspace/messaging/types/gmail-message-parsed-response.ts diff --git a/packages/twenty-server/src/workspace/messaging/types/gmailMessage.ts b/packages/twenty-server/src/workspace/messaging/types/gmail-message.ts similarity index 100% rename from packages/twenty-server/src/workspace/messaging/types/gmailMessage.ts rename to packages/twenty-server/src/workspace/messaging/types/gmail-message.ts diff --git a/packages/twenty-server/src/workspace/messaging/types/gmailThread.ts b/packages/twenty-server/src/workspace/messaging/types/gmail-thread.ts similarity index 100% rename from packages/twenty-server/src/workspace/messaging/types/gmailThread.ts rename to packages/twenty-server/src/workspace/messaging/types/gmail-thread.ts diff --git a/packages/twenty-server/src/workspace/messaging/types/messageOrThreadQuery.ts b/packages/twenty-server/src/workspace/messaging/types/message-or-thread-query.ts similarity index 100% rename from packages/twenty-server/src/workspace/messaging/types/messageOrThreadQuery.ts rename to packages/twenty-server/src/workspace/messaging/types/message-or-thread-query.ts diff --git a/packages/twenty-server/src/workspace/workspace-datasource/workspace-datasource.service.ts b/packages/twenty-server/src/workspace/workspace-datasource/workspace-datasource.service.ts index 8d78c5bdf4d..5bc48263f9c 100644 --- a/packages/twenty-server/src/workspace/workspace-datasource/workspace-datasource.service.ts +++ b/packages/twenty-server/src/workspace/workspace-datasource/workspace-datasource.service.ts @@ -22,6 +22,15 @@ export class WorkspaceDataSourceService { public async connectToWorkspaceDataSource( workspaceId: string, ): Promise { + const { dataSource } = + await this.connectedToWorkspaceDataSourceAndReturnMetadata(workspaceId); + + return dataSource; + } + + public async connectedToWorkspaceDataSourceAndReturnMetadata( + workspaceId: string, + ) { const dataSourceMetadata = await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail( workspaceId, @@ -36,7 +45,7 @@ export class WorkspaceDataSourceService { ); } - return dataSource; + return { dataSource, dataSourceMetadata }; } /** diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/types/object-record.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/types/object-record.ts new file mode 100644 index 00000000000..df388ea0329 --- /dev/null +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/types/object-record.ts @@ -0,0 +1,10 @@ +import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata'; + +// Note: This is actually not enterely correct, id field should only be added if the relation is MANY_TO_ONE or ONE_TO_ONE +export type ObjectRecord = { + [K in keyof T as T[K] extends BaseObjectMetadata + ? `${Extract}Id` + : K]: T[K] extends BaseObjectMetadata ? string : T[K]; +} & { + [K in keyof T]: T[K] extends BaseObjectMetadata ? ObjectRecord : T[K]; +}; From 2e4f2d54aa4f1276586040462e6f5102ce751b39 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Tue, 30 Jan 2024 18:38:31 +0100 Subject: [PATCH 13/54] Refactor board and table options (#3700) * Refactor board and table options * Fix * Fix --- .../components/RecordBoardColumn.tsx | 1 - .../components/RecordIndexContainer.tsx | 30 +--- .../components/RecordIndexOptionsDropdown.tsx | 38 +++++ .../RecordIndexOptionsDropdownButton.tsx} | 6 +- .../RecordIndexOptionsDropdownContent.tsx} | 132 ++++++++------- .../RecordIndexBoardOptionsDropdownId.ts | 2 + .../constants/RecordIndexOptionsDropdownId.ts | 2 + .../hooks/useRecordIndexOptionsForBoard.ts | 156 ++++++++++++++++++ .../hooks/useRecordIndexOptionsForTable.ts | 44 +++++ .../hooks/useRecordIndexOptionsImport.ts | 23 +++ .../constants/TableOptionsDropdownId.ts | 2 - .../components/TableOptionsDropdown.tsx | 34 ---- .../SignInBackgroundMockContainer.tsx | 19 ++- 13 files changed, 358 insertions(+), 131 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx rename packages/twenty-front/src/modules/object-record/{record-table/options/components/TableOptionsDropdownButton.tsx => record-index/options/components/RecordIndexOptionsDropdownButton.tsx} (64%) rename packages/twenty-front/src/modules/object-record/{record-table/options/components/TableOptionsDropdownContent.tsx => record-index/options/components/RecordIndexOptionsDropdownContent.tsx} (54%) create mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexBoardOptionsDropdownId.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexOptionsDropdownId.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsImport.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/constants/TableOptionsDropdownId.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdown.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx index 769f261ddcf..70660329bdc 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx @@ -14,7 +14,6 @@ const StyledColumn = styled.div<{ isFirstColumn: boolean }>` isFirstColumn ? 'none' : theme.border.color.light}; display: flex; flex-direction: column; - height: fit-content; max-width: 200px; min-width: 200px; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 9c35cccadc2..1cbe6f6c581 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import styled from '@emotion/styled'; import { useSetRecoilState } from 'recoil'; -import { useSpreadsheetCompanyImport } from '@/companies/hooks/useSpreadsheetCompanyImport'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; @@ -11,13 +10,12 @@ import { RecordIndexBoardContainerEffect } from '@/object-record/record-index/co import { RecordIndexTableContainer } from '@/object-record/record-index/components/RecordIndexTableContainer'; import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect'; import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect'; +import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown'; +import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; -import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; -import { TableOptionsDropdown } from '@/object-record/record-table/options/components/TableOptionsDropdown'; -import { useSpreadsheetPersonImport } from '@/people/hooks/useSpreadsheetPersonImport'; import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider'; import { ViewBar } from '@/views/components/ViewBar'; import { ViewType } from '@/views/types/ViewType'; @@ -66,37 +64,23 @@ export const RecordIndexContainer = ({ const setRecordIndexFilters = useSetRecoilState(recordIndexFiltersState); const setRecordIndexSorts = useSetRecoilState(recordIndexSortsState); - const { openPersonSpreadsheetImport } = useSpreadsheetPersonImport(); - const { openCompanySpreadsheetImport } = useSpreadsheetCompanyImport(); - const { setTableFilters, setTableSorts, setTableColumns } = useRecordTable({ recordTableId: recordIndexId, }); - const handleImport = () => { - const openImport = - objectNamePlural === 'companies' - ? openCompanySpreadsheetImport - : openPersonSpreadsheetImport; - openImport(); - }; - return ( } - optionsDropdownScopeId={TableOptionsDropdownId} + optionsDropdownScopeId={RECORD_INDEX_OPTIONS_DROPDOWN_ID} onViewFieldsChange={(viewFields) => { setTableColumns( mapViewFieldsToColumnDefinitions(viewFields, columnDefinitions), diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx new file mode 100644 index 00000000000..8d420f42cd7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx @@ -0,0 +1,38 @@ +import { RecordIndexOptionsDropdownButton } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownButton'; +import { RecordIndexOptionsDropdownContent } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownContent'; +import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; +import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useViewBar } from '@/views/hooks/useViewBar'; +import { ViewType } from '@/views/types/ViewType'; + +type RecordIndexOptionsDropdownProps = { + viewType: ViewType; + objectNameSingular: string; + recordIndexId: string; +}; + +export const RecordIndexOptionsDropdown = ({ + recordIndexId, + objectNameSingular, + viewType, +}: RecordIndexOptionsDropdownProps) => { + const { setViewEditMode } = useViewBar(); + + return ( + } + dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }} + dropdownOffset={{ y: 8 }} + dropdownComponents={ + + } + onClickOutside={() => setViewEditMode('none')} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownButton.tsx similarity index 64% rename from packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownButton.tsx rename to packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownButton.tsx index 11736f506ad..6c47f815fd2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownButton.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownButton.tsx @@ -1,10 +1,10 @@ -import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId'; +import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -export const TableOptionsDropdownButton = () => { +export const RecordIndexOptionsDropdownButton = () => { const { isDropdownOpen, toggleDropdown } = useDropdown( - TableOptionsDropdownId, + RECORD_INDEX_OPTIONS_DROPDOWN_ID, ); return ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx similarity index 54% rename from packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownContent.tsx rename to packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx index 2cfe4c81125..8d130860473 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx @@ -1,10 +1,12 @@ -import { useCallback, useRef, useState } from 'react'; -import { OnDragEndResponder } from '@hello-pangea/dnd'; +import { useRef, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { Key } from 'ts-key-enum'; -import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; +import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard'; +import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable'; +import { useRecordIndexOptionsImport } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsImport'; +import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; import { IconChevronLeft, IconFileImport, IconTag } from '@/ui/display/icon'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput'; @@ -16,69 +18,42 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates'; import { useViewBar } from '@/views/hooks/useViewBar'; +import { ViewType } from '@/views/types/ViewType'; -import { useTableColumns } from '../../hooks/useTableColumns'; -import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope'; +type RecordIndexOptionsMenu = 'fields'; -type TableOptionsMenu = 'fields'; +type RecordIndexOptionsDropdownContentProps = { + recordIndexId: string; + objectNameSingular: string; + viewType: ViewType; +}; -export const TableOptionsDropdownContent = ({ - onImport, - recordTableId, -}: { - onImport?: () => void; - recordTableId: string; -}) => { - const { setViewEditMode, handleViewNameSubmit } = useViewBar(); +export const RecordIndexOptionsDropdownContent = ({ + viewType, + recordIndexId, + objectNameSingular, +}: RecordIndexOptionsDropdownContentProps) => { + const { setViewEditMode, handleViewNameSubmit } = useViewBar({ + viewBarId: recordIndexId, + }); const { viewEditModeState, currentViewSelector } = useViewScopedStates(); const viewEditMode = useRecoilValue(viewEditModeState); const currentView = useRecoilValue(currentViewSelector); - const { closeDropdown } = useDropdown(TableOptionsDropdownId); + const { closeDropdown } = useDropdown(RECORD_INDEX_OPTIONS_DROPDOWN_ID); - const [currentMenu, setCurrentMenu] = useState( - undefined, - ); + const [currentMenu, setCurrentMenu] = useState< + RecordIndexOptionsMenu | undefined + >(undefined); + + const resetMenu = () => setCurrentMenu(undefined); const viewEditInputRef = useRef(null); - const { getHiddenTableColumnsSelector, getVisibleTableColumnsSelector } = - useRecordTableStates(recordTableId); - - const hiddenTableColumns = useRecoilValue(getHiddenTableColumnsSelector()); - const visibleTableColumns = useRecoilValue(getVisibleTableColumnsSelector()); - - const { handleColumnVisibilityChange, handleColumnReorder } = useTableColumns( - { recordTableId }, - ); - - const handleSelectMenu = (option: TableOptionsMenu) => { - const name = viewEditInputRef.current?.value; - handleViewNameSubmit(name); + const handleSelectMenu = (option: RecordIndexOptionsMenu) => { setCurrentMenu(option); }; - const handleReorderField: OnDragEndResponder = useCallback( - (result) => { - if ( - !result.destination || - result.destination.index === 1 || - result.source.index === 1 - ) { - return; - } - - const reorderFields = [...visibleTableColumns]; - const [removed] = reorderFields.splice(result.source.index - 1, 1); - reorderFields.splice(result.destination.index - 1, 0, removed); - - handleColumnReorder(reorderFields); - }, - [visibleTableColumns, handleColumnReorder], - ); - - const resetMenu = () => setCurrentMenu(undefined); - useScopedHotkeys( [Key.Escape], () => { @@ -99,6 +74,41 @@ export const TableOptionsDropdownContent = ({ TableOptionsHotkeyScope.Dropdown, ); + const { + handleColumnVisibilityChange, + handleReorderColumns, + visibleTableColumns, + hiddenTableColumns, + } = useRecordIndexOptionsForTable(recordIndexId); + + const { + visibleBoardFields, + hiddenBoardFields, + handleReorderBoardFields, + handleBoardFieldVisibilityChange, + } = useRecordIndexOptionsForBoard({ + objectNameSingular, + viewBarId: recordIndexId, + }); + + const visibleRecordFields = + viewType === ViewType.Kanban ? visibleBoardFields : visibleTableColumns; + + const hiddenRecordFields = + viewType === ViewType.Kanban ? hiddenBoardFields : hiddenTableColumns; + + const handleReorderFields = + viewType === ViewType.Kanban + ? handleReorderBoardFields + : handleReorderColumns; + + const handleChangeFieldVisibility = + viewType === ViewType.Kanban + ? handleBoardFieldVisibilityChange + : handleColumnVisibilityChange; + + const { handleImport } = useRecordIndexOptionsImport({ objectNameSingular }); + return ( <> {!currentMenu && ( @@ -122,9 +132,9 @@ export const TableOptionsDropdownContent = ({ LeftIcon={IconTag} text="Fields" /> - {onImport && ( + {handleImport && ( handleImport()} LeftIcon={IconFileImport} text="Import" /> @@ -140,20 +150,20 @@ export const TableOptionsDropdownContent = ({ - {hiddenTableColumns.length > 0 && ( + {hiddenRecordFields.length > 0 && ( <> diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexBoardOptionsDropdownId.ts b/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexBoardOptionsDropdownId.ts new file mode 100644 index 00000000000..2ccb636a130 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexBoardOptionsDropdownId.ts @@ -0,0 +1,2 @@ +export const RECORD_INDEX_BOARD_OPTIONS_DROPDOWN_ID = + 'record-index-table-options-dropdown-id'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexOptionsDropdownId.ts b/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexOptionsDropdownId.ts new file mode 100644 index 00000000000..ec21fa258b8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/constants/RecordIndexOptionsDropdownId.ts @@ -0,0 +1,2 @@ +export const RECORD_INDEX_OPTIONS_DROPDOWN_ID = + 'record-index-options-dropdown-id'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts new file mode 100644 index 00000000000..040403db745 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts @@ -0,0 +1,156 @@ +import { useCallback, useMemo } from 'react'; +import { OnDragEndResponder } from '@hello-pangea/dnd'; +import { useRecoilState } from 'recoil'; + +import { mapBoardFieldDefinitionsToViewFields } from '@/companies/utils/mapBoardFieldDefinitionsToViewFields'; +import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; +import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; +import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; +import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; +import { useViewFields } from '@/views/hooks/internal/useViewFields'; + +type useRecordIndexOptionsForBoardParams = { + objectNameSingular: string; + viewBarId: string; +}; + +export const useRecordIndexOptionsForBoard = ({ + objectNameSingular, + viewBarId, +}: useRecordIndexOptionsForBoardParams) => { + const [recordIndexFieldDefinitions, setRecordIndexFieldDefinitions] = + useRecoilState(recordIndexFieldDefinitionsState); + + const { persistViewFields } = useViewFields(viewBarId); + + const { objectMetadataItem } = useObjectMetadataItemOnly({ + objectNameSingular, + }); + + const { columnDefinitions } = + useColumnDefinitionsFromFieldMetadata(objectMetadataItem); + + const visibleBoardFields = useMemo( + () => + columnDefinitions.filter((columnDefinition) => { + return recordIndexFieldDefinitions.some( + (existingRecordFieldDefinition) => { + return ( + columnDefinition.fieldMetadataId === + existingRecordFieldDefinition.fieldMetadataId && + existingRecordFieldDefinition.isVisible + ); + }, + ); + }), + [columnDefinitions, recordIndexFieldDefinitions], + ); + + const hiddenBoardFields = useMemo( + () => + columnDefinitions.filter((columnDefinition) => { + return !recordIndexFieldDefinitions.some( + (existingRecordFieldDefinition) => { + return ( + columnDefinition.fieldMetadataId === + existingRecordFieldDefinition.fieldMetadataId && + existingRecordFieldDefinition.isVisible + ); + }, + ); + }), + [columnDefinitions, recordIndexFieldDefinitions], + ); + + const handleReorderBoardFields: OnDragEndResponder = useCallback( + (result) => { + if ( + !result.destination || + result.destination.index === 1 || + result.source.index === 1 + ) { + return; + } + + const reorderFields = [...recordIndexFieldDefinitions]; + const [removed] = reorderFields.splice(result.source.index - 1, 1); + reorderFields.splice(result.destination.index - 1, 0, removed); + + const updatedFields = reorderFields.map((field, index) => ({ + ...field, + position: index, + })); + + setRecordIndexFieldDefinitions(updatedFields); + persistViewFields(mapBoardFieldDefinitionsToViewFields(updatedFields)); + }, + [ + persistViewFields, + recordIndexFieldDefinitions, + setRecordIndexFieldDefinitions, + ], + ); + + // Todo : this seems over complex and should at least be extracted to an util with unit test. + // Let's refactor this as we introduce the new viewBar + const handleBoardFieldVisibilityChange = useCallback( + async ( + updatedFieldDefinition: Omit< + ColumnDefinition, + 'size' | 'position' + >, + ) => { + const isNewViewField = !recordIndexFieldDefinitions.some( + (fieldDefinition) => + fieldDefinition.fieldMetadataId === + updatedFieldDefinition.fieldMetadataId, + ); + + let updatedFieldsDefinitions: ColumnDefinition[]; + + if (isNewViewField) { + const correspondingFieldDefinition = columnDefinitions.find( + (availableTableColumn) => + availableTableColumn.fieldMetadataId === + updatedFieldDefinition.fieldMetadataId, + ); + if (!correspondingFieldDefinition) return; + + updatedFieldsDefinitions = [ + ...recordIndexFieldDefinitions, + { ...correspondingFieldDefinition, isVisible: true }, + ]; + } else { + updatedFieldsDefinitions = recordIndexFieldDefinitions.map( + (exitingFieldDefinition) => + exitingFieldDefinition.fieldMetadataId === + updatedFieldDefinition.fieldMetadataId + ? { + ...exitingFieldDefinition, + isVisible: !updatedFieldDefinition.isVisible, + } + : exitingFieldDefinition, + ); + } + + setRecordIndexFieldDefinitions(updatedFieldsDefinitions); + persistViewFields( + mapBoardFieldDefinitionsToViewFields(updatedFieldsDefinitions), + ); + }, + [ + recordIndexFieldDefinitions, + setRecordIndexFieldDefinitions, + persistViewFields, + columnDefinitions, + ], + ); + + return { + handleReorderBoardFields, + handleBoardFieldVisibilityChange, + visibleBoardFields, + hiddenBoardFields, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts new file mode 100644 index 00000000000..f095e5ca112 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; +import { OnDragEndResponder } from '@hello-pangea/dnd'; +import { useRecoilValue } from 'recoil'; + +import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { useTableColumns } from '@/object-record/record-table/hooks/useTableColumns'; + +export const useRecordIndexOptionsForTable = (recordTableId: string) => { + const { getHiddenTableColumnsSelector, getVisibleTableColumnsSelector } = + useRecordTableStates(recordTableId); + + const hiddenTableColumns = useRecoilValue(getHiddenTableColumnsSelector()); + const visibleTableColumns = useRecoilValue(getVisibleTableColumnsSelector()); + + const { handleColumnVisibilityChange, handleColumnReorder } = useTableColumns( + { recordTableId: recordTableId }, + ); + + const handleReorderColumns: OnDragEndResponder = useCallback( + (result) => { + if ( + !result.destination || + result.destination.index === 1 || + result.source.index === 1 + ) { + return; + } + + const reorderFields = [...visibleTableColumns]; + const [removed] = reorderFields.splice(result.source.index - 1, 1); + reorderFields.splice(result.destination.index - 1, 0, removed); + + handleColumnReorder(reorderFields); + }, + [visibleTableColumns, handleColumnReorder], + ); + + return { + handleReorderColumns, + handleColumnVisibilityChange, + visibleTableColumns, + hiddenTableColumns, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsImport.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsImport.ts new file mode 100644 index 00000000000..6acb8b37661 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsImport.ts @@ -0,0 +1,23 @@ +import { useSpreadsheetCompanyImport } from '@/companies/hooks/useSpreadsheetCompanyImport'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useSpreadsheetPersonImport } from '@/people/hooks/useSpreadsheetPersonImport'; + +type useRecordIndexOptionsImportParams = { + objectNameSingular: string; +}; + +export const useRecordIndexOptionsImport = ({ + objectNameSingular, +}: useRecordIndexOptionsImportParams) => { + const { openPersonSpreadsheetImport } = useSpreadsheetPersonImport(); + const { openCompanySpreadsheetImport } = useSpreadsheetCompanyImport(); + + const handleImport = + CoreObjectNameSingular.Company === objectNameSingular + ? openCompanySpreadsheetImport + : CoreObjectNameSingular.Person === objectNameSingular + ? openPersonSpreadsheetImport + : undefined; + + return { handleImport }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/constants/TableOptionsDropdownId.ts b/packages/twenty-front/src/modules/object-record/record-table/constants/TableOptionsDropdownId.ts deleted file mode 100644 index 57628d11976..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/constants/TableOptionsDropdownId.ts +++ /dev/null @@ -1,2 +0,0 @@ -// We should either apply the constant all caps case or maybe define a more general enum to store those ids ? -export const TableOptionsDropdownId = 'table-options-dropdown-id'; diff --git a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdown.tsx deleted file mode 100644 index 874268a2f0f..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/options/components/TableOptionsDropdown.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { useViewBar } from '@/views/hooks/useViewBar'; - -import { TableOptionsDropdownId } from '../../constants/TableOptionsDropdownId'; -import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope'; - -import { TableOptionsDropdownButton } from './TableOptionsDropdownButton'; -import { TableOptionsDropdownContent } from './TableOptionsDropdownContent'; - -export const TableOptionsDropdown = ({ - onImport, - recordTableId, -}: { - onImport?: () => void; - recordTableId: string; -}) => { - const { setViewEditMode } = useViewBar(); - - return ( - } - dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }} - dropdownOffset={{ y: 8 }} - dropdownComponents={ - - } - onClickOutside={() => setViewEditMode('none')} - /> - ); -}; diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx index 4726fecca9b..941647853c6 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx +++ b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx @@ -1,10 +1,11 @@ import styled from '@emotion/styled'; +import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown'; +import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers'; -import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId'; -import { TableOptionsDropdown } from '@/object-record/record-table/options/components/TableOptionsDropdown'; import { SignInBackgroundMockContainerEffect } from '@/sign-in-background-mock/components/SignInBackgroundMockContainerEffect'; import { ViewBar } from '@/views/components/ViewBar'; +import { ViewType } from '@/views/types/ViewType'; const StyledContainer = styled.div` display: flex; @@ -16,7 +17,7 @@ const StyledContainer = styled.div` export const SignInBackgroundMockContainer = () => { const objectNamePlural = 'companies'; const objectNameSingular = 'company'; - const recordTableId = 'sign-up-mock-record-table-id'; + const recordIndexId = 'sign-up-mock-record-table-id'; const viewBarId = 'companies-mock'; return ( @@ -24,18 +25,22 @@ export const SignInBackgroundMockContainer = () => { + } - optionsDropdownScopeId={TableOptionsDropdownId} + optionsDropdownScopeId={RECORD_INDEX_OPTIONS_DROPDOWN_ID} /> {}} updateRecordMutation={() => {}} From 03e5c792f0290acfb37954079b3afc7e8cda4fc7 Mon Sep 17 00:00:00 2001 From: Weiko Date: Tue, 30 Jan 2024 19:35:41 +0100 Subject: [PATCH 14/54] [messaging] fix message-channel-message-association field name as dependencies (#3712) --- .../standard-objects/message-channel.object-metadata.ts | 2 +- .../standard-objects/message-thread.object-metadata.ts | 2 +- .../standard-objects/message.object-metadata.ts | 2 +- .../workspace/workspace-sync-metadata/types/object-record.ts | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message-channel.object-metadata.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message-channel.object-metadata.ts index 33b4aef226c..eb0cd14feba 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message-channel.object-metadata.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message-channel.object-metadata.ts @@ -82,5 +82,5 @@ export class MessageChannelObjectMetadata extends BaseObjectMetadata { objectName: 'messageChannelMessageAssociation', }) @IsNullable() - messageChannelMessageAssociation: MessageChannelMessageAssociationObjectMetadata[]; + messageChannelMessageAssociations: MessageChannelMessageAssociationObjectMetadata[]; } diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message-thread.object-metadata.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message-thread.object-metadata.ts index c77d3890d0a..a5c3f0c0fe2 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message-thread.object-metadata.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message-thread.object-metadata.ts @@ -46,5 +46,5 @@ export class MessageThreadObjectMetadata extends BaseObjectMetadata { objectName: 'messageChannelMessageAssociation', }) @IsNullable() - messageChannelMessageAssociation: MessageChannelMessageAssociationObjectMetadata[]; + messageChannelMessageAssociations: MessageChannelMessageAssociationObjectMetadata[]; } diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message.object-metadata.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message.object-metadata.ts index f95621f05dc..15ce6ed8985 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message.object-metadata.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/standard-objects/message.object-metadata.ts @@ -112,5 +112,5 @@ export class MessageObjectMetadata extends BaseObjectMetadata { objectName: 'messageChannelMessageAssociation', }) @IsNullable() - messageChannelMessageAssociation: MessageChannelMessageAssociationObjectMetadata[]; + messageChannelMessageAssociations: MessageChannelMessageAssociationObjectMetadata[]; } diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/types/object-record.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/types/object-record.ts index df388ea0329..bf21776f2f1 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/types/object-record.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/types/object-record.ts @@ -1,6 +1,5 @@ import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata'; -// Note: This is actually not enterely correct, id field should only be added if the relation is MANY_TO_ONE or ONE_TO_ONE export type ObjectRecord = { [K in keyof T as T[K] extends BaseObjectMetadata ? `${Extract}Id` From 06e35e51199e7bb7a3d5da1bb40bf3f49196cd14 Mon Sep 17 00:00:00 2001 From: Tangerine Kugelmann <9637909+daallgeier@users.noreply.github.com> Date: Tue, 30 Jan 2024 23:28:40 +0100 Subject: [PATCH 15/54] chore: create security.txt (#3684) * chore: create security.txt Adding a security.txt file enables security researchers to quickly and easily see where they can submit security issues and know that they are being taken serious. From the proposal website: > When security risks in web services are discovered by independent security researchers who understand the severity of the risk, they often lack the channels to disclose them properly. As a result, security issues may be left unreported. security.txt defines a standard to help organizations define the process for security researchers to disclose security vulnerabilities securely. See also https://securitytxt.org * homer merge with hedge * re-add contact email * move file to public website --- packages/twenty-website/public/.well-known/security.txt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/twenty-website/public/.well-known/security.txt diff --git a/packages/twenty-website/public/.well-known/security.txt b/packages/twenty-website/public/.well-known/security.txt new file mode 100644 index 00000000000..ed3495bc59e --- /dev/null +++ b/packages/twenty-website/public/.well-known/security.txt @@ -0,0 +1,5 @@ +Contact: https://github.com/twentyhq/twenty/issues/new +Contact: security@twenty.com +Preferred-Languages: en +Canonical: https://twenty.com/.well-known/security.txt +Policy: https://github.com/twentyhq/twenty/security/policy From 9597b1ae4138712c583b1060a2406c05083b6651 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Wed, 31 Jan 2024 10:25:22 +0100 Subject: [PATCH 16/54] Avoid fetching more emails when first query loading (#3709) Co-authored-by: Thomas Trompette --- .../emails/components/EmailThreads.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx index 816b63219b6..879bda9697b 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx @@ -75,12 +75,21 @@ export const EmailThreads = ({ pageSize: TIMELINE_THREADS_DEFAULT_PAGE_SIZE, } as GetTimelineThreadsFromPersonIdQueryVariables; - const { data, loading, fetchMore, error } = useQuery(threadQuery, { + const { + data, + loading: firstQueryLoading, + fetchMore, + error, + } = useQuery(threadQuery, { variables: threadQueryVariables, }); const fetchMoreRecords = async () => { - if (emailThreadsPage.hasNextPage && !isFetchingMoreEmails) { + if ( + emailThreadsPage.hasNextPage && + !isFetchingMoreEmails && + !firstQueryLoading + ) { setIsFetchingMoreEmails(true); await fetchMore({ @@ -144,7 +153,7 @@ export const EmailThreads = ({ } fontColor={H1TitleFontColor.Primary} /> - {!loading && ( + {!firstQueryLoading && ( {timelineThreads?.map((thread: TimelineThread, index: number) => ( )}
From 29339ef99a519f8f480176441baca4254b2772bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tha=C3=AFs?= Date: Wed, 31 Jan 2024 07:36:26 -0300 Subject: [PATCH 17/54] fix: detach relation records in cache on record deletion (#3707) * fix: detach relation records in cache on record deletion * fix: fix useGetRelationMetadata tests --- ...GetRelationFieldsToOptimisticallyUpdate.ts | 76 ---------------- ...n.ts => isCachedObjectRecordConnection.ts} | 11 ++- .../utils/isObjectRecordConnection.ts | 28 ++++++ .../triggerAttachRelationOptimisticEffect.ts | 59 +++++++++++++ .../triggerCreateRecordsOptimisticEffect.ts | 4 +- .../triggerDeleteRecordsOptimisticEffect.ts | 4 +- .../triggerDetachRelationOptimisticEffect.ts | 49 +++++++++++ .../triggerUpdateRecordOptimisticEffect.ts | 4 +- ...ggerUpdateRelationFieldOptimisticEffect.ts | 86 ------------------- .../__tests__/useGetRelationMetadata.test.tsx | 70 +++++++++++++++ .../__tests__/useRelationMetadata.test.tsx | 55 ------------ .../hooks/useGetRelationMetadata.ts | 67 +++++++++++++++ .../hooks/useRelationMetadata.ts | 49 ----------- .../cache/hooks/useGetRecordFromCache.ts | 2 +- .../hooks/useDeleteManyRecords.ts | 60 ++++++++++--- .../object-record/hooks/useDeleteOneRecord.ts | 52 ++++++++++- .../object-record/hooks/useUpdateOneRecord.ts | 84 +++++++++++------- .../SettingsObjectFieldItemTableRow.tsx | 14 +-- .../data-model/SettingsObjectFieldEdit.tsx | 16 +++- 19 files changed, 465 insertions(+), 325 deletions(-) delete mode 100644 packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate.ts rename packages/twenty-front/src/modules/apollo/optimistic-effect/utils/{isCachedObjectConnection.ts => isCachedObjectRecordConnection.ts} (66%) create mode 100644 packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts create mode 100644 packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts create mode 100644 packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts delete mode 100644 packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx delete mode 100644 packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRelationMetadata.test.tsx create mode 100644 packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts delete mode 100644 packages/twenty-front/src/modules/object-metadata/hooks/useRelationMetadata.ts diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate.ts deleted file mode 100644 index 4e5c6245e16..00000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useRecoilCallback } from 'recoil'; - -import { TriggerUpdateRelationFieldOptimisticEffectParams } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect'; -import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; - -export const useGetRelationFieldsToOptimisticallyUpdate = () => - useRecoilCallback( - ({ snapshot }) => - ({ - cachedRecord, - objectMetadataItem, - updateRecordInput, - }: { - cachedRecord: UpdatedObjectRecord & { __typename: string }; - objectMetadataItem: ObjectMetadataItem; - updateRecordInput: Partial>; - }) => - Object.entries(updateRecordInput).reduce< - Pick< - TriggerUpdateRelationFieldOptimisticEffectParams, - | 'relationObjectMetadataNameSingular' - | 'relationFieldName' - | 'previousRelationRecord' - | 'nextRelationRecord' - >[] - >((result, [fieldName, nextRelationRecord]) => { - const fieldDefinition = objectMetadataItem.fields.find( - (fieldMetadataItem) => fieldMetadataItem.name === fieldName, - ); - - if (fieldDefinition?.type !== FieldMetadataType.Relation) - return result; - - const relationObjectMetadataNameSingular = ( - fieldDefinition.toRelationMetadata?.fromObjectMetadata || - fieldDefinition.fromRelationMetadata?.toObjectMetadata - )?.nameSingular; - const relationFieldMetadataId = - fieldDefinition.toRelationMetadata?.fromFieldMetadataId || - fieldDefinition.fromRelationMetadata?.toFieldMetadataId; - - if (!relationObjectMetadataNameSingular || !relationFieldMetadataId) - return result; - - const relationObjectMetadataItem = snapshot - .getLoadable( - objectMetadataItemFamilySelector({ - objectName: relationObjectMetadataNameSingular, - objectNameType: 'singular', - }), - ) - .valueOrThrow(); - - if (!relationObjectMetadataItem) return result; - - const relationFieldName = relationObjectMetadataItem.fields.find( - (fieldMetadataItem) => - fieldMetadataItem.id === relationFieldMetadataId, - )?.name; - - if (!relationFieldName) return result; - - return [ - ...result, - { - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord: cachedRecord[fieldName], - nextRelationRecord, - }, - ]; - }, []), - ); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts similarity index 66% rename from packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts rename to packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts index e77b1411983..92186f3df31 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectConnection.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts @@ -4,15 +4,24 @@ import { z } from 'zod'; import { CachedObjectRecordConnection } from '@/apollo/types/CachedObjectRecordConnection'; import { capitalize } from '~/utils/string/capitalize'; -export const isCachedObjectConnection = ( +export const isCachedObjectRecordConnection = ( objectNameSingular: string, storeValue: StoreValue, ): storeValue is CachedObjectRecordConnection => { const objectConnectionTypeName = `${capitalize( objectNameSingular, )}Connection`; + const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; const cachedObjectConnectionSchema = z.object({ __typename: z.literal(objectConnectionTypeName), + edges: z.array( + z.object({ + __typename: z.literal(objectEdgeTypeName), + node: z.object({ + __ref: z.string().startsWith(`${capitalize(objectNameSingular)}:`), + }), + }), + ), }); const cachedConnectionValidation = cachedObjectConnectionSchema.safeParse(storeValue); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts new file mode 100644 index 00000000000..7017d15c0f9 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { capitalize } from '~/utils/string/capitalize'; + +export const isObjectRecordConnection = ( + objectNameSingular: string, + value: unknown, +): value is ObjectRecordConnection => { + const objectConnectionTypeName = `${capitalize( + objectNameSingular, + )}Connection`; + const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; + const objectConnectionSchema = z.object({ + __typename: z.literal(objectConnectionTypeName), + edges: z.array( + z.object({ + __typename: z.literal(objectEdgeTypeName), + node: z.object({ + id: z.string().uuid(), + }), + }), + ), + }); + const connectionValidation = objectConnectionSchema.safeParse(value); + + return connectionValidation.success; +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts new file mode 100644 index 00000000000..68ac70a3455 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts @@ -0,0 +1,59 @@ +import { ApolloCache, StoreObject } from '@apollo/client'; + +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; +import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; +import { capitalize } from '~/utils/string/capitalize'; + +export const triggerAttachRelationOptimisticEffect = ({ + cache, + objectNameSingular, + recordId, + relationObjectMetadataNameSingular, + relationFieldName, + relationRecordId, +}: { + cache: ApolloCache; + objectNameSingular: string; + recordId: string; + relationObjectMetadataNameSingular: string; + relationFieldName: string; + relationRecordId: string; +}) => { + const recordTypeName = capitalize(objectNameSingular); + const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular); + + cache.modify({ + id: cache.identify({ + id: relationRecordId, + __typename: relationRecordTypeName, + }), + fields: { + [relationFieldName]: (cachedFieldValue, { toReference }) => { + const nodeReference = toReference({ + id: recordId, + __typename: recordTypeName, + }); + + if (!nodeReference) return cachedFieldValue; + + if ( + isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue) + ) { + // To many objects => add record to next relation field list + const nextEdges: CachedObjectRecordEdge[] = [ + ...cachedFieldValue.edges, + { + __typename: `${recordTypeName}Edge`, + node: nodeReference, + cursor: '', + }, + ]; + return { ...cachedFieldValue, edges: nextEdges }; + } + + // To one object => attach next relation record + return nodeReference; + }, + }, + }); +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts index 4ca7e2f92b5..19dbe252c60 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts @@ -1,6 +1,6 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; @@ -36,7 +36,7 @@ export const triggerCreateRecordsOptimisticEffect = ({ }, ) => { if ( - !isCachedObjectConnection( + !isCachedObjectRecordConnection( objectMetadataItem.nameSingular, cachedConnection, ) diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts index 280ad5f7390..eaa1ecc56dc 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts @@ -1,6 +1,6 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; @@ -24,7 +24,7 @@ export const triggerDeleteRecordsOptimisticEffect = ({ { DELETE, readField, storeFieldName }, ) => { if ( - !isCachedObjectConnection( + !isCachedObjectRecordConnection( objectMetadataItem.nameSingular, cachedConnection, ) diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts new file mode 100644 index 00000000000..f68265920f5 --- /dev/null +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts @@ -0,0 +1,49 @@ +import { ApolloCache, StoreObject } from '@apollo/client'; + +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; +import { capitalize } from '~/utils/string/capitalize'; + +export const triggerDetachRelationOptimisticEffect = ({ + cache, + objectNameSingular, + recordId, + relationObjectMetadataNameSingular, + relationFieldName, + relationRecordId, +}: { + cache: ApolloCache; + objectNameSingular: string; + recordId: string; + relationObjectMetadataNameSingular: string; + relationFieldName: string; + relationRecordId: string; +}) => { + const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular); + + cache.modify({ + id: cache.identify({ + id: relationRecordId, + __typename: relationRecordTypeName, + }), + fields: { + [relationFieldName]: (cachedFieldValue, { isReference, readField }) => { + // To many objects => remove record from previous relation field list + if ( + isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue) + ) { + const nextEdges = cachedFieldValue.edges.filter( + ({ node }) => readField('id', node) !== recordId, + ); + return { ...cachedFieldValue, edges: nextEdges }; + } + + // To one object => detach previous relation record + if (isReference(cachedFieldValue)) { + return null; + } + + return cachedFieldValue; + }, + }, + }); +}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts index 4da9b2839e3..23bf9be2916 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts @@ -1,6 +1,6 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; +import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; @@ -31,7 +31,7 @@ export const triggerUpdateRecordOptimisticEffect = ({ { DELETE, readField, storeFieldName, toReference }, ) => { if ( - !isCachedObjectConnection( + !isCachedObjectRecordConnection( objectMetadataItem.nameSingular, cachedConnection, ) diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect.ts deleted file mode 100644 index 0f77085c973..00000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ApolloCache, StoreObject } from '@apollo/client'; - -import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection'; -import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { capitalize } from '~/utils/string/capitalize'; - -export type TriggerUpdateRelationFieldOptimisticEffectParams = { - cache: ApolloCache; - objectNameSingular: string; - record: ObjectRecord; - relationObjectMetadataNameSingular: string; - relationFieldName: string; - previousRelationRecord: ObjectRecord | null; - nextRelationRecord: ObjectRecord | null; -}; - -export const triggerUpdateRelationFieldOptimisticEffect = ({ - cache, - objectNameSingular, - record, - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord, - nextRelationRecord, -}: TriggerUpdateRelationFieldOptimisticEffectParams) => { - const recordTypeName = capitalize(objectNameSingular); - const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular); - - if (previousRelationRecord) { - cache.modify({ - id: cache.identify({ - ...previousRelationRecord, - __typename: relationRecordTypeName, - }), - fields: { - [relationFieldName]: (cachedFieldValue, { isReference, readField }) => { - // To many objects => remove record from previous relation field list - if (isCachedObjectConnection(objectNameSingular, cachedFieldValue)) { - const nextEdges = cachedFieldValue.edges.filter( - ({ node }) => readField('id', node) !== record.id, - ); - return { ...cachedFieldValue, edges: nextEdges }; - } - - // To one object => detach previous relation record - if (isReference(cachedFieldValue)) { - return null; - } - }, - }, - }); - } - - if (nextRelationRecord) { - cache.modify({ - id: cache.identify({ - ...nextRelationRecord, - __typename: relationRecordTypeName, - }), - fields: { - [relationFieldName]: (cachedFieldValue, { toReference }) => { - const nodeReference = toReference(record); - - if (!nodeReference) return cachedFieldValue; - - if (isCachedObjectConnection(objectNameSingular, cachedFieldValue)) { - // To many objects => add record to next relation field list - const nextEdges: CachedObjectRecordEdge[] = [ - ...cachedFieldValue.edges, - { - __typename: `${recordTypeName}Edge`, - node: nodeReference, - cursor: '', - }, - ]; - return { ...cachedFieldValue, edges: nextEdges }; - } - - // To one object => attach next relation record - return nodeReference; - }, - }, - }); - } -}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx new file mode 100644 index 00000000000..b13554ed6e1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx @@ -0,0 +1,70 @@ +import { ReactNode, useEffect } from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { renderHook } from '@testing-library/react'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; + +import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + {children} + + + +); + +describe('useGetRelationMetadata', () => { + it('should return correct properties', async () => { + const objectMetadataItems = getObjectMetadataItemsMock(); + const objectMetadata = objectMetadataItems.find( + (item) => item.nameSingular === 'person', + )!; + const fieldMetadataItem = objectMetadata.fields.find( + (field) => field.name === 'opportunities', + )!; + + const { result } = renderHook( + () => { + const setMetadataItems = useSetRecoilState(objectMetadataItemsState); + + useEffect(() => { + setMetadataItems(objectMetadataItems); + }, [setMetadataItems]); + + return useGetRelationMetadata(); + }, + { + wrapper: Wrapper, + initialProps: {}, + }, + ); + + const { + relationFieldMetadataItem, + relationObjectMetadataItem, + relationType, + } = result.current({ fieldMetadataItem }) ?? {}; + + const expectedRelationObjectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === 'opportunity', + ); + const expectedRelationFieldMetadataItem = + expectedRelationObjectMetadataItem?.fields.find( + (field) => field.name === 'person', + ); + + expect(relationObjectMetadataItem).toEqual( + expectedRelationObjectMetadataItem, + ); + expect(relationFieldMetadataItem).toEqual( + expectedRelationFieldMetadataItem, + ); + expect(relationType).toBe('ONE_TO_MANY'); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRelationMetadata.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRelationMetadata.test.tsx deleted file mode 100644 index d377424bfaa..00000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useRelationMetadata.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { ReactNode } from 'react'; -import { MockedProvider } from '@apollo/client/testing'; -import { renderHook } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; - -import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata'; -import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; - -import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; - -const Wrapper = ({ children }: { children: ReactNode }) => ( - - - - {children} - - - -); - -describe('useRelationMetadata', () => { - it('should return correct properties', async () => { - const { result, rerender } = renderHook( - ({ fieldMetadataItem }: { fieldMetadataItem?: FieldMetadataItem }) => - useRelationMetadata({ fieldMetadataItem }), - { - wrapper: Wrapper, - initialProps: {}, - }, - ); - - const { - relationFieldMetadataItem, - relationObjectMetadataItem, - relationType, - } = result.current; - - expect(relationFieldMetadataItem).toBeUndefined(); - expect(relationObjectMetadataItem).toBeUndefined(); - expect(relationType).toBeUndefined(); - - const objectMetadataItems = getObjectMetadataItemsMock(); - const objectMetadata = objectMetadataItems.find( - (item) => item.nameSingular === 'person', - )!; - const fieldMetadataItem = objectMetadata.fields.find( - (field) => field.name === 'opportunities', - )!; - - rerender({ fieldMetadataItem }); - - expect(result.current.relationType).toBe('ONE_TO_MANY'); - }); -}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts new file mode 100644 index 00000000000..8b8765d4fe4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts @@ -0,0 +1,67 @@ +import { useRecoilCallback } from 'recoil'; + +import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; +import { RelationType } from '@/settings/data-model/types/RelationType'; +import { + FieldMetadataType, + RelationMetadataType, +} from '~/generated-metadata/graphql'; + +import { FieldMetadataItem } from '../types/FieldMetadataItem'; + +export const useGetRelationMetadata = () => + useRecoilCallback( + ({ snapshot }) => + ({ fieldMetadataItem }: { fieldMetadataItem: FieldMetadataItem }) => { + if (fieldMetadataItem.type !== FieldMetadataType.Relation) return null; + + const relationMetadata = + fieldMetadataItem.fromRelationMetadata || + fieldMetadataItem.toRelationMetadata; + + if (!relationMetadata) return null; + + const relationFieldMetadataId = + 'toFieldMetadataId' in relationMetadata + ? relationMetadata.toFieldMetadataId + : relationMetadata.fromFieldMetadataId; + + if (!relationFieldMetadataId) return null; + + const relationType = + relationMetadata.relationType === RelationMetadataType.OneToMany && + fieldMetadataItem.toRelationMetadata + ? 'MANY_TO_ONE' + : (relationMetadata.relationType as RelationType); + + const relationObjectMetadataNameSingular = + 'toObjectMetadata' in relationMetadata + ? relationMetadata.toObjectMetadata.nameSingular + : relationMetadata.fromObjectMetadata.nameSingular; + + const relationObjectMetadataItem = snapshot + .getLoadable( + objectMetadataItemFamilySelector({ + objectName: relationObjectMetadataNameSingular, + objectNameType: 'singular', + }), + ) + .valueOrThrow(); + + if (!relationObjectMetadataItem) return null; + + const relationFieldMetadataItem = + relationObjectMetadataItem.fields.find( + (field) => field.id === relationFieldMetadataId, + ); + + if (!relationFieldMetadataItem) return null; + + return { + relationFieldMetadataItem, + relationObjectMetadataItem, + relationType, + }; + }, + [], + ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useRelationMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useRelationMetadata.ts deleted file mode 100644 index b6d2657d805..00000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useRelationMetadata.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { RelationType } from '@/settings/data-model/types/RelationType'; -import { RelationMetadataType } from '~/generated-metadata/graphql'; - -import { useObjectMetadataItemForSettings } from '../hooks/useObjectMetadataItemForSettings'; -import { FieldMetadataItem } from '../types/FieldMetadataItem'; - -export const useRelationMetadata = ({ - fieldMetadataItem, -}: { - fieldMetadataItem?: FieldMetadataItem; -}) => { - const { findObjectMetadataItemById } = useObjectMetadataItemForSettings(); - - const relationMetadata = - fieldMetadataItem?.fromRelationMetadata || - fieldMetadataItem?.toRelationMetadata; - - const relationType = - relationMetadata?.relationType === RelationMetadataType.OneToMany && - fieldMetadataItem?.toRelationMetadata - ? 'MANY_TO_ONE' - : (relationMetadata?.relationType as RelationType | undefined); - - const relationObjectMetadataId = - relationMetadata && 'toObjectMetadata' in relationMetadata - ? relationMetadata.toObjectMetadata.id - : relationMetadata?.fromObjectMetadata.id; - - const relationObjectMetadataItem = relationObjectMetadataId - ? findObjectMetadataItemById(relationObjectMetadataId) - : undefined; - - const relationFieldMetadataId = - relationMetadata && 'toFieldMetadataId' in relationMetadata - ? relationMetadata.toFieldMetadataId - : relationMetadata?.fromFieldMetadataId; - - const relationFieldMetadataItem = relationFieldMetadataId - ? relationObjectMetadataItem?.fields?.find( - (field) => field.id === relationFieldMetadataId, - ) - : undefined; - - return { - relationFieldMetadataItem, - relationObjectMetadataItem, - relationType, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts index 78986eec3f9..a81109367bb 100644 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts @@ -15,6 +15,7 @@ export const useGetRecordFromCache = ({ return ( recordId: string, + cache = apolloClient.cache, ) => { if (!objectMetadataItem) { return null; @@ -31,7 +32,6 @@ export const useGetRecordFromCache = ({ } `; - const cache = apolloClient.cache; const cachedRecordId = cache.identify({ __typename: capitalize(objectMetadataItem.nameSingular), id: recordId, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts index f62fa9570b4..080a19cb20b 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -1,9 +1,14 @@ import { useApolloClient } from '@apollo/client'; +import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection'; import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; +import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getDeleteManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation'; -import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; type useDeleteOneRecordProps = { @@ -14,9 +19,11 @@ type useDeleteOneRecordProps = { export const useDeleteManyRecords = ({ objectNameSingular, }: useDeleteOneRecordProps) => { - const { objectMetadataItem, deleteManyRecordsMutation } = + const { objectMetadataItem, deleteManyRecordsMutation, getRecordFromCache } = useObjectMetadataItem({ objectNameSingular }); + const getRelationMetadata = useGetRelationMetadata(); + const apolloClient = useApolloClient(); const mutationResponseField = getDeleteManyRecordsMutationResponseField( @@ -24,16 +31,10 @@ export const useDeleteManyRecords = ({ ); const deleteManyRecords = async (idsToDelete: string[]) => { - const deleteRecordFilter: ObjectRecordQueryFilter = { - id: { - in: idsToDelete, - }, - }; const deletedRecords = await apolloClient.mutate({ mutation: deleteManyRecordsMutation, variables: { - filter: deleteRecordFilter, - // atMost: idsToDelete.length, + filter: { id: { in: idsToDelete } }, }, optimisticResponse: { [mutationResponseField]: idsToDelete.map((idToDelete) => ({ @@ -46,10 +47,49 @@ export const useDeleteManyRecords = ({ if (!records?.length) return; + objectMetadataItem.fields.forEach((fieldMetadataItem) => { + const relationMetadata = getRelationMetadata({ fieldMetadataItem }); + + if (!relationMetadata) return; + + const { relationObjectMetadataItem, relationFieldMetadataItem } = + relationMetadata; + + records.forEach((record) => { + const cachedRecord = getRecordFromCache(record.id, cache); + + if (!cachedRecord) return; + + const previousFieldValue: + | ObjectRecordConnection + | ObjectRecord + | null = cachedRecord[fieldMetadataItem.name]; + + const relationRecordIds = isObjectRecordConnection( + relationObjectMetadataItem.nameSingular, + previousFieldValue, + ) + ? previousFieldValue.edges.map(({ node }) => node.id) + : [previousFieldValue?.id].filter(isDefined); + + relationRecordIds.forEach((relationRecordId) => + triggerDetachRelationOptimisticEffect({ + cache, + objectNameSingular, + recordId: record.id, + relationObjectMetadataNameSingular: + relationObjectMetadataItem.nameSingular, + relationFieldName: relationFieldMetadataItem.name, + relationRecordId, + }), + ); + }); + }); + triggerDeleteRecordsOptimisticEffect({ cache, objectMetadataItem, - records, + records: records, }); }, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts index 5987d1472e3..389b2e646ab 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts @@ -1,9 +1,15 @@ import { useCallback } from 'react'; import { useApolloClient } from '@apollo/client'; +import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection'; import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; +import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/generateDeleteOneRecordMutation'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; type useDeleteOneRecordProps = { @@ -14,9 +20,10 @@ type useDeleteOneRecordProps = { export const useDeleteOneRecord = ({ objectNameSingular, }: useDeleteOneRecordProps) => { - const { objectMetadataItem, deleteOneRecordMutation } = useObjectMetadataItem( - { objectNameSingular }, - ); + const { objectMetadataItem, deleteOneRecordMutation, getRecordFromCache } = + useObjectMetadataItem({ objectNameSingular }); + + const getRelationMetadata = useGetRelationMetadata(); const apolloClient = useApolloClient(); @@ -39,6 +46,43 @@ export const useDeleteOneRecord = ({ if (!record) return; + objectMetadataItem.fields.forEach((fieldMetadataItem) => { + const relationMetadata = getRelationMetadata({ fieldMetadataItem }); + + if (!relationMetadata) return; + + const { relationObjectMetadataItem, relationFieldMetadataItem } = + relationMetadata; + + const cachedRecord = getRecordFromCache(record.id, cache); + + if (!cachedRecord) return; + + const previousFieldValue: + | ObjectRecordConnection + | ObjectRecord + | null = cachedRecord[fieldMetadataItem.name]; + + const relationRecordIds = isObjectRecordConnection( + relationObjectMetadataItem.nameSingular, + previousFieldValue, + ) + ? previousFieldValue.edges.map(({ node }) => node.id) + : [previousFieldValue?.id].filter(isDefined); + + relationRecordIds.forEach((relationRecordId) => + triggerDetachRelationOptimisticEffect({ + cache, + objectNameSingular, + recordId: record.id, + relationObjectMetadataNameSingular: + relationObjectMetadataItem.nameSingular, + relationFieldName: relationFieldMetadataItem.name, + relationRecordId, + }), + ); + }); + triggerDeleteRecordsOptimisticEffect({ cache, objectMetadataItem, @@ -52,6 +96,8 @@ export const useDeleteOneRecord = ({ [ apolloClient, deleteOneRecordMutation, + getRecordFromCache, + getRelationMetadata, mutationResponseField, objectMetadataItem, objectNameSingular, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts index 796e417429f..611c34dd2f8 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -1,12 +1,15 @@ import { useApolloClient } from '@apollo/client'; -import { useGetRelationFieldsToOptimisticallyUpdate } from '@/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate'; +import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection'; +import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect'; +import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect'; import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect'; -import { triggerUpdateRelationFieldOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getUpdateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { capitalize } from '~/utils/string/capitalize'; type useUpdateOneRecordProps = { @@ -21,8 +24,7 @@ export const useUpdateOneRecord = < const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } = useObjectMetadataItem({ objectNameSingular }); - const getRelationFieldsToOptimisticallyUpdate = - useGetRelationFieldsToOptimisticallyUpdate(); + const getRelationMetadata = useGetRelationMetadata(); const apolloClient = useApolloClient(); @@ -48,14 +50,6 @@ export const useUpdateOneRecord = < id: idToUpdate, }; - const updatedRelationFields = cachedRecord - ? getRelationFieldsToOptimisticallyUpdate({ - cachedRecord, - objectMetadataItem, - updateRecordInput: updateOneRecordInput, - }) - : []; - const mutationResponseField = getUpdateOneRecordMutationResponseField(objectNameSingular); @@ -73,29 +67,59 @@ export const useUpdateOneRecord = < if (!record) return; + objectMetadataItem.fields.forEach((fieldMetadataItem) => { + const relationMetadata = getRelationMetadata({ fieldMetadataItem }); + + if (!relationMetadata) return; + + const { relationObjectMetadataItem, relationFieldMetadataItem } = + relationMetadata; + + const previousFieldValue = cachedRecord?.[fieldMetadataItem.name]; + const nextFieldValue = + updateOneRecordInput[fieldMetadataItem.name] ?? null; + + if ( + !(fieldMetadataItem.name in updateOneRecordInput) || + isObjectRecordConnection( + relationObjectMetadataItem.nameSingular, + previousFieldValue, + ) || + isDeeplyEqual(previousFieldValue, nextFieldValue) + ) { + return; + } + + if (previousFieldValue) { + triggerDetachRelationOptimisticEffect({ + cache, + objectNameSingular, + recordId: record.id, + relationObjectMetadataNameSingular: + relationObjectMetadataItem.nameSingular, + relationFieldName: relationFieldMetadataItem.name, + relationRecordId: previousFieldValue.id, + }); + } + + if (nextFieldValue) { + triggerAttachRelationOptimisticEffect({ + cache, + objectNameSingular, + recordId: record.id, + relationObjectMetadataNameSingular: + relationObjectMetadataItem.nameSingular, + relationFieldName: relationFieldMetadataItem.name, + relationRecordId: nextFieldValue.id, + }); + } + }); + triggerUpdateRecordOptimisticEffect({ cache, objectMetadataItem, record, }); - - updatedRelationFields.forEach( - ({ - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord, - nextRelationRecord, - }) => - triggerUpdateRelationFieldOptimisticEffect({ - cache, - objectNameSingular, - record, - relationObjectMetadataNameSingular, - relationFieldName, - previousRelationRecord, - nextRelationRecord, - }), - ); }, }); diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx index bec44f3b5e5..242f8a6a63f 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx @@ -1,9 +1,9 @@ -import { ReactNode } from 'react'; +import { ReactNode, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug'; import { FieldIdentifierType } from '@/settings/data-model/types/FieldIdentifierType'; @@ -53,9 +53,13 @@ export const SettingsObjectFieldItemTableRow = ({ const fieldDataTypeIsSupported = fieldMetadataItem.type in settingsFieldMetadataTypes; - const { relationObjectMetadataItem, relationType } = useRelationMetadata({ - fieldMetadataItem, - }); + const getRelationMetadata = useGetRelationMetadata(); + + const { relationObjectMetadataItem, relationType } = + useMemo( + () => getRelationMetadata({ fieldMetadataItem }), + [fieldMetadataItem, getRelationMetadata], + ) ?? {}; if (!fieldDataTypeIsSupported) return null; diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx index 853b5345326..e8f1a681e6f 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx @@ -1,9 +1,9 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings'; -import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata'; import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug'; import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; @@ -43,11 +43,21 @@ export const SettingsObjectFieldEdit = () => { metadataField.isActive && getFieldSlug(metadataField) === fieldSlug, ); + const getRelationMetadata = useGetRelationMetadata(); const { relationFieldMetadataItem, relationObjectMetadataItem, relationType, - } = useRelationMetadata({ fieldMetadataItem: activeMetadataField }); + } = + useMemo( + () => + activeMetadataField + ? getRelationMetadata({ + fieldMetadataItem: activeMetadataField, + }) + : null, + [activeMetadataField, getRelationMetadata], + ) ?? {}; const { formValues, From c8e4d0ab9a63ba5f1c57195b96c8937b7e0cbd9e Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 31 Jan 2024 11:37:03 +0100 Subject: [PATCH 18/54] Board compact view and Company Picker for opportunity special case (#3713) * Re-enabled board compact mode * Add specific case for opportunity to display company picker * Add infinite scroll * Remove useEffect * Fix * Fix --- .../hooks/internal/useRecordBoardStates.ts | 11 +++ .../record-board/hooks/useRecordBoard.ts | 4 + .../components/RecordBoardCard.tsx | 42 +++++++-- .../RecordBoardColumnCardsContainer.tsx | 18 ++-- .../RecordBoardColumnFetchMoreLoader.tsx | 36 ++++++++ .../RecordBoardColumnNewOpportunityButton.tsx | 90 +++++++++++++++++++ ...RecordBoardFetchingRecordsStateScopeMap.ts | 7 ++ ...dFetchMoreVisibilityChangeStateScopeMap.ts | 7 ++ .../utils/computeDraftValueFromFieldValue.ts | 21 +++-- .../utils/computeEmptyDraftValue.ts | 4 +- .../components/RecordIndexBoardContainer.tsx | 6 +- .../RecordIndexBoardContainerEffect.tsx | 38 +++++--- .../RecordIndexOptionsDropdownContent.tsx | 25 +++++- .../hooks/useRecordIndexOptionsForBoard.ts | 20 ++++- .../action-bar/components/ActionBar.tsx | 5 +- .../context-menu/components/ContextMenu.tsx | 3 - 16 files changed, 291 insertions(+), 46 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap.ts diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts index 63b98c80468..291db2655e4 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts @@ -3,6 +3,8 @@ import { isFirstRecordBoardColumnFamilyStateScopeMap } from '@/object-record/rec import { isLastRecordBoardColumnFamilyStateScopeMap } from '@/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap'; import { isRecordBoardCardSelectedFamilyStateScopeMap } from '@/object-record/record-board/states/isRecordBoardCardSelectedFamilyStateScopeMap'; import { isRecordBoardCompactModeActiveStateScopeMap } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveStateScopeMap'; +import { isRecordBoardFetchingRecordsStateScopeMap } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap'; +import { onRecordBoardFetchMoreVisibilityChangeStateScopeMap } from '@/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap'; import { recordBoardColumnIdsStateScopeMap } from '@/object-record/record-board/states/recordBoardColumnIdsStateScopeMap'; import { recordBoardFieldDefinitionsStateScopeMap } from '@/object-record/record-board/states/recordBoardFieldDefinitionsStateScopeMap'; import { recordBoardFiltersStateScopeMap } from '@/object-record/record-board/states/recordBoardFiltersStateScopeMap'; @@ -30,6 +32,10 @@ export const useRecordBoardStates = (recordBoardId?: string) => { recordBoardObjectSingularNameStateScopeMap, scopeId, ), + getIsFetchingRecordState: getState( + isRecordBoardFetchingRecordsStateScopeMap, + scopeId, + ), getColumnIdsState: getState(recordBoardColumnIdsStateScopeMap, scopeId), isFirstColumnFamilyState: getFamilyState( isFirstRecordBoardColumnFamilyStateScopeMap, @@ -72,5 +78,10 @@ export const useRecordBoardStates = (recordBoardId?: string) => { isRecordBoardCompactModeActiveStateScopeMap, scopeId, ), + + getOnFetchMoreVisibilityChangeState: getState( + onRecordBoardFetchMoreVisibilityChangeStateScopeMap, + scopeId, + ), }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts index 486bce6e1af..59429b25465 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts @@ -10,6 +10,8 @@ export const useRecordBoard = (recordBoardId?: string) => { getFieldDefinitionsState, getObjectSingularNameState, getSelectedRecordIdsSelector, + getIsCompactModeActiveState, + getOnFetchMoreVisibilityChangeState, } = useRecordBoardStates(recordBoardId); const { setColumns } = useSetRecordBoardColumns(recordBoardId); @@ -24,5 +26,7 @@ export const useRecordBoard = (recordBoardId?: string) => { setFieldDefinitions, setObjectSingularName, getSelectedRecordIdsSelector, + getIsCompactModeActiveState, + getOnFetchMoreVisibilityChangeState, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx index efbc96d1f73..15f2424e97c 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx @@ -1,4 +1,5 @@ import { ReactNode, useContext, useState } from 'react'; +import { useInView } from 'react-intersection-observer'; import styled from '@emotion/styled'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; @@ -20,6 +21,7 @@ import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox'; import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState'; import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut'; +import { ScrollWrapperContext } from '@/ui/utilities/scroll/components/ScrollWrapper'; const StyledBoardCard = styled.div<{ selected: boolean }>` background-color: ${({ theme, selected }) => @@ -119,22 +121,26 @@ const StyledCompactIconContainer = styled.div` align-items: center; display: flex; justify-content: center; + margin-left: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledRecordInlineCellPlaceholder = styled.div` + height: 24px; `; export const RecordBoardCard = () => { const { recordId } = useContext(RecordBoardCardContext); - const { updateOneRecord } = useContext(RecordBoardContext); + const { updateOneRecord, objectMetadataItem } = + useContext(RecordBoardContext); const { - getObjectSingularNameState, getIsCompactModeActiveState, isRecordBoardCardSelectedFamilyState, getVisibleFieldDefinitionsState, } = useRecordBoardStates(); const isCompactModeActive = useRecoilValue(getIsCompactModeActiveState()); - const objectNameSingular = useRecoilValue(getObjectSingularNameState()); - const [isCardInCompactMode, setIsCardInCompactMode] = - useState(isCompactModeActive); + + const [isCardInCompactMode, setIsCardInCompactMode] = useState(true); const [isCurrentCardSelected, setIsCurrentCardSelected] = useRecoilState( isRecordBoardCardSelectedFamilyState(recordId), @@ -190,13 +196,21 @@ export const RecordBoardCard = () => { return [updateEntity, { loading: false }]; }; - if (!objectNameSingular || !record) { + const scrollWrapperRef = useContext(ScrollWrapperContext); + + const { ref: cardRef, inView } = useInView({ + root: scrollWrapperRef.current, + rootMargin: '1000px', + }); + + if (!record) { return null; } return ( { @@ -204,7 +218,10 @@ export const RecordBoardCard = () => { }} > - + {isCompactModeActive && ( { - + {visibleBoardCardFieldDefinitions.map((fieldDefinition) => ( { hotkeyScope: InlineCellHotkeyScope.InlineCell, }} > - + {inView ? ( + + ) : ( + + )} ))} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx index 236a4314557..96d14a011bd 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx @@ -2,14 +2,14 @@ import React, { useContext } from 'react'; import styled from '@emotion/styled'; import { Draggable, DroppableProvided } from '@hello-pangea/dnd'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordBoardColumnCardsMemo } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnCardsMemo'; +import { RecordBoardColumnFetchMoreLoader } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader'; import { RecordBoardColumnNewButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton'; +import { RecordBoardColumnNewOpportunityButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; -const StyledPlaceholder = styled.div` - min-height: 1px; -`; - const StyledColumnCardsContainer = styled.div` display: flex; flex: 1; @@ -30,6 +30,7 @@ export const RecordBoardColumnCardsContainer = ({ droppableProvided, }: RecordBoardColumnCardsContainerProps) => { const { columnDefinition } = useContext(RecordBoardColumnContext); + const { objectMetadataItem } = useContext(RecordBoardContext); return ( - {droppableProvided?.placeholder} + - + {objectMetadataItem.nameSingular === + CoreObjectNameSingular.Opportunity ? ( + + ) : ( + + )} )} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx new file mode 100644 index 00000000000..98ca2e8666b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx @@ -0,0 +1,36 @@ +import { useInView } from 'react-intersection-observer'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; + +import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; +import { grayScale } from '@/ui/theme/constants/colors'; + +const StyledText = styled.div` + align-items: center; + box-shadow: none; + color: ${grayScale.gray40}; + display: flex; + height: 32px; + margin-left: ${({ theme }) => theme.spacing(8)}; + padding-left: ${({ theme }) => theme.spacing(2)}; +`; + +export const RecordBoardColumnFetchMoreLoader = () => { + const { getIsFetchingRecordState, getOnFetchMoreVisibilityChangeState } = + useRecordBoardStates(); + const isFetchingRecords = useRecoilValue(getIsFetchingRecordState()); + + const onFetchMoreVisibilityChange = useRecoilValue( + getOnFetchMoreVisibilityChangeState(), + ); + + const { ref } = useInView({ + onChange: onFetchMoreVisibilityChange, + }); + + return ( +
+ {isFetchingRecords && Loading more...} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton.tsx new file mode 100644 index 00000000000..9cac6da4ff7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton.tsx @@ -0,0 +1,90 @@ +import { useCallback, useContext, useState } from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; +import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect'; +import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; +import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; +import { IconPlus } from '@/ui/display/icon'; +import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; + +const StyledButton = styled.button` + align-items: center; + align-self: baseline; + background-color: ${({ theme }) => theme.background.primary}; + border: none; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.font.color.tertiary}; + cursor: pointer; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(1)}; + + &:hover { + background-color: ${({ theme }) => theme.background.tertiary}; + } +`; + +export const RecordBoardColumnNewOpportunityButton = () => { + const [isCreatingCard, setIsCreatingCard] = useState(false); + + const theme = useTheme(); + const { columnDefinition } = useContext(RecordBoardColumnContext); + const { createOneRecord, selectFieldMetadataItem } = + useContext(RecordBoardContext); + + const { + goBackToPreviousHotkeyScope, + setHotkeyScopeAndMemorizePreviousScope, + } = usePreviousHotkeyScope(); + + const handleEntitySelect = (company?: EntityForSelect) => { + setIsCreatingCard(false); + goBackToPreviousHotkeyScope(); + + if (!company) { + return; + } + + createOneRecord({ + name: company.name, + companyId: company.id, + [selectFieldMetadataItem.name]: columnDefinition.value, + }); + }; + + const handleNewClick = useCallback(() => { + setIsCreatingCard(true); + setHotkeyScopeAndMemorizePreviousScope( + RelationPickerHotkeyScope.RelationPicker, + ); + }, [setIsCreatingCard, setHotkeyScopeAndMemorizePreviousScope]); + + const handleCancel = () => { + goBackToPreviousHotkeyScope(); + setIsCreatingCard(false); + }; + + return ( + <> + {isCreatingCard ? ( + + ) : ( + + + New + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap.ts new file mode 100644 index 00000000000..83c35a4be90 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap.ts @@ -0,0 +1,7 @@ +import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; + +export const isRecordBoardFetchingRecordsStateScopeMap = + createStateScopeMap({ + key: 'isRecordBoardFetchingRecordsStateScopeMap', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap.ts new file mode 100644 index 00000000000..fb5da620aa7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap.ts @@ -0,0 +1,7 @@ +import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; + +export const onRecordBoardFetchMoreVisibilityChangeStateScopeMap = + createStateScopeMap<(visbility: boolean) => void>({ + key: 'onRecordBoardFetchMoreVisibilityChangeStateScopeMap', + defaultValue: () => {}, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts index bca25224dae..ee11e822cbf 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts @@ -4,6 +4,7 @@ import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldIn import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency'; import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; +import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; import { computeEmptyDraftValue } from '@/object-record/record-field/utils/computeEmptyDraftValue'; import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty'; @@ -21,18 +22,20 @@ export const computeDraftValueFromFieldValue = ({ // than the intputDraftValue type as string can be typed anywhere if (isFieldCurrency(fieldDefinition)) { - if (isFieldValueEmpty({ fieldValue, fieldDefinition })) { + if ( + isFieldValueEmpty({ fieldValue, fieldDefinition }) || + !isFieldCurrencyValue(fieldValue) + ) { return computeEmptyDraftValue({ fieldDefinition }); } - if (isFieldCurrencyValue(fieldValue)) { - return { - amount: fieldValue?.amountMicros - ? fieldValue.amountMicros / 1000000 - : '', - currenyCode: CurrencyCode.USD, - } as unknown as FieldInputDraftValue; - } + return { + amount: fieldValue?.amountMicros ? fieldValue.amountMicros / 1000000 : '', + currenyCode: CurrencyCode.USD, + } as unknown as FieldInputDraftValue; + } + if (isFieldRelation(fieldDefinition)) { + return computeEmptyDraftValue({ fieldDefinition }); } return fieldValue as FieldInputDraftValue; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts index 5a680ff13ad..2af0ea25c18 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts @@ -8,6 +8,7 @@ import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldE import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; +import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue'; import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid'; @@ -24,7 +25,8 @@ export const computeEmptyDraftValue = ({ isFieldText(fieldDefinition) || isFieldDateTime(fieldDefinition) || isFieldNumber(fieldDefinition) || - isFieldEmail(fieldDefinition) + isFieldEmail(fieldDefinition) || + isFieldRelationValue(fieldDefinition) ) { return '' as FieldInputDraftValue; } diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx index d11fcb4ac4c..2fd30265fc3 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx @@ -1,4 +1,4 @@ -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; @@ -19,7 +19,9 @@ export const RecordIndexBoardContainer = ({ recordBoardId, objectNameSingular, }: RecordIndexBoardContainerProps) => { - const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular }); + const { objectMetadataItem } = useObjectMetadataItemOnly({ + objectNameSingular, + }); const selectFieldMetadataItem = objectMetadataItem.fields.find( (field) => field.type === FieldMetadataType.Select, diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx index fc430fd162c..44f0ab12af3 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; import { useRecordActionBar } from '@/object-record/record-action-bar/hooks/useRecordActionBar'; import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; @@ -21,11 +21,35 @@ export const RecordIndexBoardContainerEffect = ({ recordBoardId, viewBarId, }: RecordIndexBoardContainerEffectProps) => { - const { objectMetadataItem } = useObjectMetadataItem({ + const { objectMetadataItem } = useObjectMetadataItemOnly({ objectNameSingular, }); - useLoadRecordIndexBoard({ objectNameSingular, recordBoardId, viewBarId }); + const { + setColumns, + setObjectSingularName, + getSelectedRecordIdsSelector, + setFieldDefinitions, + getOnFetchMoreVisibilityChangeState, + } = useRecordBoard(recordBoardId); + + const { fetchMoreRecords, loading } = useLoadRecordIndexBoard({ + objectNameSingular, + recordBoardId, + viewBarId, + }); + + const setOnFetchMoreVisibilityChange = useSetRecoilState( + getOnFetchMoreVisibilityChangeState(), + ); + + useEffect(() => { + setOnFetchMoreVisibilityChange(() => () => { + if (!loading) { + fetchMoreRecords?.(); + } + }); + }, [fetchMoreRecords, loading, setOnFetchMoreVisibilityChange]); const navigate = useNavigate(); @@ -33,12 +57,6 @@ export const RecordIndexBoardContainerEffect = ({ navigate(`/settings/objects/${objectMetadataItem.namePlural}`); }, [navigate, objectMetadataItem.namePlural]); - const { - setColumns, - setObjectSingularName, - getSelectedRecordIdsSelector, - setFieldDefinitions, - } = useRecordBoard(recordBoardId); const { resetRecordSelection } = useRecordBoardSelection(recordBoardId); useEffect(() => { diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx index 8d130860473..8c1f855970b 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx @@ -7,13 +7,19 @@ import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/opti import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable'; import { useRecordIndexOptionsImport } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsImport'; import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; -import { IconChevronLeft, IconFileImport, IconTag } from '@/ui/display/icon'; +import { + IconBaselineDensitySmall, + IconChevronLeft, + IconFileImport, + IconTag, +} from '@/ui/display/icon'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { MenuItemToggle } from '@/ui/navigation/menu-item/components/MenuItemToggle'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates'; @@ -86,8 +92,11 @@ export const RecordIndexOptionsDropdownContent = ({ hiddenBoardFields, handleReorderBoardFields, handleBoardFieldVisibilityChange, + isCompactModeActive, + setIsCompactModeActive, } = useRecordIndexOptionsForBoard({ objectNameSingular, + recordBoardId: recordIndexId, viewBarId: recordIndexId, }); @@ -170,6 +179,20 @@ export const RecordIndexOptionsDropdownContent = ({ )} )} + {viewType === ViewType.Kanban && ( + <> + + + + + + )} ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts index 040403db745..027ca3cafe2 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts @@ -5,32 +5,48 @@ import { useRecoilState } from 'recoil'; import { mapBoardFieldDefinitionsToViewFields } from '@/companies/utils/mapBoardFieldDefinitionsToViewFields'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; +import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; +import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailableTableColumns'; import { useViewFields } from '@/views/hooks/internal/useViewFields'; type useRecordIndexOptionsForBoardParams = { objectNameSingular: string; + recordBoardId: string; viewBarId: string; }; export const useRecordIndexOptionsForBoard = ({ objectNameSingular, + recordBoardId, viewBarId, }: useRecordIndexOptionsForBoardParams) => { const [recordIndexFieldDefinitions, setRecordIndexFieldDefinitions] = useRecoilState(recordIndexFieldDefinitionsState); const { persistViewFields } = useViewFields(viewBarId); + const { getIsCompactModeActiveState } = useRecordBoard(recordBoardId); + + const [isCompactModeActive, setIsCompactModeActive] = useRecoilState( + getIsCompactModeActiveState(), + ); const { objectMetadataItem } = useObjectMetadataItemOnly({ objectNameSingular, }); - const { columnDefinitions } = + const { columnDefinitions: availableColumnDefinitions } = useColumnDefinitionsFromFieldMetadata(objectMetadataItem); + // Todo replace this with label identifier logic + const columnDefinitions = availableColumnDefinitions + .filter( + (columnDefinition) => columnDefinition.metadata.fieldName !== 'name', + ) + .filter(filterAvailableTableColumns); + const visibleBoardFields = useMemo( () => columnDefinitions.filter((columnDefinition) => { @@ -152,5 +168,7 @@ export const useRecordIndexOptionsForBoard = ({ handleBoardFieldVisibilityChange, visibleBoardFields, hiddenBoardFields, + isCompactModeActive, + setIsCompactModeActive, }; }; diff --git a/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx b/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx index d5d1996b3b4..bf5c2586a0a 100644 --- a/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/action-bar/components/ActionBar.tsx @@ -5,8 +5,6 @@ import { useRecoilValue } from 'recoil'; import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState'; import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; -import { actionBarOpenState } from '../states/actionBarIsOpenState'; - import { ActionBarItem } from './ActionBarItem'; const StyledContainerActionBar = styled.div` @@ -30,12 +28,11 @@ const StyledContainerActionBar = styled.div` `; export const ActionBar = () => { - const actionBarOpen = useRecoilValue(actionBarOpenState); const contextMenuIsOpen = useRecoilValue(contextMenuIsOpenState); const actionBarEntries = useRecoilValue(actionBarEntriesState); const wrapperRef = useRef(null); - if (!actionBarOpen || contextMenuIsOpen) { + if (contextMenuIsOpen) { return null; } diff --git a/packages/twenty-front/src/modules/ui/navigation/context-menu/components/ContextMenu.tsx b/packages/twenty-front/src/modules/ui/navigation/context-menu/components/ContextMenu.tsx index 0c1ee250bc7..8a3a1cfd0e8 100644 --- a/packages/twenty-front/src/modules/ui/navigation/context-menu/components/ContextMenu.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/context-menu/components/ContextMenu.tsx @@ -4,7 +4,6 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState'; import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; @@ -42,14 +41,12 @@ export const ContextMenu = () => { const contextMenuIsOpen = useRecoilValue(contextMenuIsOpenState); const contextMenuEntries = useRecoilValue(contextMenuEntriesState); const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState); - const setActionBarOpenState = useSetRecoilState(actionBarOpenState); const wrapperRef = useRef(null); useListenClickOutside({ refs: [wrapperRef], callback: () => { setContextMenuOpenState(false); - setActionBarOpenState(true); }, }); From edf62f3a3b981809c20c3449ac9b44cdacd2933a Mon Sep 17 00:00:00 2001 From: Jeet Desai <52026385+jeet1desai@users.noreply.github.com> Date: Wed, 31 Jan 2024 16:11:27 +0530 Subject: [PATCH 19/54] Resolved dropdown style issue (#3620) * #3617 #3615 resolved dropdown style issue * resolved lint error * resolved gap issue * resolved lint error --- .../modules/ui/layout/dropdown/components/DropdownMenu.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenu.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenu.tsx index bad0843525c..cee91270190 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenu.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenu.tsx @@ -4,9 +4,8 @@ const StyledDropdownMenu = styled.div<{ disableBlur?: boolean; width?: `${string}px` | 'auto' | number; }>` - backdrop-filter: ${({ disableBlur }) => - disableBlur ? 'none' : 'blur(20px)'}; - background: ${({ theme }) => theme.background.secondary}; + backdrop-filter: ${({ disableBlur }) => (disableBlur ? 'none' : 'blur(8px)')}; + background: ${({ theme }) => theme.background.transparent.secondary}; border: 1px solid ${({ theme }) => theme.border.color.medium}; border-radius: ${({ theme }) => theme.border.radius.md}; From f6e9456ef6c3448a34428afdb54b21495ffe0119 Mon Sep 17 00:00:00 2001 From: Anoop P <44577841+anoopw3bdev@users.noreply.github.com> Date: Wed, 31 Jan 2024 16:19:01 +0530 Subject: [PATCH 20/54] fix: columns overlapping with checkbox column when horizantally scrolling (#3705) --- .../record-table/components/RecordTableRow.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx index 9fd2b1dc9f0..fa0c7cb5e99 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx @@ -22,6 +22,10 @@ const StyledPlaceholder = styled.td` height: 30px; `; +const StyledTd = styled.td` + background-color: ${({ theme }) => theme.background.primary}; +`; + export const RecordTableRow = ({ recordId, rowIndex }: RecordTableRowProps) => { const { getVisibleTableColumnsSelector, isRowSelectedFamilyState } = useRecordTableStates(); @@ -54,9 +58,9 @@ export const RecordTableRow = ({ recordId, rowIndex }: RecordTableRowProps) => { > {inView ? ( <> - + - + {[...visibleTableColumns] .sort((columnA, columnB) => columnA.position - columnB.position) .map((column, columnIndex) => { From ba77d7430adff37a43a692c1911ddd1bca420457 Mon Sep 17 00:00:00 2001 From: Jeet Desai <52026385+jeet1desai@users.noreply.github.com> Date: Wed, 31 Jan 2024 16:27:48 +0530 Subject: [PATCH 21/54] Added empty card for show relations (#3612) #3610 Add empty states for show relations Co-authored-by: Lucas Bordeau --- .../RecordRelationFieldCardSection.tsx | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx index 662a3723e67..d19315ab490 100644 --- a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx @@ -1,6 +1,6 @@ import { useCallback, useContext } from 'react'; import { Link } from 'react-router-dom'; -import { css } from '@emotion/react'; +import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import qs from 'qs'; import { useRecoilValue } from 'recoil'; @@ -20,6 +20,7 @@ import { RelationPickerScope } from '@/object-record/relation-picker/scopes/Rela import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { IconForbid, IconPlus } from '@/ui/display/icon'; +import { useIcons } from '@/ui/display/icon/hooks/useIcons'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { Card } from '@/ui/layout/card/components/Card'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -79,7 +80,22 @@ const StyledLink = styled(Link)` } `; +const StyledCardNoContent = styled.div` + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.font.color.light}; + + align-items: center; + justify-content: center; + gap: ${({ theme }) => theme.spacing(2)}; + display: flex; + height: ${({ theme }) => theme.spacing(10)}; + padding: ${({ theme }) => theme.spacing(0, 2)}; +`; + export const RecordRelationFieldCardSection = () => { + const theme = useTheme(); + const { entityId, fieldDefinition } = useContext(FieldContext); const { fieldName, @@ -160,17 +176,21 @@ export const RecordRelationFieldCardSection = () => { relationObjectMetadataItem.namePlural }?${qs.stringify(filterQueryParams)}`; + const { getIcon } = useIcons(); + const Icon = getIcon(relationObjectMetadataItem.icon); + return (
{fieldDefinition.label} {parseFieldRelationType(relationFieldMetadataItem) === - 'TO_ONE_OBJECT' && ( - - All ({relationRecords.length}) - - )} + 'TO_ONE_OBJECT' && + relationRecords.length > 0 && ( + + All ({relationRecords.length}) + + )} { /> + {relationRecords.length === 0 && ( + + +
No {relationObjectMetadataItem.labelSingular}
+
+ )} {!!relationRecords.length && ( {relationRecords.slice(0, 5).map((relationRecord, index) => ( From d7e4b4116f5e6fe0c9d0d015a85bcae6a39d7b27 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Wed, 31 Jan 2024 12:12:51 +0100 Subject: [PATCH 22/54] Minor fixes empty state (#3703) * Minor fixes for Timeline empty states * Refactored TimelineCreateButtonGroup --- .../components/ActivityCreateButton.tsx | 30 ----------- .../tasks/components/TaskGroups.tsx | 2 +- .../timeline/components/Timeline.tsx | 20 +------ .../components/TimelineCreateButtonGroup.tsx | 53 +++++++++++++++++++ .../components/RecordTableWithWrappers.tsx | 9 ++-- .../components/EmptyPlaceholderStyled.tsx | 1 - .../components/ShowPageRightContainer.tsx | 2 +- 7 files changed, 62 insertions(+), 55 deletions(-) delete mode 100644 packages/twenty-front/src/modules/activities/components/ActivityCreateButton.tsx create mode 100644 packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx diff --git a/packages/twenty-front/src/modules/activities/components/ActivityCreateButton.tsx b/packages/twenty-front/src/modules/activities/components/ActivityCreateButton.tsx deleted file mode 100644 index 8dd8dfcc1fe..00000000000 --- a/packages/twenty-front/src/modules/activities/components/ActivityCreateButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { - IconCheckbox, - IconNotes, - IconTimelineEvent, -} from '@/ui/display/icon/index'; -import { Button } from '@/ui/input/button/components/Button'; -import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup'; - -type ActivityCreateButtonProps = { - onNoteClick?: () => void; - onTaskClick?: () => void; - onActivityClick?: () => void; -}; - -export const ActivityCreateButton = ({ - onNoteClick, - onTaskClick, - onActivityClick, -}: ActivityCreateButtonProps) => ( - -