Add sync front component (#17748)

## Context
Allow twenty apps to sync front components
This commit is contained in:
Weiko
2026-02-09 00:00:23 +01:00
committed by GitHub
parent 40d7e740ef
commit 9aa63f7ddc
33 changed files with 643 additions and 97 deletions
@@ -975,8 +975,13 @@ export type CreateFieldInput = {
};
export type CreateFrontComponentInput = {
builtComponentChecksum: Scalars['String'];
builtComponentPath: Scalars['String'];
componentName: Scalars['String'];
description?: InputMaybe<Scalars['String']>;
id?: InputMaybe<Scalars['UUID']>;
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<Scalars['String']>;
id: Scalars['UUID'];
name: Scalars['String'];
sourceComponentPath: Scalars['String'];
universalIdentifier?: Maybe<Scalars['UUID']>;
updatedAt: Scalars['DateTime'];
};
@@ -4696,6 +4707,7 @@ export type UpdateFrontComponentInput = {
};
export type UpdateFrontComponentInputUpdates = {
description?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>;
};
@@ -971,8 +971,13 @@ export type CreateFieldInput = {
};
export type CreateFrontComponentInput = {
builtComponentChecksum: Scalars['String'];
builtComponentPath: Scalars['String'];
componentName: Scalars['String'];
description?: InputMaybe<Scalars['String']>;
id?: InputMaybe<Scalars['UUID']>;
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<Scalars['String']>;
id: Scalars['UUID'];
name: Scalars['String'];
sourceComponentPath: Scalars['String'];
universalIdentifier?: Maybe<Scalars['UUID']>;
updatedAt: Scalars['DateTime'];
};
@@ -4535,6 +4546,7 @@ export type UpdateFrontComponentInput = {
};
export type UpdateFrontComponentInputUpdates = {
description?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>;
};
@@ -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 = <T extends JsonManifestInput>(
...component,
builtComponentChecksum: component.builtComponentChecksum
? '[checksum]'
: null,
: '',
})),
sources: {}, // removing sources for now, waiting compressed file implementation
});
@@ -195,7 +195,7 @@ export const buildManifest = async (
componentName: component.name,
sourceComponentPath: relativeFilePath,
builtComponentPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'),
builtComponentChecksum: null,
builtComponentChecksum: '',
};
frontComponents.push(config);
@@ -0,0 +1,43 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddFrontComponentColumns1770309316193
implements MigrationInterface
{
name = 'AddFrontComponentColumns1770309316193';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`,
);
}
}
@@ -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:
@@ -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,
@@ -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:
@@ -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,
@@ -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)[];
@@ -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,
};
@@ -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),
@@ -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,
@@ -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;
}
@@ -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;
@@ -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()
@@ -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<FrontComponentEntity>
{
@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;
@@ -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);
}
@@ -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<FrontComponentDTO> {
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<FrontComponentDTO> {
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<FrontComponentDTO> {
return await this.frontComponentService.delete(id, workspace.id);
const flatFrontComponent = await this.frontComponentService.destroyOne({
id,
workspaceId: workspace.id,
});
return fromFlatFrontComponentToFrontComponentDto(flatFrontComponent);
}
}
@@ -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<FrontComponentDTO> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
async createOne({
input,
workspaceId,
ownerFlatApplication,
}: {
input: Omit<CreateFrontComponentInput, 'applicationId'>;
workspaceId: string;
ownerFlatApplication?: FlatApplication;
}): Promise<FlatFrontComponent> {
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<FrontComponentDTO> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
async updateOne({
id,
update,
workspaceId,
ownerFlatApplication,
}: {
id: string;
update: UpdateFrontComponentInput['update'];
workspaceId: string;
ownerFlatApplication?: FlatApplication;
}): Promise<FlatFrontComponent> {
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<FrontComponentDTO> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
async destroyOne({
id,
workspaceId,
isSystemBuild = false,
ownerFlatApplication,
}: {
id: string;
workspaceId: string;
isSystemBuild?: boolean;
ownerFlatApplication?: FlatApplication;
}): Promise<FlatFrontComponent> {
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(
@@ -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);
}
@@ -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',
},
];
@@ -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()
@@ -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<FlatCreateFrontComponentAction>,
): Promise<void> {
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<FrontComponentEntity>(
@@ -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<void> {
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<FlatCreateFrontComponentAction>,
): Promise<void> {
@@ -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;
@@ -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": {
@@ -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",
}
`;
@@ -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<TestContext>[] = [
{
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 });
@@ -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',
},
});
@@ -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',
},
});
@@ -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',
},
});
@@ -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<string> => {
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);
}
},
};
};
@@ -4,6 +4,6 @@ export type FrontComponentManifest = {
description?: string;
sourceComponentPath: string;
builtComponentPath: string;
builtComponentChecksum: string | null;
builtComponentChecksum: string;
componentName: string;
};