diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 452e10ae32..9ce0907dd8 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -975,8 +975,13 @@ export type CreateFieldInput = { }; export type CreateFrontComponentInput = { + builtComponentChecksum: Scalars['String']; + builtComponentPath: Scalars['String']; + componentName: Scalars['String']; + description?: InputMaybe; id?: InputMaybe; name: Scalars['String']; + sourceComponentPath: Scalars['String']; }; export type CreateLogicFunctionInput = { @@ -1655,9 +1660,15 @@ export type FindAvailableSsoidpOutput = { export type FrontComponent = { __typename?: 'FrontComponent'; applicationId: Scalars['UUID']; + builtComponentChecksum: Scalars['String']; + builtComponentPath: Scalars['String']; + componentName: Scalars['String']; createdAt: Scalars['DateTime']; + description?: Maybe; id: Scalars['UUID']; name: Scalars['String']; + sourceComponentPath: Scalars['String']; + universalIdentifier?: Maybe; updatedAt: Scalars['DateTime']; }; @@ -4696,6 +4707,7 @@ export type UpdateFrontComponentInput = { }; export type UpdateFrontComponentInputUpdates = { + description?: InputMaybe; name?: InputMaybe; }; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 015fb4a27c..ede24a0317 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -971,8 +971,13 @@ export type CreateFieldInput = { }; export type CreateFrontComponentInput = { + builtComponentChecksum: Scalars['String']; + builtComponentPath: Scalars['String']; + componentName: Scalars['String']; + description?: InputMaybe; id?: InputMaybe; name: Scalars['String']; + sourceComponentPath: Scalars['String']; }; export type CreateLogicFunctionInput = { @@ -1627,9 +1632,15 @@ export type FindAvailableSsoidpOutput = { export type FrontComponent = { __typename?: 'FrontComponent'; applicationId: Scalars['UUID']; + builtComponentChecksum: Scalars['String']; + builtComponentPath: Scalars['String']; + componentName: Scalars['String']; createdAt: Scalars['DateTime']; + description?: Maybe; id: Scalars['UUID']; name: Scalars['String']; + sourceComponentPath: Scalars['String']; + universalIdentifier?: Maybe; updatedAt: Scalars['DateTime']; }; @@ -4535,6 +4546,7 @@ export type UpdateFrontComponentInput = { }; export type UpdateFrontComponentInputUpdates = { + description?: InputMaybe; name?: InputMaybe; }; diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts index 76519a6a23..911449e7bd 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts @@ -5,7 +5,7 @@ type JsonManifestInput = { [key: string]: unknown; }>; frontComponents?: Array<{ - builtComponentChecksum?: string | null; + builtComponentChecksum?: string; [key: string]: unknown; }>; [key: string]: unknown; @@ -24,7 +24,7 @@ export const normalizeManifestForComparison = ( ...component, builtComponentChecksum: component.builtComponentChecksum ? '[checksum]' - : null, + : '', })), sources: {}, // removing sources for now, waiting compressed file implementation }); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index 20c3a4f7f8..72e13c1ee4 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -195,7 +195,7 @@ export const buildManifest = async ( componentName: component.name, sourceComponentPath: relativeFilePath, builtComponentPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'), - builtComponentChecksum: null, + builtComponentChecksum: '', }; frontComponents.push(config); diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1770309316193-add-front-component-columns.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770309316193-add-front-component-columns.ts new file mode 100644 index 0000000000..f61e18c424 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770309316193-add-front-component-columns.ts @@ -0,0 +1,43 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddFrontComponentColumns1770309316193 + implements MigrationInterface +{ + name = 'AddFrontComponentColumns1770309316193'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" ADD "description" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" ADD "sourceComponentPath" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" ADD "builtComponentPath" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" ADD "componentName" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" ADD "builtComponentChecksum" character varying NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" DROP COLUMN "builtComponentChecksum"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" DROP COLUMN "componentName"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" DROP COLUMN "builtComponentPath"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" DROP COLUMN "sourceComponentPath"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."frontComponent" DROP COLUMN "description"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts index be57e331c8..5d5b0965f3 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts @@ -20,6 +20,7 @@ export class ApplicationExceptionFilter implements ExceptionFilter { case ApplicationExceptionCode.ENTITY_NOT_FOUND: case ApplicationExceptionCode.APPLICATION_NOT_FOUND: case ApplicationExceptionCode.LOGIC_FUNCTION_NOT_FOUND: + case ApplicationExceptionCode.FRONT_COMPONENT_NOT_FOUND: throw new NotFoundError(exception); case ApplicationExceptionCode.FORBIDDEN: case ApplicationExceptionCode.INVALID_INPUT: diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts index fd4402b411..1d9f0fc8f1 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.module.ts @@ -11,6 +11,7 @@ import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { FrontComponentModule } from 'src/engine/metadata-modules/front-component/front-component.module'; import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module'; @@ -21,8 +22,8 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; -import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/code-step-build.module'; import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; +import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/code-step-build.module'; @Module({ imports: [ @@ -44,6 +45,7 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo FileStorageModule, WorkspaceCacheModule, WorkspaceMigrationRunnerModule, + FrontComponentModule, ], providers: [ ApplicationResolver, diff --git a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts index 7ef578ac40..68ce429a70 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts @@ -8,6 +8,7 @@ export enum ApplicationExceptionCode { OBJECT_NOT_FOUND = 'OBJECT_NOT_FOUND', FIELD_NOT_FOUND = 'FIELD_NOT_FOUND', LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND', + FRONT_COMPONENT_NOT_FOUND = 'FRONT_COMPONENT_NOT_FOUND', ENTITY_NOT_FOUND = 'ENTITY_NOT_FOUND', APPLICATION_NOT_FOUND = 'APPLICATION_NOT_FOUND', FORBIDDEN = 'FORBIDDEN', @@ -24,6 +25,8 @@ const getApplicationExceptionUserFriendlyMessage = ( return msg`Field not found.`; case ApplicationExceptionCode.LOGIC_FUNCTION_NOT_FOUND: return msg`Logic function not found.`; + case ApplicationExceptionCode.FRONT_COMPONENT_NOT_FOUND: + return msg`Front component not found.`; case ApplicationExceptionCode.ENTITY_NOT_FOUND: return msg`Entity not found.`; case ApplicationExceptionCode.APPLICATION_NOT_FOUND: diff --git a/packages/twenty-server/src/engine/core-modules/application/services/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/services/application-sync.service.ts index 214ec00efc..0a26727454 100644 --- a/packages/twenty-server/src/engine/core-modules/application/services/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/services/application-sync.service.ts @@ -4,6 +4,7 @@ import { parse } from 'path'; import { FieldManifest, + FrontComponentManifest, LogicFunctionManifest, Manifest, ObjectFieldManifest, @@ -34,7 +35,9 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ import { findFlatEntitiesByApplicationId } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entities-by-application-id.util'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service'; import { LogicFunctionService } from 'src/engine/metadata-modules/logic-function/services/logic-function.service'; import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; @@ -64,6 +67,7 @@ export class ApplicationSyncService { private readonly fieldPermissionService: FieldPermissionService, private readonly permissionService: PermissionFlagService, private readonly fileStorageService: FileStorageService, + private readonly frontComponentService: FrontComponentService, ) {} public async synchronizeFromManifest({ @@ -108,6 +112,14 @@ export class ApplicationSyncService { }); } + if ((manifest.frontComponents ?? []).length > 0) { + await this.syncFrontComponents({ + frontComponentsToSync: manifest.frontComponents, + workspaceId, + ownerFlatApplication, + }); + } + await this.syncRoles({ manifest, workspaceId, @@ -948,6 +960,103 @@ export class ApplicationSyncService { } } + private async syncFrontComponents({ + frontComponentsToSync, + workspaceId, + ownerFlatApplication, + }: { + frontComponentsToSync: FrontComponentManifest[]; + workspaceId: string; + ownerFlatApplication: FlatApplication; + }) { + const { flatFrontComponentMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatFrontComponentMaps'], + }, + ); + + const applicationFrontComponents = Object.values( + flatFrontComponentMaps.byUniversalIdentifier, + ).filter( + (frontComponent) => + isDefined(frontComponent) && + frontComponent.applicationId === ownerFlatApplication.id, + ) as FlatFrontComponent[]; + + const frontComponentsToSyncUniversalIdentifiers = frontComponentsToSync.map( + (frontComponent) => frontComponent.universalIdentifier, + ); + + const applicationFrontComponentsUniversalIdentifiers = + applicationFrontComponents.map( + (frontComponent) => frontComponent.universalIdentifier, + ); + + const frontComponentsToDelete = applicationFrontComponents.filter( + (frontComponent) => + isDefined(frontComponent.universalIdentifier) && + !frontComponentsToSyncUniversalIdentifiers.includes( + frontComponent.universalIdentifier, + ), + ); + + const frontComponentsToUpdate = applicationFrontComponents.filter( + (frontComponent) => + isDefined(frontComponent.universalIdentifier) && + frontComponentsToSyncUniversalIdentifiers.includes( + frontComponent.universalIdentifier, + ), + ); + + const frontComponentsToCreate = frontComponentsToSync.filter( + (frontComponentToSync) => + !applicationFrontComponentsUniversalIdentifiers.includes( + frontComponentToSync.universalIdentifier, + ), + ); + + for (const frontComponentToDelete of frontComponentsToDelete) { + await this.frontComponentService.destroyOne({ + id: frontComponentToDelete.id, + workspaceId, + isSystemBuild: true, + ownerFlatApplication, + }); + } + + for (const frontComponentToUpdate of frontComponentsToUpdate) { + const frontComponentToSync = frontComponentsToSync.find( + (frontComponent) => + frontComponent.universalIdentifier === + frontComponentToUpdate.universalIdentifier, + ); + + if (!frontComponentToSync) { + throw new ApplicationException( + `Failed to find frontComponent to sync with universalIdentifier ${frontComponentToUpdate.universalIdentifier}`, + ApplicationExceptionCode.FRONT_COMPONENT_NOT_FOUND, + ); + } + + await this.frontComponentService.updateOne({ + id: frontComponentToUpdate.id, + update: frontComponentToSync, + workspaceId, + ownerFlatApplication, + }); + } + + for (const frontComponentToCreate of frontComponentsToCreate) { + await this.frontComponentService.createOne({ + input: frontComponentToCreate, + workspaceId, + ownerFlatApplication, + }); + } + } + public async uninstallApplication({ workspaceId, applicationUniversalIdentifier, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant.ts index b3f904377d..879af71aaa 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant.ts @@ -2,4 +2,9 @@ import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front- export const FLAT_FRONT_COMPONENT_EDITABLE_PROPERTIES = [ 'name', + 'description', + 'builtComponentChecksum', + 'sourceComponentPath', + 'builtComponentPath', + 'componentName', ] as const satisfies (keyof FlatFrontComponent)[]; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-create-front-component-input-to-flat-front-component-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-create-front-component-input-to-flat-front-component-to-create.util.ts index 4e1854bdd5..c5e8c5579c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-create-front-component-input-to-flat-front-component-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-create-front-component-input-to-flat-front-component-to-create.util.ts @@ -22,14 +22,21 @@ export const fromCreateFrontComponentInputToFlatFrontComponentToCreate = ({ ); const id = createFrontComponentInput.id ?? v4(); + const universalIdentifier = + createFrontComponentInput.universalIdentifier ?? v4(); return { id, - name, + name: name ?? createFrontComponentInput.componentName, + description: createFrontComponentInput.description ?? null, + sourceComponentPath: createFrontComponentInput.sourceComponentPath, + builtComponentPath: createFrontComponentInput.builtComponentPath, + componentName: createFrontComponentInput.componentName, + builtComponentChecksum: createFrontComponentInput.builtComponentChecksum, workspaceId, createdAt: now, updatedAt: now, - universalIdentifier: id, + universalIdentifier, applicationId: flatApplication.id, applicationUniversalIdentifier: flatApplication.universalIdentifier, }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util.ts index 7a66631c63..d6a17c056e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util.ts @@ -1,3 +1,5 @@ +import { isDefined } from 'twenty-shared/utils'; + import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; import { type FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; @@ -6,6 +8,16 @@ export const fromFlatFrontComponentToFrontComponentDto = ( ): FrontComponentDTO => ({ id: flatFrontComponent.id, name: flatFrontComponent.name, + description: isDefined(flatFrontComponent.description) + ? flatFrontComponent.description + : undefined, + sourceComponentPath: flatFrontComponent.sourceComponentPath, + builtComponentPath: flatFrontComponent.builtComponentPath, + componentName: flatFrontComponent.componentName, + builtComponentChecksum: flatFrontComponent.builtComponentChecksum, + universalIdentifier: isDefined(flatFrontComponent.universalIdentifier) + ? flatFrontComponent.universalIdentifier + : undefined, workspaceId: flatFrontComponent.workspaceId, applicationId: flatFrontComponent.applicationId, createdAt: new Date(flatFrontComponent.createdAt), diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-front-component-entity-to-flat-front-component.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-front-component-entity-to-flat-front-component.util.ts index 04df5c94cf..388117df09 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-front-component-entity-to-flat-front-component.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-front-component/utils/from-front-component-entity-to-flat-front-component.util.ts @@ -26,6 +26,11 @@ export const fromFrontComponentEntityToFlatFrontComponent = ({ return { id: frontComponentEntity.id, name: frontComponentEntity.name, + description: frontComponentEntity.description, + sourceComponentPath: frontComponentEntity.sourceComponentPath, + builtComponentPath: frontComponentEntity.builtComponentPath, + componentName: frontComponentEntity.componentName, + builtComponentChecksum: frontComponentEntity.builtComponentChecksum, workspaceId: frontComponentEntity.workspaceId, universalIdentifier: frontComponentEntity.universalIdentifier, applicationId: frontComponentEntity.applicationId, diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/create-front-component.input.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/create-front-component.input.ts index a5f2961bff..f6842bd5ad 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/create-front-component.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/create-front-component.input.ts @@ -1,4 +1,4 @@ -import { Field, InputType } from '@nestjs/graphql'; +import { Field, HideField, InputType } from '@nestjs/graphql'; import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; @@ -11,8 +11,37 @@ export class CreateFrontComponentInput { @Field(() => UUIDScalarType, { nullable: true }) id?: string; + @IsUUID() + @IsOptional() + @HideField() + universalIdentifier?: string; + + @IsString() + @IsOptional() + @Field() + name?: string; + + @IsString() + @IsOptional() + @Field({ nullable: true }) + description?: string; + @IsString() @IsNotEmpty() @Field() - name: string; + sourceComponentPath: string; + + @IsString() + @IsNotEmpty() + @Field() + builtComponentPath: string; + + @IsString() + @Field() + componentName: string; + + @IsString() + @IsNotEmpty() + @Field() + builtComponentChecksum: string; } diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts index f773b1a265..9b6a9adf73 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/front-component.dto.ts @@ -1,6 +1,12 @@ import { Field, HideField, ObjectType } from '@nestjs/graphql'; -import { IsDateString, IsNotEmpty, IsString, IsUUID } from 'class-validator'; +import { + IsDateString, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -15,6 +21,33 @@ export class FrontComponentDTO { @Field() name: string; + @IsString() + @IsOptional() + @Field({ nullable: true }) + description?: string; + + @IsString() + @Field() + sourceComponentPath: string; + + @IsString() + @Field() + builtComponentPath: string; + + @IsString() + @Field() + componentName: string; + + @IsString() + @IsNotEmpty() + @Field() + builtComponentChecksum: string; + + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + universalIdentifier?: string; + @HideField() workspaceId: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/update-front-component.input.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/update-front-component.input.ts index 561b8eb41a..0cb2e75548 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/update-front-component.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/dtos/update-front-component.input.ts @@ -1,4 +1,4 @@ -import { Field, InputType } from '@nestjs/graphql'; +import { Field, HideField, InputType } from '@nestjs/graphql'; import { Type } from 'class-transformer'; import { @@ -17,6 +17,16 @@ export class UpdateFrontComponentInputUpdates { @IsString() @Field({ nullable: true }) name?: string; + + @IsOptional() + @IsString() + @Field({ nullable: true }) + description?: string; + + @IsOptional() + @IsString() + @HideField() + builtComponentChecksum?: string; } @InputType() diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/entities/front-component.entity.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/entities/front-component.entity.ts index 3ad44915b0..61a286a87d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/entities/front-component.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/entities/front-component.entity.ts @@ -9,13 +9,31 @@ import { import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; @Entity('frontComponent') -export class FrontComponentEntity extends SyncableEntity { +export class FrontComponentEntity + extends SyncableEntity + implements Required +{ @PrimaryGeneratedColumn('uuid') id: string; @Column({ nullable: false }) name: string; + @Column({ nullable: true, type: 'varchar' }) + description: string | null; + + @Column({ nullable: false }) + sourceComponentPath: string; + + @Column({ nullable: false }) + builtComponentPath: string; + + @Column({ nullable: false }) + componentName: string; + + @Column({ nullable: false }) + builtComponentChecksum: string; + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.exception.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.exception.ts index 15c52c161f..df4188203f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.exception.ts @@ -8,6 +8,8 @@ export enum FrontComponentExceptionCode { FRONT_COMPONENT_NOT_FOUND = 'FRONT_COMPONENT_NOT_FOUND', FRONT_COMPONENT_ALREADY_EXISTS = 'FRONT_COMPONENT_ALREADY_EXISTS', INVALID_FRONT_COMPONENT_INPUT = 'INVALID_FRONT_COMPONENT_INPUT', + FRONT_COMPONENT_CREATE_FAILED = 'FRONT_COMPONENT_CREATE_FAILED', + FRONT_COMPONENT_NOT_READY = 'FRONT_COMPONENT_NOT_READY', } const getFrontComponentExceptionUserFriendlyMessage = ( @@ -20,6 +22,10 @@ const getFrontComponentExceptionUserFriendlyMessage = ( return msg`A front component with this name already exists.`; case FrontComponentExceptionCode.INVALID_FRONT_COMPONENT_INPUT: return msg`Invalid front component input.`; + case FrontComponentExceptionCode.FRONT_COMPONENT_CREATE_FAILED: + return msg`Failed to create front component.`; + case FrontComponentExceptionCode.FRONT_COMPONENT_NOT_READY: + return msg`Front component is not ready.`; default: assertUnreachable(code); } diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts index b74139e235..59baff19d0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts @@ -9,6 +9,7 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { fromFlatFrontComponentToFrontComponentDto } from 'src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util'; import { CreateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/create-front-component.input'; import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto'; import { UpdateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/update-front-component.input'; @@ -48,7 +49,12 @@ export class FrontComponentResolver { @Args('input') input: CreateFrontComponentInput, @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { - return await this.frontComponentService.create(input, workspace.id); + const flatFrontComponent = await this.frontComponentService.createOne({ + input, + workspaceId: workspace.id, + }); + + return fromFlatFrontComponentToFrontComponentDto(flatFrontComponent); } @Mutation(() => FrontComponentDTO) @@ -57,7 +63,13 @@ export class FrontComponentResolver { @Args('input') input: UpdateFrontComponentInput, @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { - return await this.frontComponentService.update(input, workspace.id); + const flatFrontComponent = await this.frontComponentService.updateOne({ + id: input.id, + update: input.update, + workspaceId: workspace.id, + }); + + return fromFlatFrontComponentToFrontComponentDto(flatFrontComponent); } @Mutation(() => FrontComponentDTO) @@ -66,6 +78,11 @@ export class FrontComponentResolver { @Args('id', { type: () => UUIDScalarType }) id: string, @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { - return await this.frontComponentService.delete(id, workspace.id); + const flatFrontComponent = await this.frontComponentService.destroyOne({ + id, + workspaceId: workspace.id, + }); + + return fromFlatFrontComponentToFrontComponentDto(flatFrontComponent); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts index 3ef572f720..569528969d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts @@ -3,11 +3,12 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; import { fromCreateFrontComponentInputToFlatFrontComponentToCreate } from 'src/engine/metadata-modules/flat-front-component/utils/from-create-front-component-input-to-flat-front-component-to-create.util'; -import { fromDeleteFrontComponentInputToFlatFrontComponentOrThrow } from 'src/engine/metadata-modules/flat-front-component/utils/from-delete-front-component-input-to-flat-front-component-or-throw.util'; import { fromFlatFrontComponentToFrontComponentDto } from 'src/engine/metadata-modules/flat-front-component/utils/from-flat-front-component-to-front-component-dto.util'; import { fromUpdateFrontComponentInputToFlatFrontComponentToUpdateOrThrow } from 'src/engine/metadata-modules/flat-front-component/utils/from-update-front-component-input-to-flat-front-component-to-update-or-throw.util'; import { type CreateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/create-front-component.input'; @@ -67,20 +68,28 @@ export class FrontComponentService { return fromFlatFrontComponentToFrontComponentDto(flatFrontComponent); } - async create( - input: CreateFrontComponentInput, - workspaceId: string, - ): Promise { - const { workspaceCustomFlatApplication } = - await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( - { workspaceId }, - ); + async createOne({ + input, + workspaceId, + ownerFlatApplication, + }: { + input: Omit; + workspaceId: string; + ownerFlatApplication?: FlatApplication; + }): Promise { + const resolvedOwnerFlatApplication = + ownerFlatApplication ?? + ( + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ) + ).workspaceCustomFlatApplication; const flatFrontComponentToCreate = fromCreateFrontComponentInputToFlatFrontComponentToCreate({ createFrontComponentInput: input, workspaceId, - flatApplication: workspaceCustomFlatApplication, + flatApplication: resolvedOwnerFlatApplication, }); const validateAndBuildResult = @@ -96,7 +105,7 @@ export class FrontComponentService { workspaceId, isSystemBuild: false, applicationUniversalIdentifier: - workspaceCustomFlatApplication.universalIdentifier, + resolvedOwnerFlatApplication.universalIdentifier, }, ); @@ -115,22 +124,30 @@ export class FrontComponentService { }, ); - return fromFlatFrontComponentToFrontComponentDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: flatFrontComponentToCreate.id, - flatEntityMaps: recomputedFlatFrontComponentMaps, - }), - ); + return findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: flatFrontComponentToCreate.id, + flatEntityMaps: recomputedFlatFrontComponentMaps, + }); } - async update( - input: UpdateFrontComponentInput, - workspaceId: string, - ): Promise { - const { workspaceCustomFlatApplication } = - await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( - { workspaceId }, - ); + async updateOne({ + id, + update, + workspaceId, + ownerFlatApplication, + }: { + id: string; + update: UpdateFrontComponentInput['update']; + workspaceId: string; + ownerFlatApplication?: FlatApplication; + }): Promise { + const resolvedOwnerFlatApplication = + ownerFlatApplication ?? + ( + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ) + ).workspaceCustomFlatApplication; const { flatFrontComponentMaps: existingFlatFrontComponentMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( @@ -143,7 +160,7 @@ export class FrontComponentService { const flatFrontComponentToUpdate = fromUpdateFrontComponentInputToFlatFrontComponentToUpdateOrThrow({ flatFrontComponentMaps: existingFlatFrontComponentMaps, - updateFrontComponentInput: input, + updateFrontComponentInput: { id, update }, }); const validateAndBuildResult = @@ -159,7 +176,7 @@ export class FrontComponentService { workspaceId, isSystemBuild: false, applicationUniversalIdentifier: - workspaceCustomFlatApplication.universalIdentifier, + resolvedOwnerFlatApplication.universalIdentifier, }, ); @@ -178,19 +195,30 @@ export class FrontComponentService { }, ); - return fromFlatFrontComponentToFrontComponentDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: input.id, - flatEntityMaps: recomputedFlatFrontComponentMaps, - }), - ); + return findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: id, + flatEntityMaps: recomputedFlatFrontComponentMaps, + }); } - async delete(id: string, workspaceId: string): Promise { - const { workspaceCustomFlatApplication } = - await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( - { workspaceId }, - ); + async destroyOne({ + id, + workspaceId, + isSystemBuild = false, + ownerFlatApplication, + }: { + id: string; + workspaceId: string; + isSystemBuild?: boolean; + ownerFlatApplication?: FlatApplication; + }): Promise { + const resolvedOwnerFlatApplication = + ownerFlatApplication ?? + ( + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ) + ).workspaceCustomFlatApplication; const { flatFrontComponentMaps: existingFlatFrontComponentMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( @@ -200,11 +228,17 @@ export class FrontComponentService { }, ); - const flatFrontComponentToDelete = - fromDeleteFrontComponentInputToFlatFrontComponentOrThrow({ - flatFrontComponentMaps: existingFlatFrontComponentMaps, - frontComponentId: id, - }); + const existingFlatFrontComponent = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: id, + flatEntityMaps: existingFlatFrontComponentMaps, + }); + + if (!isDefined(existingFlatFrontComponent)) { + throw new FrontComponentException( + 'Front component to destroy not found', + FrontComponentExceptionCode.FRONT_COMPONENT_NOT_FOUND, + ); + } const validateAndBuildResult = await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( @@ -212,27 +246,25 @@ export class FrontComponentService { allFlatEntityOperationByMetadataName: { frontComponent: { flatEntityToCreate: [], - flatEntityToDelete: [flatFrontComponentToDelete], + flatEntityToDelete: [existingFlatFrontComponent], flatEntityToUpdate: [], }, }, workspaceId, - isSystemBuild: false, + isSystemBuild, applicationUniversalIdentifier: - workspaceCustomFlatApplication.universalIdentifier, + resolvedOwnerFlatApplication.universalIdentifier, }, ); if (isDefined(validateAndBuildResult)) { throw new WorkspaceMigrationBuilderException( validateAndBuildResult, - 'Multiple validation errors occurred while deleting front component', + 'Multiple validation errors occurred while destroying front component', ); } - return fromFlatFrontComponentToFrontComponentDto( - flatFrontComponentToDelete, - ); + return existingFlatFrontComponent; } async findByIdOrThrow( diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/front-component-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/front-component-graphql-api-exception-handler.util.ts index b33f42a2d3..21f2f98860 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/front-component-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/front-component-graphql-api-exception-handler.util.ts @@ -2,6 +2,7 @@ import { assertUnreachable } from 'twenty-shared/utils'; import { ConflictError, + ForbiddenError, NotFoundError, UserInputError, } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; @@ -19,6 +20,10 @@ export const frontComponentGraphqlApiExceptionHandler = (error: Error) => { throw new UserInputError(error); case FrontComponentExceptionCode.FRONT_COMPONENT_ALREADY_EXISTS: throw new ConflictError(error); + case FrontComponentExceptionCode.FRONT_COMPONENT_NOT_READY: + throw new ForbiddenError(error); + case FrontComponentExceptionCode.FRONT_COMPONENT_CREATE_FAILED: + throw error; default: { return assertUnreachable(error.code); } diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-front-component-and-command-menu-item-data-seeds.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-front-component-and-command-menu-item-data-seeds.util.ts index a6b90c82db..781f2809f5 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-front-component-and-command-menu-item-data-seeds.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/get-front-component-and-command-menu-item-data-seeds.util.ts @@ -11,6 +11,10 @@ type FrontComponentSeed = { workspaceId: string; universalIdentifier: string; applicationId: string; + sourceComponentPath: string; + builtComponentPath: string; + componentName: string; + builtComponentChecksum: string; }; type CommandMenuItemSeed = { @@ -48,6 +52,10 @@ export const getFrontComponentAndCommandMenuItemDataSeeds = ( workspaceId, universalIdentifier: frontComponentId, applicationId, + sourceComponentPath: 'src/front-components/demo-app.tsx', + builtComponentPath: 'src/front-components/demo-app.mjs', + componentName: 'DemoApp', + builtComponentChecksum: '1234567890', }, ]; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-front-components-and-command-menu-items.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-front-components-and-command-menu-items.util.ts index bad59d67d4..9eea84a636 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-front-components-and-command-menu-items.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-front-components-and-command-menu-items.util.ts @@ -28,6 +28,10 @@ export const seedFrontComponentsAndCommandMenuItems = async ({ 'workspaceId', 'universalIdentifier', 'applicationId', + 'sourceComponentPath', + 'builtComponentPath', + 'componentName', + 'builtComponentChecksum', ]) .values( frontComponents.map((row) => ({ @@ -36,6 +40,10 @@ export const seedFrontComponentsAndCommandMenuItems = async ({ workspaceId: row.workspaceId, universalIdentifier: row.universalIdentifier, applicationId: row.applicationId, + sourceComponentPath: row.sourceComponentPath, + builtComponentPath: row.builtComponentPath, + componentName: row.componentName, + builtComponentChecksum: row.builtComponentChecksum, })), ) .orIgnore() diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service.ts index ada32ee78b..bc80484b88 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service.ts @@ -1,8 +1,16 @@ import { Injectable } from '@nestjs/common'; +import { FileFolder } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity'; +import { + FrontComponentException, + FrontComponentExceptionCode, +} from 'src/engine/metadata-modules/front-component/front-component.exception'; import { FlatCreateFrontComponentAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-action.type'; import { WorkspaceMigrationActionRunnerArgs, @@ -14,7 +22,7 @@ export class CreateFrontComponentActionHandlerService extends WorkspaceMigration 'create', 'frontComponent', ) { - constructor() { + constructor(private readonly fileStorageService: FileStorageService) { super(); } @@ -27,8 +35,18 @@ export class CreateFrontComponentActionHandlerService extends WorkspaceMigration async executeForMetadata( context: WorkspaceMigrationActionRunnerContext, ): Promise { - const { flatAction, queryRunner, workspaceId } = context; - const { flatEntity } = flatAction; + const { flatAction, queryRunner, workspaceId, flatApplication } = context; + const { flatEntity: frontComponent } = flatAction; + + const applicationUniversalIdentifier = flatApplication.universalIdentifier; + + if (isDefined(frontComponent.builtComponentChecksum)) { + await this.verifySourceAndBuiltFilesExist({ + workspaceId, + applicationUniversalIdentifier, + builtComponentPath: frontComponent.builtComponentPath, + }); + } const frontComponentRepository = queryRunner.manager.getRepository( @@ -36,11 +54,35 @@ export class CreateFrontComponentActionHandlerService extends WorkspaceMigration ); await frontComponentRepository.insert({ - ...flatEntity, + ...frontComponent, workspaceId, }); } + private async verifySourceAndBuiltFilesExist({ + workspaceId, + applicationUniversalIdentifier, + builtComponentPath, + }: { + workspaceId: string; + applicationUniversalIdentifier: string; + builtComponentPath: string; + }): Promise { + const builtExists = await this.fileStorageService.checkFileExists_v2({ + workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.BuiltFrontComponent, + resourcePath: builtComponentPath, + }); + + if (!builtExists) { + throw new FrontComponentException( + `Front component built file missing before create (built: ${builtExists})`, + FrontComponentExceptionCode.FRONT_COMPONENT_CREATE_FAILED, + ); + } + } + async executeForWorkspaceSchema( _context: WorkspaceMigrationActionRunnerContext, ): Promise { diff --git a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts index 1d1728a56b..d8a578d71b 100644 --- a/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/command-menu-item/successful-command-menu-item-creation.integration-spec.ts @@ -3,6 +3,7 @@ import { createCommandMenuItem } from 'test/integration/metadata/suites/command- import { deleteCommandMenuItem } from 'test/integration/metadata/suites/command-menu-item/utils/delete-command-menu-item.util'; import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util'; import { deleteFrontComponent } from 'test/integration/metadata/suites/front-component/utils/delete-front-component.util'; +import { seedBuiltFrontComponentFile } from 'test/integration/metadata/suites/front-component/utils/seed-built-front-component-file.util'; import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util'; import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; @@ -13,6 +14,7 @@ import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/com describe('CommandMenuItem creation should succeed', () => { let createdCommandMenuItemId: string; let createdFrontComponentId: string | undefined; + let cleanupBuiltFile: (() => void) | undefined; let companyObjectMetadataId: string; let personObjectMetadataId: string; @@ -23,6 +25,12 @@ describe('CommandMenuItem creation should succeed', () => { expectToFail: false, }); + const { cleanup } = await seedBuiltFrontComponentFile({ + builtComponentPath: 'src/front-components/index.mjs', + }); + + cleanupBuiltFile = cleanup; + const { objects } = await findManyObjectMetadata({ expectToFail: false, input: { @@ -52,6 +60,8 @@ describe('CommandMenuItem creation should succeed', () => { }); afterAll(async () => { + cleanupBuiltFile?.(); + await updateFeatureFlag({ featureFlag: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, value: false, @@ -176,7 +186,13 @@ describe('CommandMenuItem creation should succeed', () => { it('should create command menu item with frontComponentId', async () => { const { data: frontComponentData } = await createFrontComponent({ expectToFail: false, - input: { name: 'Test Front Component' }, + input: { + name: 'Test Front Component', + componentName: 'TestFrontComponent', + sourceComponentPath: 'src/front-components/index.tsx', + builtComponentPath: 'src/front-components/index.mjs', + builtComponentChecksum: 'abc123', + }, }); createdFrontComponentId = frontComponentData?.createFrontComponent?.id; diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap index 9bbf4c1ff5..aa90efb671 100644 --- a/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-creation.integration-spec.ts.snap @@ -37,20 +37,6 @@ exports[`Front component creation should fail when name is empty 1`] = ` } `; -exports[`Front component creation should fail when name is too long 1`] = ` -{ - "extensions": { - "code": "BAD_USER_INPUT", - "http": { - "status": 400, - }, - "userFriendlyMessage": "An error occurred.", - }, - "message": "Expected non-nullable type "String!" not to be null.", - "name": "GraphQLError", -} -`; - exports[`Front component creation should fail when name is whitespace-only 1`] = ` { "extensions": { diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-deletion.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-deletion.integration-spec.ts.snap index 00b996b093..74047c0859 100644 --- a/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-deletion.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/__snapshots__/failing-front-component-deletion.integration-spec.ts.snap @@ -7,7 +7,7 @@ exports[`Front component deletion should fail when front component does not exis "subCode": "FRONT_COMPONENT_NOT_FOUND", "userFriendlyMessage": "Front component not found.", }, - "message": "Front component not found", + "message": "Front component to destroy not found", "name": "NotFoundError", } `; diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts index 01ab086ec4..e1fcc8c5df 100644 --- a/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/failing-front-component-creation.integration-spec.ts @@ -1,5 +1,6 @@ import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util'; +import { seedBuiltFrontComponentFile } from 'test/integration/metadata/suites/front-component/utils/seed-built-front-component-file.util'; import { type EachTestingContext, eachTestingContextFilter, @@ -8,39 +9,57 @@ import { import { type CreateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/create-front-component.input'; type TestContext = { - name: string | null; + input: CreateFrontComponentInput; }; const FAILING_TEST_CASES: EachTestingContext[] = [ { title: 'when name is empty', context: { - name: '', + input: { + name: '', + componentName: 'TestComponent', + sourceComponentPath: 'src/front-components/index.tsx', + builtComponentPath: 'src/front-components/index.mjs', + builtComponentChecksum: 'abc123', + }, }, }, { title: 'when name is whitespace-only', context: { - name: ' ', - }, - }, - { - title: 'when name is too long', - context: { - name: null, + input: { + name: ' ', + componentName: 'TestComponent', + sourceComponentPath: 'src/front-components/index.tsx', + builtComponentPath: 'src/front-components/index.mjs', + builtComponentChecksum: 'abc123', + }, }, }, ]; describe('Front component creation should fail', () => { + let cleanupBuiltFile: (() => void) | undefined; + + beforeAll(async () => { + const { cleanup } = await seedBuiltFrontComponentFile({ + builtComponentPath: 'src/front-components/index.mjs', + }); + + cleanupBuiltFile = cleanup; + }); + + afterAll(() => { + cleanupBuiltFile?.(); + }); + it.each(eachTestingContextFilter(FAILING_TEST_CASES))( '$title', async ({ context }) => { const { errors } = await createFrontComponent({ expectToFail: true, - input: { - name: context.name, - } as CreateFrontComponentInput, + input: context.input, }); expectOneNotInternalServerErrorSnapshot({ errors }); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-creation.integration-spec.ts index 0e80866890..074c0b7642 100644 --- a/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-creation.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-creation.integration-spec.ts @@ -1,8 +1,22 @@ import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util'; import { deleteFrontComponent } from 'test/integration/metadata/suites/front-component/utils/delete-front-component.util'; +import { seedBuiltFrontComponentFile } from 'test/integration/metadata/suites/front-component/utils/seed-built-front-component-file.util'; describe('Front component creation should succeed', () => { let createdFrontComponentId: string | undefined; + let cleanupBuiltFile: (() => void) | undefined; + + beforeAll(async () => { + const { cleanup } = await seedBuiltFrontComponentFile({ + builtComponentPath: 'src/front-components/index.mjs', + }); + + cleanupBuiltFile = cleanup; + }); + + afterAll(() => { + cleanupBuiltFile?.(); + }); afterEach(async () => { if (createdFrontComponentId) { @@ -19,6 +33,10 @@ describe('Front component creation should succeed', () => { expectToFail: false, input: { name: 'testFrontComponent', + componentName: 'TestFrontComponent', + sourceComponentPath: 'src/front-components/index.tsx', + builtComponentPath: 'src/front-components/index.mjs', + builtComponentChecksum: 'abc123', }, }); @@ -35,6 +53,10 @@ describe('Front component creation should succeed', () => { expectToFail: false, input: { name: ' frontComponentWithSpaces ', + componentName: 'FrontComponentWithSpaces', + sourceComponentPath: 'src/front-components/index.tsx', + builtComponentPath: 'src/front-components/index.mjs', + builtComponentChecksum: 'abc123', }, }); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-deletion.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-deletion.integration-spec.ts index 7c088e7bda..2a84be9ec3 100644 --- a/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-deletion.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-deletion.integration-spec.ts @@ -1,13 +1,32 @@ import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util'; import { deleteFrontComponent } from 'test/integration/metadata/suites/front-component/utils/delete-front-component.util'; import { findFrontComponent } from 'test/integration/metadata/suites/front-component/utils/find-front-component.util'; +import { seedBuiltFrontComponentFile } from 'test/integration/metadata/suites/front-component/utils/seed-built-front-component-file.util'; describe('Front component deletion should succeed', () => { + let cleanupBuiltFile: (() => void) | undefined; + + beforeAll(async () => { + const { cleanup } = await seedBuiltFrontComponentFile({ + builtComponentPath: 'src/front-components/index.mjs', + }); + + cleanupBuiltFile = cleanup; + }); + + afterAll(() => { + cleanupBuiltFile?.(); + }); + it('should successfully delete a front component', async () => { const { data: createData } = await createFrontComponent({ expectToFail: false, input: { name: 'frontComponentToDelete', + componentName: 'FrontComponentToDelete', + sourceComponentPath: 'src/front-components/index.tsx', + builtComponentPath: 'src/front-components/index.mjs', + builtComponentChecksum: 'abc123', }, }); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-update.integration-spec.ts index 2d2b5d34a3..0d75cadd8d 100644 --- a/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-update.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-update.integration-spec.ts @@ -1,16 +1,34 @@ import { createFrontComponent } from 'test/integration/metadata/suites/front-component/utils/create-front-component.util'; import { deleteFrontComponent } from 'test/integration/metadata/suites/front-component/utils/delete-front-component.util'; +import { seedBuiltFrontComponentFile } from 'test/integration/metadata/suites/front-component/utils/seed-built-front-component-file.util'; import { updateFrontComponent } from 'test/integration/metadata/suites/front-component/utils/update-front-component.util'; import { isDefined } from 'twenty-shared/utils'; describe('Front component update should succeed', () => { let testFrontComponentId: string | undefined; + let cleanupBuiltFile: (() => void) | undefined; + + beforeAll(async () => { + const { cleanup } = await seedBuiltFrontComponentFile({ + builtComponentPath: 'src/front-components/index.mjs', + }); + + cleanupBuiltFile = cleanup; + }); + + afterAll(() => { + cleanupBuiltFile?.(); + }); beforeEach(async () => { const { data } = await createFrontComponent({ expectToFail: false, input: { name: 'testFrontComponentToUpdate', + componentName: 'TestFrontComponentToUpdate', + sourceComponentPath: 'src/front-components/index.tsx', + builtComponentPath: 'src/front-components/index.mjs', + builtComponentChecksum: 'abc123', }, }); diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/seed-built-front-component-file.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/seed-built-front-component-file.util.ts new file mode 100644 index 0000000000..bce0bbdd65 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/seed-built-front-component-file.util.ts @@ -0,0 +1,47 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +const STORAGE_LOCAL_PATH = join(process.cwd(), '.local-storage'); + +const getWorkspaceCustomApplicationUniversalIdentifier = async ( + workspaceId: string, +): Promise => { + const result = await global.testDataSource.query( + 'SELECT "workspaceCustomApplicationId" FROM core.workspace WHERE id = $1', + [workspaceId], + ); + + return result[0].workspaceCustomApplicationId; +}; + +export const seedBuiltFrontComponentFile = async ({ + workspaceId = SEED_APPLE_WORKSPACE_ID, + builtComponentPath, +}: { + workspaceId?: string; + builtComponentPath: string; +}): Promise<{ cleanup: () => void }> => { + const applicationUniversalIdentifier = + await getWorkspaceCustomApplicationUniversalIdentifier(workspaceId); + + const filePath = join( + STORAGE_LOCAL_PATH, + workspaceId, + applicationUniversalIdentifier, + 'built-front-component', + builtComponentPath, + ); + + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, 'dummy built component content'); + + return { + cleanup: () => { + if (existsSync(filePath)) { + rmSync(filePath); + } + }, + }; +}; diff --git a/packages/twenty-shared/src/application/frontComponentManifestType.ts b/packages/twenty-shared/src/application/frontComponentManifestType.ts index 5097520a5d..c076f81feb 100644 --- a/packages/twenty-shared/src/application/frontComponentManifestType.ts +++ b/packages/twenty-shared/src/application/frontComponentManifestType.ts @@ -4,6 +4,6 @@ export type FrontComponentManifest = { description?: string; sourceComponentPath: string; builtComponentPath: string; - builtComponentChecksum: string | null; + builtComponentChecksum: string; componentName: string; };