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
@@ -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);
}