diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 3d9731eab5a..9e29b38ec4f 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -695,6 +695,12 @@ export type CreateOneObjectInput = { object: CreateObjectInput; }; +export type CreatePageLayoutInput = { + name: Scalars['String']; + objectMetadataId?: InputMaybe; + type?: InputMaybe; +}; + export type CreateRemoteServerInput = { foreignDataWrapperOptions: Scalars['JSON']; foreignDataWrapperType: Scalars['String']; @@ -1371,6 +1377,7 @@ export type Mutation = { createOneRemoteServer: RemoteServer; createOneRole: Role; createOneServerlessFunction: ServerlessFunction; + createPageLayout: PageLayout; createSAMLIdentityProvider: SetupSsoOutput; createWebhook: Webhook; createWorkflowVersionEdge: WorkflowVersionStepChanges; @@ -1392,6 +1399,7 @@ export type Mutation = { deleteOneRemoteServer: RemoteServer; deleteOneRole: Scalars['String']; deleteOneServerlessFunction: ServerlessFunction; + deletePageLayout: PageLayout; deleteSSOIdentityProvider: DeleteSsoOutput; deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; @@ -1405,6 +1413,7 @@ export type Mutation = { destroyCoreViewFilterGroup: Scalars['Boolean']; destroyCoreViewGroup: Scalars['Boolean']; destroyCoreViewSort: Scalars['Boolean']; + destroyPageLayout: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -1428,6 +1437,7 @@ export type Mutation = { renewToken: AuthTokens; resendEmailVerificationToken: ResendEmailVerificationTokenOutput; resendWorkspaceInvitation: SendInvitationsOutput; + restorePageLayout: PageLayout; revokeApiKey?: Maybe; runWorkflowVersion: WorkflowRun; saveImapSmtpCaldavAccount: ImapSmtpCaldavConnectionSuccess; @@ -1460,6 +1470,7 @@ export type Mutation = { updateOneRemoteServer: RemoteServer; updateOneRole: Role; updateOneServerlessFunction: ServerlessFunction; + updatePageLayout: PageLayout; updatePasswordViaResetToken: InvalidatePassword; updateSubscriptionItemPrice: BillingUpdateOutput; updateWebhook?: Maybe; @@ -1638,6 +1649,11 @@ export type MutationCreateOneServerlessFunctionArgs = { }; +export type MutationCreatePageLayoutArgs = { + input: CreatePageLayoutInput; +}; + + export type MutationCreateSamlIdentityProviderArgs = { input: SetupSamlSsoInput; }; @@ -1738,6 +1754,11 @@ export type MutationDeleteOneServerlessFunctionArgs = { }; +export type MutationDeletePageLayoutArgs = { + id: Scalars['String']; +}; + + export type MutationDeleteSsoIdentityProviderArgs = { input: DeleteSsoInput; }; @@ -1798,6 +1819,11 @@ export type MutationDestroyCoreViewSortArgs = { }; +export type MutationDestroyPageLayoutArgs = { + id: Scalars['String']; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1907,6 +1933,11 @@ export type MutationResendWorkspaceInvitationArgs = { }; +export type MutationRestorePageLayoutArgs = { + id: Scalars['String']; +}; + + export type MutationRevokeApiKeyArgs = { input: RevokeApiKeyDto; }; @@ -2070,6 +2101,12 @@ export type MutationUpdateOneServerlessFunctionArgs = { }; +export type MutationUpdatePageLayoutArgs = { + id: Scalars['String']; + input: UpdatePageLayoutInput; +}; + + export type MutationUpdatePasswordViaResetTokenArgs = { newPassword: Scalars['String']; passwordResetToken: Scalars['String']; @@ -2336,6 +2373,36 @@ export type PageInfo = { startCursor?: Maybe; }; +export type PageLayout = { + __typename?: 'PageLayout'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['UUID']; + name: Scalars['String']; + objectMetadataId?: Maybe; + tabs?: Maybe>; + type: PageLayoutType; + updatedAt: Scalars['DateTime']; + workspaceId: Scalars['UUID']; +}; + +export type PageLayoutTab = { + __typename?: 'PageLayoutTab'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['UUID']; + pageLayoutId: Scalars['UUID']; + position: Scalars['Float']; + title: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + +export enum PageLayoutType { + DASHBOARD = 'DASHBOARD', + RECORD_INDEX = 'RECORD_INDEX', + RECORD_PAGE = 'RECORD_PAGE' +} + export type PermissionFlag = { __typename?: 'PermissionFlag'; flag: PermissionFlagType; @@ -2454,6 +2521,8 @@ export type Query = { getDatabaseConfigVariable: ConfigVariable; getIndicatorHealthStatus: AdminPanelHealthServiceData; getMeteredProductsUsage: Array; + getPageLayout?: Maybe; + getPageLayouts: Array; getPostgresCredentials?: Maybe; getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput; getQueueMetrics: QueueMetricsData; @@ -2662,6 +2731,16 @@ export type QueryGetIndicatorHealthStatusArgs = { }; +export type QueryGetPageLayoutArgs = { + id: Scalars['String']; +}; + + +export type QueryGetPageLayoutsArgs = { + objectMetadataId?: InputMaybe; +}; + + export type QueryGetPublicWorkspaceDataByDomainArgs = { origin?: InputMaybe; }; @@ -3273,6 +3352,12 @@ export type UpdateOneObjectInput = { update: UpdateObjectPayload; }; +export type UpdatePageLayoutInput = { + name?: InputMaybe; + objectMetadataId?: InputMaybe; + type?: InputMaybe; +}; + export type UpdateRemoteServerInput = { foreignDataWrapperOptions?: InputMaybe; id: Scalars['UUID']; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 41a007dc250..4ed084cdab1 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -667,6 +667,12 @@ export type CreateOneFieldMetadataInput = { field: CreateFieldInput; }; +export type CreatePageLayoutInput = { + name: Scalars['String']; + objectMetadataId?: InputMaybe; + type?: InputMaybe; +}; + export type CreateRoleInput = { canAccessAllTools?: InputMaybe; canDestroyAllObjectRecords?: InputMaybe; @@ -1327,6 +1333,7 @@ export type Mutation = { createOneObject: Object; createOneRole: Role; createOneServerlessFunction: ServerlessFunction; + createPageLayout: PageLayout; createSAMLIdentityProvider: SetupSsoOutput; createWebhook: Webhook; createWorkflowVersionEdge: WorkflowVersionStepChanges; @@ -1347,6 +1354,7 @@ export type Mutation = { deleteOneObject: Object; deleteOneRole: Scalars['String']; deleteOneServerlessFunction: ServerlessFunction; + deletePageLayout: PageLayout; deleteSSOIdentityProvider: DeleteSsoOutput; deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; @@ -1360,6 +1368,7 @@ export type Mutation = { destroyCoreViewFilterGroup: Scalars['Boolean']; destroyCoreViewGroup: Scalars['Boolean']; destroyCoreViewSort: Scalars['Boolean']; + destroyPageLayout: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -1383,6 +1392,7 @@ export type Mutation = { renewToken: AuthTokens; resendEmailVerificationToken: ResendEmailVerificationTokenOutput; resendWorkspaceInvitation: SendInvitationsOutput; + restorePageLayout: PageLayout; revokeApiKey?: Maybe; runWorkflowVersion: WorkflowRun; saveImapSmtpCaldavAccount: ImapSmtpCaldavConnectionSuccess; @@ -1411,6 +1421,7 @@ export type Mutation = { updateOneObject: Object; updateOneRole: Role; updateOneServerlessFunction: ServerlessFunction; + updatePageLayout: PageLayout; updatePasswordViaResetToken: InvalidatePassword; updateSubscriptionItemPrice: BillingUpdateOutput; updateWebhook?: Maybe; @@ -1574,6 +1585,11 @@ export type MutationCreateOneServerlessFunctionArgs = { }; +export type MutationCreatePageLayoutArgs = { + input: CreatePageLayoutInput; +}; + + export type MutationCreateSamlIdentityProviderArgs = { input: SetupSamlSsoInput; }; @@ -1669,6 +1685,11 @@ export type MutationDeleteOneServerlessFunctionArgs = { }; +export type MutationDeletePageLayoutArgs = { + id: Scalars['String']; +}; + + export type MutationDeleteSsoIdentityProviderArgs = { input: DeleteSsoInput; }; @@ -1729,6 +1750,11 @@ export type MutationDestroyCoreViewSortArgs = { }; +export type MutationDestroyPageLayoutArgs = { + id: Scalars['String']; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1838,6 +1864,11 @@ export type MutationResendWorkspaceInvitationArgs = { }; +export type MutationRestorePageLayoutArgs = { + id: Scalars['String']; +}; + + export type MutationRevokeApiKeyArgs = { input: RevokeApiKeyDto; }; @@ -1981,6 +2012,12 @@ export type MutationUpdateOneServerlessFunctionArgs = { }; +export type MutationUpdatePageLayoutArgs = { + id: Scalars['String']; + input: UpdatePageLayoutInput; +}; + + export type MutationUpdatePasswordViaResetTokenArgs = { newPassword: Scalars['String']; passwordResetToken: Scalars['String']; @@ -2247,6 +2284,36 @@ export type PageInfo = { startCursor?: Maybe; }; +export type PageLayout = { + __typename?: 'PageLayout'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['UUID']; + name: Scalars['String']; + objectMetadataId?: Maybe; + tabs?: Maybe>; + type: PageLayoutType; + updatedAt: Scalars['DateTime']; + workspaceId: Scalars['UUID']; +}; + +export type PageLayoutTab = { + __typename?: 'PageLayoutTab'; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['UUID']; + pageLayoutId: Scalars['UUID']; + position: Scalars['Float']; + title: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + +export enum PageLayoutType { + DASHBOARD = 'DASHBOARD', + RECORD_INDEX = 'RECORD_INDEX', + RECORD_PAGE = 'RECORD_PAGE' +} + export type PermissionFlag = { __typename?: 'PermissionFlag'; flag: PermissionFlagType; @@ -2362,6 +2429,8 @@ export type Query = { getDatabaseConfigVariable: ConfigVariable; getIndicatorHealthStatus: AdminPanelHealthServiceData; getMeteredProductsUsage: Array; + getPageLayout?: Maybe; + getPageLayouts: Array; getPostgresCredentials?: Maybe; getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput; getQueueMetrics: QueueMetricsData; @@ -2544,6 +2613,16 @@ export type QueryGetIndicatorHealthStatusArgs = { }; +export type QueryGetPageLayoutArgs = { + id: Scalars['String']; +}; + + +export type QueryGetPageLayoutsArgs = { + objectMetadataId?: InputMaybe; +}; + + export type QueryGetPublicWorkspaceDataByDomainArgs = { origin?: InputMaybe; }; @@ -3119,6 +3198,12 @@ export type UpdateOneObjectInput = { update: UpdateObjectPayload; }; +export type UpdatePageLayoutInput = { + name?: InputMaybe; + objectMetadataId?: InputMaybe; + type?: InputMaybe; +}; + export type UpdateRoleInput = { /** The id of the role to update */ id: Scalars['UUID']; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1756816245841-createPartialIndexForPageLayout.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756816245841-createPartialIndexForPageLayout.ts new file mode 100644 index 00000000000..f72b3808d7f --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756816245841-createPartialIndexForPageLayout.ts @@ -0,0 +1,25 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class CreatePartialIndexForPageLayout1756816245841 + implements MigrationInterface +{ + name = 'CreatePartialIndexForPageLayout1756816245841'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "core"."IDX_PAGE_LAYOUT_WORKSPACE_ID_OBJECT_METADATA_ID"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_PAGE_LAYOUT_WORKSPACE_ID_OBJECT_METADATA_ID" ON "core"."pageLayout" ("workspaceId", "objectMetadataId") WHERE "deletedAt" IS NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "core"."IDX_PAGE_LAYOUT_WORKSPACE_ID_OBJECT_METADATA_ID"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_PAGE_LAYOUT_WORKSPACE_ID_OBJECT_METADATA_ID" ON "core"."pageLayout" ("workspaceId", "objectMetadataId") `, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout.controller.ts b/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout.controller.ts new file mode 100644 index 00000000000..2661a74aff1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout.controller.ts @@ -0,0 +1,88 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input'; +import { UpdatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input'; +import { type PageLayoutDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout.dto'; +import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity'; +import { PageLayoutRestApiExceptionFilter } from 'src/engine/core-modules/page-layout/filters/page-layout-rest-api-exception.filter'; +import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Controller('rest/metadata/page-layouts') +@UseGuards(WorkspaceAuthGuard) +@UseFilters(PageLayoutRestApiExceptionFilter) +export class PageLayoutController { + constructor(private readonly pageLayoutService: PageLayoutService) {} + + @Get() + async findMany( + @AuthWorkspace() workspace: Workspace, + @Query('objectMetadataId') objectMetadataId?: string, + ): Promise { + if (objectMetadataId) { + return this.pageLayoutService.findByObjectMetadataId( + workspace.id, + objectMetadataId, + ); + } + + return this.pageLayoutService.findByWorkspaceId(workspace.id); + } + + @Get(':id') + async findOne( + @Param('id') id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutService.findByIdOrThrow(id, workspace.id); + } + + @Post() + async create( + @Body() input: CreatePageLayoutInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutService.create(input, workspace.id); + } + + @Patch(':id') + async update( + @Param('id') id: string, + @Body() input: UpdatePageLayoutInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const updatedPageLayout = await this.pageLayoutService.update( + id, + workspace.id, + input, + ); + + return updatedPageLayout; + } + + @Delete(':id') + async delete( + @Param('id') id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const deletedPageLayout = await this.pageLayoutService.delete( + id, + workspace.id, + ); + + return deletedPageLayout; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input.ts b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input.ts new file mode 100644 index 00000000000..4624906e955 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input.ts @@ -0,0 +1,33 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; + +@InputType() +export class CreatePageLayoutInput { + @Field({ nullable: false }) + @IsString() + @IsNotEmpty() + name: string; + + @Field(() => PageLayoutType, { + nullable: true, + defaultValue: PageLayoutType.RECORD_PAGE, + }) + @IsEnum(PageLayoutType) + @IsOptional() + type?: PageLayoutType; + + @Field(() => UUIDScalarType, { nullable: true }) + @IsUUID() + @IsOptional() + objectMetadataId?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input.ts b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input.ts new file mode 100644 index 00000000000..dff713c97d6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input.ts @@ -0,0 +1,24 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; + +@InputType() +export class UpdatePageLayoutInput { + @Field({ nullable: true }) + @IsString() + @IsOptional() + name?: string; + + @Field(() => PageLayoutType, { nullable: true }) + @IsEnum(PageLayoutType) + @IsOptional() + type?: PageLayoutType; + + @Field(() => UUIDScalarType, { nullable: true }) + @IsUUID() + @IsOptional() + objectMetadataId?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/page-layout-tab.dto.ts b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/page-layout-tab.dto.ts new file mode 100644 index 00000000000..1aa67b1536b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/page-layout-tab.dto.ts @@ -0,0 +1,29 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('PageLayoutTab') +export class PageLayoutTabDTO { + @IDField(() => UUIDScalarType) + id: string; + + @Field({ nullable: false }) + title: string; + + @Field({ nullable: false, defaultValue: 0 }) + position: number; + + @Field(() => UUIDScalarType, { nullable: false }) + pageLayoutId: string; + + @Field() + createdAt: Date; + + @Field() + updatedAt: Date; + + @Field(() => Date, { nullable: true }) + deletedAt?: Date | null; +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/page-layout.dto.ts b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/page-layout.dto.ts new file mode 100644 index 00000000000..faa9ff17600 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/page-layout.dto.ts @@ -0,0 +1,42 @@ +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { PageLayoutTabDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout-tab.dto'; +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; + +registerEnumType(PageLayoutType, { name: 'PageLayoutType' }); + +@ObjectType('PageLayout') +export class PageLayoutDTO { + @IDField(() => UUIDScalarType) + id: string; + + @Field({ nullable: false }) + name: string; + + @Field(() => UUIDScalarType, { nullable: false }) + workspaceId: string; + + @Field(() => PageLayoutType, { + nullable: false, + defaultValue: PageLayoutType.RECORD_PAGE, + }) + type: PageLayoutType; + + @Field(() => UUIDScalarType, { nullable: true }) + objectMetadataId?: string | null; + + @Field(() => [PageLayoutTabDTO], { nullable: true }) + tabs?: PageLayoutTabDTO[] | null; + + @Field() + createdAt: Date; + + @Field() + updatedAt: Date; + + @Field(() => Date, { nullable: true }) + deletedAt?: Date | null; +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout.entity.ts b/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout.entity.ts index 0c5c7a0fb06..a721267ba77 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout.entity.ts @@ -18,10 +18,11 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @Entity({ name: 'pageLayout', schema: 'core' }) -@Index('IDX_PAGE_LAYOUT_WORKSPACE_ID_OBJECT_METADATA_ID', [ - 'workspaceId', - 'objectMetadataId', -]) +@Index( + 'IDX_PAGE_LAYOUT_WORKSPACE_ID_OBJECT_METADATA_ID', + ['workspaceId', 'objectMetadataId'], + { where: '"deletedAt" IS NULL' }, +) export class PageLayoutEntity implements Required { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/exceptions/page-layout.exception.ts b/packages/twenty-server/src/engine/core-modules/page-layout/exceptions/page-layout.exception.ts new file mode 100644 index 00000000000..8dc9c42e532 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/exceptions/page-layout.exception.ts @@ -0,0 +1,29 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { CustomException } from 'src/utils/custom-exception'; + +export enum PageLayoutExceptionCode { + PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND', + INVALID_PAGE_LAYOUT_DATA = 'INVALID_PAGE_LAYOUT_DATA', +} + +export enum PageLayoutExceptionMessageKey { + PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND', + NAME_REQUIRED = 'NAME_REQUIRED', +} + +export class PageLayoutException extends CustomException {} + +export const generatePageLayoutExceptionMessage = ( + key: PageLayoutExceptionMessageKey, + value?: string, +): string => { + switch (key) { + case PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND: + return `Page layout with ID "${value}" not found`; + case PageLayoutExceptionMessageKey.NAME_REQUIRED: + return 'Page layout name is required'; + default: + assertUnreachable(key); + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/filters/page-layout-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/page-layout/filters/page-layout-rest-api-exception.filter.ts new file mode 100644 index 00000000000..475f719eb1a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/filters/page-layout-rest-api-exception.filter.ts @@ -0,0 +1,48 @@ +import { + type ArgumentsHost, + Catch, + type ExceptionFilter, +} from '@nestjs/common'; + +import { type Response } from 'express'; + +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { + PageLayoutException, + PageLayoutExceptionCode, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception'; +import { type CustomException } from 'src/utils/custom-exception'; + +@Catch(PageLayoutException) +export class PageLayoutRestApiExceptionFilter implements ExceptionFilter { + constructor( + private readonly httpExceptionHandlerService: HttpExceptionHandlerService, + ) {} + + catch(exception: PageLayoutException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + switch (exception.code) { + case PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND: + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 404, + ); + case PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA: + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 400, + ); + default: + // TODO: change to 500 when we have input validation + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 400, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/page-layout.module.ts b/packages/twenty-server/src/engine/core-modules/page-layout/page-layout.module.ts index 70db0c11f01..b4c6ea639f5 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/page-layout.module.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/page-layout.module.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PageLayoutController } from 'src/engine/core-modules/page-layout/controllers/page-layout.controller'; import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity'; import { PageLayoutWidgetEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-widget.entity'; import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity'; +import { PageLayoutResolver } from 'src/engine/core-modules/page-layout/resolvers/page-layout.resolver'; +import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service'; +import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service'; @Module({ imports: [ @@ -13,6 +17,8 @@ import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/p PageLayoutWidgetEntity, ]), ], - exports: [], + controllers: [PageLayoutController], + providers: [PageLayoutService, PageLayoutTabService, PageLayoutResolver], + exports: [PageLayoutService, PageLayoutTabService], }) export class PageLayoutModule {} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout.resolver.ts b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout.resolver.ts new file mode 100644 index 00000000000..7e2e5fb2de1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout.resolver.ts @@ -0,0 +1,95 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input'; +import { UpdatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input'; +import { PageLayoutDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout.dto'; +import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service'; +import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception.filter'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@Resolver(() => PageLayoutDTO) +@UseFilters(PageLayoutGraphqlApiExceptionFilter) +@UseGuards(WorkspaceAuthGuard) +export class PageLayoutResolver { + constructor(private readonly pageLayoutService: PageLayoutService) {} + + @Query(() => [PageLayoutDTO]) + async getPageLayouts( + @AuthWorkspace() workspace: Workspace, + @Args('objectMetadataId', { type: () => String, nullable: true }) + objectMetadataId?: string, + ): Promise { + if (objectMetadataId) { + return this.pageLayoutService.findByObjectMetadataId( + workspace.id, + objectMetadataId, + ); + } + + return this.pageLayoutService.findByWorkspaceId(workspace.id); + } + + @Query(() => PageLayoutDTO, { nullable: true }) + async getPageLayout( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutService.findByIdOrThrow(id, workspace.id); + } + + @Mutation(() => PageLayoutDTO) + async createPageLayout( + @Args('input') input: CreatePageLayoutInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutService.create(input, workspace.id); + } + + @Mutation(() => PageLayoutDTO) + async updatePageLayout( + @Args('id', { type: () => String }) id: string, + @Args('input') input: UpdatePageLayoutInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutService.update(id, workspace.id, input); + } + + @Mutation(() => PageLayoutDTO) + async deletePageLayout( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const deletedPageLayout = await this.pageLayoutService.delete( + id, + workspace.id, + ); + + return deletedPageLayout; + } + + @Mutation(() => Boolean) + async destroyPageLayout( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const deletedPageLayout = await this.pageLayoutService.destroy( + id, + workspace.id, + ); + + return isDefined(deletedPageLayout); + } + + @Mutation(() => PageLayoutDTO) + async restorePageLayout( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutService.restore(id, workspace.id); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-tab.service.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-tab.service.ts new file mode 100644 index 00000000000..b908aa6d21a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-tab.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { IsNull, Repository } from 'typeorm'; + +import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity'; + +@Injectable() +export class PageLayoutTabService { + constructor( + @InjectRepository(PageLayoutTabEntity) + private readonly pageLayoutTabRepository: Repository, + ) {} + + async findByPageLayoutId( + workspaceId: string, + pageLayoutId: string, + ): Promise { + return this.pageLayoutTabRepository.find({ + where: { + pageLayoutId, + pageLayout: { workspaceId }, + deletedAt: IsNull(), + }, + order: { position: 'ASC' }, + relations: ['widgets'], + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout.service.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout.service.ts new file mode 100644 index 00000000000..a4e5c364291 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout.service.ts @@ -0,0 +1,172 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, Repository } from 'typeorm'; + +import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity'; +import { + PageLayoutException, + PageLayoutExceptionCode, + PageLayoutExceptionMessageKey, + generatePageLayoutExceptionMessage, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception'; + +@Injectable() +export class PageLayoutService { + constructor( + @InjectRepository(PageLayoutEntity) + private readonly pageLayoutRepository: Repository, + ) {} + + async findByWorkspaceId(workspaceId: string): Promise { + return this.pageLayoutRepository.find({ + where: { + workspaceId, + deletedAt: IsNull(), + }, + relations: ['tabs'], + }); + } + + async findByObjectMetadataId( + workspaceId: string, + objectMetadataId: string, + ): Promise { + return this.pageLayoutRepository.find({ + where: { + workspaceId, + objectMetadataId, + deletedAt: IsNull(), + }, + relations: ['tabs'], + }); + } + + async findByIdOrThrow( + id: string, + workspaceId: string, + ): Promise { + const pageLayout = await this.pageLayoutRepository.findOne({ + where: { + id, + workspaceId, + deletedAt: IsNull(), + }, + relations: ['tabs'], + }); + + if (!isDefined(pageLayout)) { + throw new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + id, + ), + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ); + } + + return pageLayout; + } + + async create( + pageLayoutData: Partial, + workspaceId: string, + ): Promise { + if (!isDefined(pageLayoutData.name)) { + throw new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.NAME_REQUIRED, + ), + PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA, + ); + } + + const pageLayout = this.pageLayoutRepository.create({ + ...pageLayoutData, + workspaceId, + }); + + return this.pageLayoutRepository.save(pageLayout); + } + + async update( + id: string, + workspaceId: string, + updateData: Partial, + ): Promise { + const existingPageLayout = await this.findByIdOrThrow(id, workspaceId); + + const updatedPageLayout = await this.pageLayoutRepository.save({ + ...existingPageLayout, + ...updateData, + }); + + return updatedPageLayout; + } + + async delete(id: string, workspaceId: string): Promise { + const pageLayout = await this.findByIdOrThrow(id, workspaceId); + + await this.pageLayoutRepository.softDelete(id); + + return pageLayout; + } + + async destroy(id: string, workspaceId: string): Promise { + const pageLayout = await this.pageLayoutRepository.findOne({ + where: { + id, + workspaceId, + }, + withDeleted: true, + }); + + if (!isDefined(pageLayout)) { + throw new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + id, + ), + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ); + } + + await this.pageLayoutRepository.delete(id); + + return pageLayout; + } + + async restore(id: string, workspaceId: string): Promise { + const pageLayout = await this.pageLayoutRepository.findOne({ + where: { + id, + workspaceId, + }, + withDeleted: true, + }); + + if (!isDefined(pageLayout)) { + throw new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + id, + ), + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ); + } + + if (!isDefined(pageLayout.deletedAt)) { + throw new PageLayoutException( + 'Page layout is not deleted and cannot be restored', + PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA, + ); + } + + await this.pageLayoutRepository.restore(id); + + const restoredPageLayout = await this.findByIdOrThrow(id, workspaceId); + + return restoredPageLayout; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-tab.service.spec.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-tab.service.spec.ts new file mode 100644 index 00000000000..20a1ad796ee --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-tab.service.spec.ts @@ -0,0 +1,164 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { type Repository } from 'typeorm'; + +import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity'; +import { type PageLayoutWidgetEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-widget.entity'; +import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum'; +import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service'; + +describe('PageLayoutTabService', () => { + let pageLayoutTabService: PageLayoutTabService; + let pageLayoutTabRepository: Repository; + + const mockPageLayoutTab = { + id: 'page-layout-tab-id', + title: 'Test Tab', + position: 0, + pageLayoutId: 'page-layout-id', + pageLayout: {} as any, + widgets: [], + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as PageLayoutTabEntity; + + const mockWidget = { + id: 'widget-1', + title: 'Test Widget', + type: WidgetType.VIEW, + pageLayoutTabId: 'page-layout-tab-id', + objectMetadataId: 'object-metadata-id', + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + configuration: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as PageLayoutWidgetEntity; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PageLayoutTabService, + { + provide: getRepositoryToken(PageLayoutTabEntity), + useValue: { + find: jest.fn(), + }, + }, + ], + }).compile(); + + pageLayoutTabService = + module.get(PageLayoutTabService); + pageLayoutTabRepository = module.get>( + getRepositoryToken(PageLayoutTabEntity), + ); + }); + + it('should be defined', () => { + expect(pageLayoutTabService).toBeDefined(); + }); + + describe('findByPageLayoutId', () => { + it('should return page layout tabs for a page layout id', async () => { + const workspaceId = 'workspace-id'; + const pageLayoutId = 'page-layout-id'; + const expectedTabs = [mockPageLayoutTab]; + + jest + .spyOn(pageLayoutTabRepository, 'find') + .mockResolvedValue(expectedTabs); + + const result = await pageLayoutTabService.findByPageLayoutId( + workspaceId, + pageLayoutId, + ); + + expect(pageLayoutTabRepository.find).toHaveBeenCalledWith({ + where: { + pageLayoutId, + pageLayout: { workspaceId }, + deletedAt: expect.anything(), + }, + order: { position: 'ASC' }, + relations: ['widgets'], + }); + expect(result).toEqual(expectedTabs); + }); + + it('should return empty array when no tabs are found', async () => { + const workspaceId = 'workspace-id'; + const pageLayoutId = 'page-layout-id'; + + jest.spyOn(pageLayoutTabRepository, 'find').mockResolvedValue([]); + + const result = await pageLayoutTabService.findByPageLayoutId( + workspaceId, + pageLayoutId, + ); + + expect(result).toEqual([]); + }); + + it('should order tabs by position in ascending order', async () => { + const workspaceId = 'workspace-id'; + const pageLayoutId = 'page-layout-id'; + const tab1 = { ...mockPageLayoutTab, id: 'tab-1', position: 2 }; + const tab2 = { ...mockPageLayoutTab, id: 'tab-2', position: 0 }; + const tab3 = { ...mockPageLayoutTab, id: 'tab-3', position: 1 }; + const expectedTabs = [tab2, tab3, tab1]; + + jest + .spyOn(pageLayoutTabRepository, 'find') + .mockResolvedValue(expectedTabs); + + const result = await pageLayoutTabService.findByPageLayoutId( + workspaceId, + pageLayoutId, + ); + + expect(pageLayoutTabRepository.find).toHaveBeenCalledWith({ + where: { + pageLayoutId, + pageLayout: { workspaceId }, + deletedAt: expect.anything(), + }, + order: { position: 'ASC' }, + relations: ['widgets'], + }); + expect(result).toEqual(expectedTabs); + }); + + it('should include widgets relation', async () => { + const workspaceId = 'workspace-id'; + const pageLayoutId = 'page-layout-id'; + const widget1 = { ...mockWidget, id: 'widget-1', type: WidgetType.VIEW }; + const widget2 = { + ...mockWidget, + id: 'widget-2', + type: WidgetType.FIELDS, + }; + const tabWithWidgets = { + ...mockPageLayoutTab, + widgets: [widget1, widget2], + }; + + jest + .spyOn(pageLayoutTabRepository, 'find') + .mockResolvedValue([tabWithWidgets]); + + const result = await pageLayoutTabService.findByPageLayoutId( + workspaceId, + pageLayoutId, + ); + + expect(result[0].widgets).toHaveLength(2); + expect(result[0].widgets[0].id).toEqual('widget-1'); + expect(result[0].widgets[1].id).toEqual('widget-2'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout.service.spec.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout.service.spec.ts new file mode 100644 index 00000000000..d3e4aaff79a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout.service.spec.ts @@ -0,0 +1,398 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { IsNull, type Repository } from 'typeorm'; + +import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity'; +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; +import { + PageLayoutException, + PageLayoutExceptionCode, + PageLayoutExceptionMessageKey, + generatePageLayoutExceptionMessage, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception'; +import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service'; + +describe('PageLayoutService', () => { + let pageLayoutService: PageLayoutService; + let pageLayoutRepository: Repository; + + const mockPageLayout = { + id: 'page-layout-id', + name: 'Test Page Layout', + workspaceId: 'workspace-id', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: 'object-metadata-id', + tabs: [], + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as unknown as PageLayoutEntity; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PageLayoutService, + { + provide: getRepositoryToken(PageLayoutEntity), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + softDelete: jest.fn(), + delete: jest.fn(), + restore: jest.fn(), + }, + }, + ], + }).compile(); + + pageLayoutService = module.get(PageLayoutService); + pageLayoutRepository = module.get>( + getRepositoryToken(PageLayoutEntity), + ); + }); + + describe('findByWorkspaceId', () => { + it('should return page layouts for a workspace', async () => { + const workspaceId = 'workspace-id'; + const expectedPageLayouts = [mockPageLayout]; + + jest + .spyOn(pageLayoutRepository, 'find') + .mockResolvedValue(expectedPageLayouts); + + const result = await pageLayoutService.findByWorkspaceId(workspaceId); + + expect(pageLayoutRepository.find).toHaveBeenCalledWith({ + where: { + workspaceId, + deletedAt: IsNull(), + }, + relations: ['tabs'], + }); + expect(result).toEqual(expectedPageLayouts); + }); + }); + + describe('findByObjectMetadataId', () => { + it('should return page layouts for an object metadata id', async () => { + const workspaceId = 'workspace-id'; + const objectMetadataId = 'object-metadata-id'; + const expectedPageLayouts = [mockPageLayout]; + + jest + .spyOn(pageLayoutRepository, 'find') + .mockResolvedValue(expectedPageLayouts); + + const result = await pageLayoutService.findByObjectMetadataId( + workspaceId, + objectMetadataId, + ); + + expect(pageLayoutRepository.find).toHaveBeenCalledWith({ + where: { + workspaceId, + objectMetadataId, + deletedAt: IsNull(), + }, + relations: ['tabs'], + }); + expect(result).toEqual(expectedPageLayouts); + }); + }); + + describe('findByIdOrThrow', () => { + it('should return a page layout by id', async () => { + const id = 'page-layout-id'; + const workspaceId = 'workspace-id'; + + jest + .spyOn(pageLayoutRepository, 'findOne') + .mockResolvedValue(mockPageLayout); + + const result = await pageLayoutService.findByIdOrThrow(id, workspaceId); + + expect(result).toEqual(mockPageLayout); + }); + + it('should throw exception when page layout is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + + jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue(null); + + await expect( + pageLayoutService.findByIdOrThrow(id, workspaceId), + ).rejects.toThrow( + new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + id, + ), + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ), + ); + }); + }); + + describe('create', () => { + const validPageLayoutData = { + name: 'Test Page Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: 'object-metadata-id', + }; + + it('should create a page layout successfully', async () => { + jest + .spyOn(pageLayoutRepository, 'create') + .mockReturnValue(mockPageLayout); + jest + .spyOn(pageLayoutRepository, 'save') + .mockResolvedValue(mockPageLayout); + + const result = await pageLayoutService.create( + validPageLayoutData, + 'workspace-id', + ); + + expect(pageLayoutRepository.create).toHaveBeenCalledWith({ + ...validPageLayoutData, + workspaceId: 'workspace-id', + }); + expect(pageLayoutRepository.save).toHaveBeenCalledWith(mockPageLayout); + expect(result).toEqual(mockPageLayout); + }); + + it('should throw exception when name is missing', async () => { + const invalidData = { ...validPageLayoutData, name: undefined }; + const workspaceId = 'workspace-id'; + + await expect( + pageLayoutService.create(invalidData, workspaceId), + ).rejects.toThrow( + new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.NAME_REQUIRED, + ), + PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA, + ), + ); + }); + }); + + describe('update', () => { + it('should update a page layout successfully', async () => { + const id = 'page-layout-id'; + const workspaceId = 'workspace-id'; + const updateData = { name: 'Updated Page Layout' }; + const updatedPageLayout = { ...mockPageLayout, ...updateData }; + + jest + .spyOn(pageLayoutService, 'findByIdOrThrow') + .mockResolvedValue(mockPageLayout); + jest + .spyOn(pageLayoutRepository, 'save') + .mockResolvedValue(updatedPageLayout); + + const result = await pageLayoutService.update( + id, + workspaceId, + updateData, + ); + + expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith( + id, + workspaceId, + ); + expect(pageLayoutRepository.save).toHaveBeenCalledWith({ + ...mockPageLayout, + ...updateData, + }); + expect(result).toEqual(updatedPageLayout); + }); + + it('should throw exception when page layout is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + const updateData = { name: 'Updated Page Layout' }; + + jest + .spyOn(pageLayoutService, 'findByIdOrThrow') + .mockRejectedValue( + new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + id, + ), + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ), + ); + + await expect( + pageLayoutService.update(id, workspaceId, updateData), + ).rejects.toThrow( + new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + id, + ), + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ), + ); + }); + }); + + describe('delete', () => { + it('should delete a page layout successfully', async () => { + const id = 'page-layout-id'; + const workspaceId = 'workspace-id'; + + jest + .spyOn(pageLayoutService, 'findByIdOrThrow') + .mockResolvedValue(mockPageLayout); + jest + .spyOn(pageLayoutRepository, 'softDelete') + .mockResolvedValue({} as any); + + const result = await pageLayoutService.delete(id, workspaceId); + + expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith( + id, + workspaceId, + ); + expect(pageLayoutRepository.softDelete).toHaveBeenCalledWith(id); + expect(result).toEqual(mockPageLayout); + }); + + it('should throw exception when page layout is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + + jest + .spyOn(pageLayoutService, 'findByIdOrThrow') + .mockRejectedValue( + new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + id, + ), + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ), + ); + + await expect(pageLayoutService.delete(id, workspaceId)).rejects.toThrow( + new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + id, + ), + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ), + ); + }); + }); + + describe('destroy', () => { + it('should destroy a page layout successfully', async () => { + const id = 'page-layout-id'; + const workspaceId = 'workspace-id'; + + jest + .spyOn(pageLayoutRepository, 'findOne') + .mockResolvedValue(mockPageLayout); + jest.spyOn(pageLayoutRepository, 'delete').mockResolvedValue({} as any); + + const result = await pageLayoutService.destroy(id, workspaceId); + + expect(pageLayoutRepository.findOne).toHaveBeenCalledWith({ + where: { + id, + workspaceId, + }, + withDeleted: true, + }); + expect(pageLayoutRepository.delete).toHaveBeenCalledWith(id); + expect(result).toEqual(mockPageLayout); + }); + + it('should throw exception when page layout is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + + jest + .spyOn(pageLayoutRepository, 'findOne') + .mockRejectedValue( + new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + id, + ), + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ), + ); + + await expect(pageLayoutService.destroy(id, workspaceId)).rejects.toThrow( + new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + id, + ), + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ), + ); + }); + }); + + describe('restore', () => { + it('should restore a page layout successfully', async () => { + const id = 'page-layout-id'; + const workspaceId = 'workspace-id'; + const deletedPageLayout = { ...mockPageLayout, deletedAt: new Date() }; + + jest + .spyOn(pageLayoutRepository, 'findOne') + .mockResolvedValue(deletedPageLayout); + jest.spyOn(pageLayoutRepository, 'restore').mockResolvedValue({} as any); + jest + .spyOn(pageLayoutService, 'findByIdOrThrow') + .mockResolvedValue(mockPageLayout); + + const result = await pageLayoutService.restore(id, workspaceId); + + expect(pageLayoutRepository.findOne).toHaveBeenCalledWith({ + where: { + id, + workspaceId, + }, + withDeleted: true, + }); + expect(pageLayoutRepository.restore).toHaveBeenCalledWith(id); + expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith( + id, + workspaceId, + ); + expect(result).toEqual(mockPageLayout); + }); + + it('should throw exception when page layout is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + + jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue(null); + + await expect(pageLayoutService.restore(id, workspaceId)).rejects.toThrow( + new PageLayoutException( + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + id, + ), + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception-handler.util.ts new file mode 100644 index 00000000000..b2c6b52e43c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception-handler.util.ts @@ -0,0 +1,28 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + PageLayoutException, + PageLayoutExceptionCode, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception'; + +export const pageLayoutGraphqlApiExceptionHandler = (error: Error) => { + if (error instanceof PageLayoutException) { + switch (error.code) { + case PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND: + throw new NotFoundError(error.message); + case PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA: + throw new UserInputError(error.message, { + userFriendlyMessage: error.userFriendlyMessage, + }); + default: { + return assertUnreachable(error.code); + } + } + } + + throw error; +}; diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception.filter.ts new file mode 100644 index 00000000000..e3e3c504282 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception.filter.ts @@ -0,0 +1,12 @@ +import { ArgumentsHost, Catch } from '@nestjs/common'; +import { GqlExceptionFilter } from '@nestjs/graphql'; + +import { PageLayoutException } from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception'; +import { pageLayoutGraphqlApiExceptionHandler } from 'src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception-handler.util'; + +@Catch(PageLayoutException) +export class PageLayoutGraphqlApiExceptionFilter implements GqlExceptionFilter { + catch(exception: PageLayoutException, _host: ArgumentsHost) { + return pageLayoutGraphqlApiExceptionHandler(exception); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts b/packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts index afae88d8e88..51a7e3a711a 100644 --- a/packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts @@ -29,7 +29,6 @@ export class ViewController { constructor(private readonly viewService: ViewService) {} @Get() - @UseGuards(WorkspaceAuthGuard) async findMany( @AuthWorkspace() workspace: Workspace, @Query('objectMetadataId') objectMetadataId?: string, diff --git a/packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts b/packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts index 8ebf0bacf67..e47df630912 100644 --- a/packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts @@ -45,8 +45,8 @@ export class ViewResolver { private readonly viewFieldService: ViewFieldService, private readonly viewFilterService: ViewFilterService, private readonly viewFilterGroupService: ViewFilterGroupService, - private readonly viewGroupService: ViewGroupService, private readonly viewSortService: ViewSortService, + private readonly viewGroupService: ViewGroupService, private readonly i18nService: I18nService, ) {} diff --git a/packages/twenty-server/test/integration/constants/page-layout-gql-fields.constants.ts b/packages/twenty-server/test/integration/constants/page-layout-gql-fields.constants.ts new file mode 100644 index 00000000000..47b4963a01b --- /dev/null +++ b/packages/twenty-server/test/integration/constants/page-layout-gql-fields.constants.ts @@ -0,0 +1,20 @@ +export const PAGE_LAYOUT_GQL_FIELDS = ` + id + name + workspaceId + type + objectMetadataId + createdAt + updatedAt + deletedAt +`; + +export const PAGE_LAYOUT_TAB_GQL_FIELDS = ` + id + title + position + pageLayoutId + createdAt + updatedAt + deletedAt +`; diff --git a/packages/twenty-server/test/integration/constants/test-page-layout-ids.constants.ts b/packages/twenty-server/test/integration/constants/test-page-layout-ids.constants.ts new file mode 100644 index 00000000000..b080b9c6491 --- /dev/null +++ b/packages/twenty-server/test/integration/constants/test-page-layout-ids.constants.ts @@ -0,0 +1,5 @@ +export const TEST_PAGE_LAYOUT_1_ID = '20202020-481a-4d20-b231-74977b830771'; +export const TEST_PAGE_LAYOUT_2_ID = '20202020-e512-495a-b2b1-6524ad5684d0'; + +export const TEST_NOT_EXISTING_PAGE_LAYOUT_ID = + '20202020-df6b-4273-9aca-7170b998c175'; diff --git a/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-resolver.integration-spec.ts new file mode 100644 index 00000000000..64cb840d14c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-resolver.integration-spec.ts @@ -0,0 +1,464 @@ +import { TEST_NOT_EXISTING_PAGE_LAYOUT_ID } from 'test/integration/constants/test-page-layout-ids.constants'; +import { createPageLayoutOperationFactory } from 'test/integration/graphql/utils/create-page-layout-operation-factory.util'; +import { deletePageLayoutOperationFactory } from 'test/integration/graphql/utils/delete-page-layout-operation-factory.util'; +import { destroyPageLayoutOperationFactory } from 'test/integration/graphql/utils/destroy-page-layout-operation-factory.util'; +import { findPageLayoutOperationFactory } from 'test/integration/graphql/utils/find-page-layout-operation-factory.util'; +import { findPageLayoutsOperationFactory } from 'test/integration/graphql/utils/find-page-layouts-operation-factory.util'; +import { + assertGraphQLErrorResponse, + assertGraphQLSuccessfulResponse, +} from 'test/integration/graphql/utils/graphql-test-assertions.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { + cleanupPageLayoutRecordsWithGraphQL, + createTestPageLayoutWithGraphQL, +} from 'test/integration/graphql/utils/page-layout-graphql.util'; +import { restorePageLayoutOperationFactory } from 'test/integration/graphql/utils/restore-page-layout-operation-factory.util'; +import { updatePageLayoutOperationFactory } from 'test/integration/graphql/utils/update-page-layout-operation-factory.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { assertPageLayoutStructure } from 'test/integration/utils/page-layout-test.util'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; +import { + PageLayoutExceptionMessageKey, + generatePageLayoutExceptionMessage, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception'; + +describe('Page Layout Resolver', () => { + let testObjectMetadataId: string; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'myTestPageLayoutObject', + namePlural: 'myTestPageLayoutObjects', + labelSingular: 'My Test Page Layout Object', + labelPlural: 'My Test Page Layout Objects', + icon: 'IconLayout', + }, + }); + + testObjectMetadataId = objectMetadataId; + }); + + afterAll(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: testObjectMetadataId }, + }); + }); + + afterEach(async () => { + await cleanupPageLayoutRecordsWithGraphQL(); + }); + + describe('getPageLayouts', () => { + it('should return empty array when no page layouts exist', async () => { + const operation = findPageLayoutsOperationFactory(); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getPageLayouts).toEqual([]); + }); + + it('should return all page layouts for workspace when no objectMetadataId provided', async () => { + const pageLayoutName = 'Test Page Layout for Workspace'; + + await createTestPageLayoutWithGraphQL({ + name: pageLayoutName, + objectMetadataId: testObjectMetadataId, + }); + + const operation = findPageLayoutsOperationFactory(); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getPageLayouts).toHaveLength(1); + assertPageLayoutStructure(response.body.data.getPageLayouts[0], { + name: pageLayoutName, + objectMetadataId: testObjectMetadataId, + type: PageLayoutType.RECORD_PAGE, + }); + }); + + it('should filter page layouts by objectMetadataId when provided', async () => { + const object1PageLayoutName = 'Page Layout for Object 1'; + const object2PageLayoutName = 'Page Layout for Object 2'; + + const { + data: { + createOneObject: { id: objectMetadata2Id }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'myTestPageLayoutObject2', + namePlural: 'myTestPageLayoutObjects2', + labelSingular: 'My Test Page Layout Object 2', + labelPlural: 'My Test Page Layout Objects 2', + icon: 'IconLayout2', + isLabelSyncedWithName: false, + }, + }); + + await Promise.all([ + createTestPageLayoutWithGraphQL({ + name: object1PageLayoutName, + objectMetadataId: testObjectMetadataId, + }), + createTestPageLayoutWithGraphQL({ + name: object2PageLayoutName, + objectMetadataId: objectMetadata2Id, + }), + ]); + + const operation = findPageLayoutsOperationFactory({ + objectMetadataId: testObjectMetadataId, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getPageLayouts).toHaveLength(1); + assertPageLayoutStructure(response.body.data.getPageLayouts[0], { + name: object1PageLayoutName, + objectMetadataId: testObjectMetadataId, + }); + + await deleteOneObjectMetadata({ + input: { idToDelete: objectMetadata2Id }, + }); + }); + }); + + describe('getPageLayout', () => { + it('should throw when page layout does not exist', async () => { + const operation = findPageLayoutOperationFactory({ + pageLayoutId: TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + ), + ); + }); + + it('should return page layout when it exists', async () => { + const pageLayoutName = 'Test Page Layout for Get'; + + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: pageLayoutName, + objectMetadataId: testObjectMetadataId, + }); + + const operation = findPageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertPageLayoutStructure(response.body.data.getPageLayout, { + id: pageLayout.id, + name: pageLayoutName, + objectMetadataId: testObjectMetadataId, + type: PageLayoutType.RECORD_PAGE, + }); + }); + }); + + describe('createPageLayout', () => { + it('should create a new page layout with all properties', async () => { + const input = { + name: 'Dashboard Page Layout', + type: PageLayoutType.DASHBOARD, + objectMetadataId: testObjectMetadataId, + }; + + const operation = createPageLayoutOperationFactory({ data: input }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + + const createdPageLayout = response.body.data.createPageLayout; + + assertPageLayoutStructure(createdPageLayout, { + name: input.name, + type: input.type, + objectMetadataId: input.objectMetadataId, + deletedAt: null, + }); + }); + + it('should create a page layout with minimum required fields', async () => { + const input = { + name: 'Minimal Page Layout', + }; + + const operation = createPageLayoutOperationFactory({ data: input }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + + const createdPageLayout = response.body.data.createPageLayout; + + assertPageLayoutStructure(createdPageLayout, { + name: input.name, + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + deletedAt: null, + }); + }); + }); + + describe('updatePageLayout', () => { + it('should update an existing page layout', async () => { + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: 'Original Page Layout', + type: PageLayoutType.RECORD_PAGE, + }); + + const updateInput = { + name: 'Updated Page Layout', + type: PageLayoutType.DASHBOARD, + }; + + const operation = updatePageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + data: updateInput, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertPageLayoutStructure(response.body.data.updatePageLayout, { + id: pageLayout.id, + name: updateInput.name, + type: updateInput.type, + deletedAt: null, + }); + }); + + it('should update only provided fields', async () => { + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: 'Original Page Layout', + type: PageLayoutType.RECORD_PAGE, + }); + + const updateInput = { + name: 'Updated Name Only', + }; + + const operation = updatePageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + data: updateInput, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertPageLayoutStructure(response.body.data.updatePageLayout, { + id: pageLayout.id, + name: updateInput.name, + type: PageLayoutType.RECORD_PAGE, + deletedAt: null, + }); + }); + + it('should throw error when updating non-existent page layout', async () => { + const operation = updatePageLayoutOperationFactory({ + pageLayoutId: TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + data: { name: 'Non-existent Page Layout' }, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + ), + ); + }); + }); + + describe('deletePageLayout', () => { + it('should delete an existing page layout (soft delete)', async () => { + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: 'Page Layout to Delete', + }); + + const deleteOperation = deletePageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + }); + const deleteResponse = await makeGraphqlAPIRequest(deleteOperation); + + assertGraphQLSuccessfulResponse(deleteResponse); + assertPageLayoutStructure( + deleteResponse.body.data.deletePageLayout, + pageLayout, + ); + + const getOperation = findPageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + }); + const getResponse = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLErrorResponse( + getResponse, + ErrorCode.NOT_FOUND, + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + pageLayout.id, + ), + ); + }); + + it('should throw an error when deleting non-existent page layout', async () => { + const operation = deletePageLayoutOperationFactory({ + pageLayoutId: TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + ), + ); + }); + }); + + describe('destroyPageLayout', () => { + it('should destroy an existing page layout (hard delete)', async () => { + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: 'Page Layout to Destroy', + }); + + const destroyOperation = destroyPageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + }); + const destroyResponse = await makeGraphqlAPIRequest(destroyOperation); + + assertGraphQLSuccessfulResponse(destroyResponse); + expect(destroyResponse.body.data.destroyPageLayout).toBe(true); + }); + + it('should throw an error when destroying non-existent page layout', async () => { + const operation = destroyPageLayoutOperationFactory({ + pageLayoutId: TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + ), + ); + }); + }); + + describe('restorePageLayout', () => { + it('should restore a soft deleted page layout', async () => { + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: 'Page Layout to Restore', + type: PageLayoutType.RECORD_INDEX, + }); + + const deleteOperation = deletePageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + }); + const deleteResponse = await makeGraphqlAPIRequest(deleteOperation); + + assertGraphQLSuccessfulResponse(deleteResponse); + + const restoreOperation = restorePageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + }); + const restoreResponse = await makeGraphqlAPIRequest(restoreOperation); + + assertGraphQLSuccessfulResponse(restoreResponse); + assertPageLayoutStructure(restoreResponse.body.data.restorePageLayout, { + id: pageLayout.id, + name: 'Page Layout to Restore', + type: PageLayoutType.RECORD_INDEX, + deletedAt: null, + }); + + const getOperation = findPageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + }); + const getResponse = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLSuccessfulResponse(getResponse); + assertPageLayoutStructure(getResponse.body.data.getPageLayout, { + id: pageLayout.id, + name: 'Page Layout to Restore', + type: PageLayoutType.RECORD_INDEX, + deletedAt: null, + }); + }); + + it('should throw an error when restoring non-existent page layout', async () => { + const operation = restorePageLayoutOperationFactory({ + pageLayoutId: TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + ), + ); + }); + }); + + describe('tabs resolver field', () => { + it('should resolve tabs field for page layout', async () => { + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: 'Page Layout with Tabs', + }); + + const operation = findPageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + gqlFields: ` + id + name + type + workspaceId + createdAt + updatedAt + deletedAt + tabs { + id + title + position + pageLayoutId + } + `, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertPageLayoutStructure(response.body.data.getPageLayout, { + id: pageLayout.id, + name: 'Page Layout with Tabs', + tabs: expect.any(Array), + }); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/create-page-layout-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-page-layout-operation-factory.util.ts new file mode 100644 index 00000000000..d0c5b485659 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/create-page-layout-operation-factory.util.ts @@ -0,0 +1,25 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +import { type CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input'; + +type CreatePageLayoutOperationFactoryParams = { + gqlFields?: string; + data?: CreatePageLayoutInput; +}; + +export const createPageLayoutOperationFactory = ({ + gqlFields = PAGE_LAYOUT_GQL_FIELDS, + data = {} as CreatePageLayoutInput, +}: CreatePageLayoutOperationFactoryParams = {}) => ({ + query: gql` + mutation CreatePageLayout($input: CreatePageLayoutInput!) { + createPageLayout(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/delete-page-layout-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/delete-page-layout-operation-factory.util.ts new file mode 100644 index 00000000000..01a02e378a6 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/delete-page-layout-operation-factory.util.ts @@ -0,0 +1,23 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +type DeletePageLayoutOperationFactoryParams = { + pageLayoutId: string; + gqlFields?: string; +}; + +export const deletePageLayoutOperationFactory = ({ + pageLayoutId, + gqlFields = PAGE_LAYOUT_GQL_FIELDS, +}: DeletePageLayoutOperationFactoryParams) => ({ + query: gql` + mutation DeletePageLayout($id: String!) { + deletePageLayout(id: $id) { + ${gqlFields} + } + } + `, + variables: { + id: pageLayoutId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/destroy-page-layout-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/destroy-page-layout-operation-factory.util.ts new file mode 100644 index 00000000000..8de6d0e5f61 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/destroy-page-layout-operation-factory.util.ts @@ -0,0 +1,18 @@ +import gql from 'graphql-tag'; + +type DestroyPageLayoutOperationFactoryParams = { + pageLayoutId: string; +}; + +export const destroyPageLayoutOperationFactory = ({ + pageLayoutId, +}: DestroyPageLayoutOperationFactoryParams) => ({ + query: gql` + mutation DestroyPageLayout($id: String!) { + destroyPageLayout(id: $id) + } + `, + variables: { + id: pageLayoutId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-page-layout-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-page-layout-operation-factory.util.ts new file mode 100644 index 00000000000..fa3cdce1727 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-page-layout-operation-factory.util.ts @@ -0,0 +1,23 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +type FindPageLayoutOperationFactoryParams = { + gqlFields?: string; + pageLayoutId: string; +}; + +export const findPageLayoutOperationFactory = ({ + gqlFields = PAGE_LAYOUT_GQL_FIELDS, + pageLayoutId, +}: FindPageLayoutOperationFactoryParams) => ({ + query: gql` + query GetPageLayout($id: String!) { + getPageLayout(id: $id) { + ${gqlFields} + } + } + `, + variables: { + id: pageLayoutId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-page-layouts-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-page-layouts-operation-factory.util.ts new file mode 100644 index 00000000000..f47c9ccc1f2 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-page-layouts-operation-factory.util.ts @@ -0,0 +1,23 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +type FindPageLayoutsOperationFactoryParams = { + gqlFields?: string; + objectMetadataId?: string; +}; + +export const findPageLayoutsOperationFactory = ({ + gqlFields = PAGE_LAYOUT_GQL_FIELDS, + objectMetadataId, +}: FindPageLayoutsOperationFactoryParams = {}) => ({ + query: gql` + query GetPageLayouts($objectMetadataId: String) { + getPageLayouts(objectMetadataId: $objectMetadataId) { + ${gqlFields} + } + } + `, + variables: { + objectMetadataId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/page-layout-graphql.util.ts b/packages/twenty-server/test/integration/graphql/utils/page-layout-graphql.util.ts new file mode 100644 index 00000000000..a5506521efb --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/page-layout-graphql.util.ts @@ -0,0 +1,60 @@ +import { type GraphQLResponse } from 'test/integration/graphql/utils/graphql-test-assertions.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +import { type PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity'; +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; + +import { createPageLayoutOperationFactory } from './create-page-layout-operation-factory.util'; +import { destroyPageLayoutOperationFactory } from './destroy-page-layout-operation-factory.util'; +import { findPageLayoutsOperationFactory } from './find-page-layouts-operation-factory.util'; + +interface CreatePageLayoutResponse extends Record { + createPageLayout: PageLayoutEntity; +} + +export const createTestPageLayoutWithGraphQL = async ( + data: { + name: string; + type?: PageLayoutType; + objectMetadataId?: string; + } = { name: 'Test Page Layout' }, +): Promise => { + const operation = createPageLayoutOperationFactory({ + data: { + name: data.name, + type: data.type || PageLayoutType.RECORD_PAGE, + objectMetadataId: data.objectMetadataId, + }, + }); + + const response = (await makeGraphqlAPIRequest( + operation, + )) as GraphQLResponse; + + if (response.body.errors) { + throw new Error( + `Failed to create test page layout: ${JSON.stringify(response.body.errors)}`, + ); + } + + if (!response.body.data) { + throw new Error('No data returned from createTestPageLayoutWithGraphQL'); + } + + return response.body.data.createPageLayout; +}; + +export const cleanupPageLayoutRecordsWithGraphQL = async (): Promise => { + const operation = findPageLayoutsOperationFactory(); + const response = await makeGraphqlAPIRequest(operation); + + if (response.body.data?.getPageLayouts) { + for (const pageLayout of response.body.data.getPageLayouts) { + const destroyOperation = destroyPageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + }); + + await makeGraphqlAPIRequest(destroyOperation); + } + } +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/restore-page-layout-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/restore-page-layout-operation-factory.util.ts new file mode 100644 index 00000000000..72fc8906d69 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/restore-page-layout-operation-factory.util.ts @@ -0,0 +1,23 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +type RestorePageLayoutOperationFactoryParams = { + gqlFields?: string; + pageLayoutId: string; +}; + +export const restorePageLayoutOperationFactory = ({ + gqlFields = PAGE_LAYOUT_GQL_FIELDS, + pageLayoutId, +}: RestorePageLayoutOperationFactoryParams) => ({ + query: gql` + mutation RestorePageLayout($id: String!) { + restorePageLayout(id: $id) { + ${gqlFields} + } + } + `, + variables: { + id: pageLayoutId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/update-page-layout-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/update-page-layout-operation-factory.util.ts new file mode 100644 index 00000000000..7525fbb8d20 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/update-page-layout-operation-factory.util.ts @@ -0,0 +1,28 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +import { type UpdatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input'; + +type UpdatePageLayoutOperationFactoryParams = { + gqlFields?: string; + pageLayoutId: string; + data?: UpdatePageLayoutInput; +}; + +export const updatePageLayoutOperationFactory = ({ + gqlFields = PAGE_LAYOUT_GQL_FIELDS, + pageLayoutId, + data = {} as UpdatePageLayoutInput, +}: UpdatePageLayoutOperationFactoryParams) => ({ + query: gql` + mutation UpdatePageLayout($id: String!, $input: UpdatePageLayoutInput!) { + updatePageLayout(id: $id, input: $input) { + ${gqlFields} + } + } + `, + variables: { + id: pageLayoutId, + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/rest/suites/page-layout.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/page-layout.integration-spec.ts new file mode 100644 index 00000000000..eda76a5f911 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/page-layout.integration-spec.ts @@ -0,0 +1,369 @@ +import { TEST_NOT_EXISTING_PAGE_LAYOUT_ID } from 'test/integration/constants/test-page-layout-ids.constants'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { + createTestPageLayoutWithRestApi, + deleteTestPageLayoutWithRestApi, +} from 'test/integration/rest/utils/page-layout-rest-api.util'; +import { + assertRestApiErrorResponse, + assertRestApiSuccessfulResponse, +} from 'test/integration/rest/utils/rest-test-assertions.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { + assertPageLayoutStructure, + cleanupPageLayoutRecords, +} from 'test/integration/utils/page-layout-test.util'; + +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; +import { + PageLayoutExceptionMessageKey, + generatePageLayoutExceptionMessage, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception'; + +describe('Page Layout REST API', () => { + let testObjectMetadataId: string; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'myTestPageLayoutObject', + namePlural: 'myTestPageLayoutObjects', + labelSingular: 'My Test Page Layout Object', + labelPlural: 'My Test Page Layout Objects', + icon: 'IconLayout', + }, + }); + + testObjectMetadataId = objectMetadataId; + }); + + afterAll(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: testObjectMetadataId }, + }); + }); + + afterEach(async () => { + await cleanupPageLayoutRecords(); + }); + + describe('GET /rest/metadata/page-layouts', () => { + it('should return all page layouts for workspace', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/page-layouts', + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + }); + + it('should return page layouts filtered by objectMetadataId', async () => { + await createTestPageLayoutWithRestApi({ + name: 'Test Page Layout for Filter', + objectMetadataId: testObjectMetadataId, + type: PageLayoutType.RECORD_PAGE, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/page-layouts?objectMetadataId=${testObjectMetadataId}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + + if (response.body.length > 0) { + assertPageLayoutStructure(response.body[0]); + expect(response.body[0].objectMetadataId).toBe(testObjectMetadataId); + } + }); + + it('should return empty array when no page layouts match objectMetadataId', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/page-layouts?objectMetadataId=${TEST_NOT_EXISTING_PAGE_LAYOUT_ID}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(0); + }); + }); + + describe('POST /rest/metadata/page-layouts', () => { + it('should create a new page layout with all properties', async () => { + const pageLayoutName = generateRecordName('Dashboard Page Layout'); + const pageLayout = await createTestPageLayoutWithRestApi({ + name: pageLayoutName, + type: PageLayoutType.DASHBOARD, + objectMetadataId: testObjectMetadataId, + }); + + assertPageLayoutStructure(pageLayout, { + name: pageLayoutName, + type: PageLayoutType.DASHBOARD, + objectMetadataId: testObjectMetadataId, + }); + + await deleteTestPageLayoutWithRestApi(pageLayout.id); + }); + + it('should create a page layout with minimum required fields', async () => { + const pageLayoutName = generateRecordName('Minimal Page Layout'); + const pageLayout = await createTestPageLayoutWithRestApi({ + name: pageLayoutName, + }); + + assertPageLayoutStructure(pageLayout, { + name: pageLayoutName, + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + }); + + await deleteTestPageLayoutWithRestApi(pageLayout.id); + }); + + describe('GET /rest/metadata/page-layouts/:id', () => { + it('should return a page layout by id', async () => { + const pageLayoutName = generateRecordName('Test Page Layout for Get'); + const pageLayout = await createTestPageLayoutWithRestApi({ + name: pageLayoutName, + type: PageLayoutType.DASHBOARD, + objectMetadataId: testObjectMetadataId, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/page-layouts/${pageLayout.id}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertPageLayoutStructure(response.body, { + id: pageLayout.id, + name: pageLayoutName, + type: PageLayoutType.DASHBOARD, + objectMetadataId: testObjectMetadataId, + }); + + await deleteTestPageLayoutWithRestApi(pageLayout.id); + }); + + it('should return {} for non-existent page layout', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/page-layouts/${TEST_NOT_EXISTING_PAGE_LAYOUT_ID}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + ), + ); + }); + }); + + describe('PATCH /rest/metadata/page-layouts/:id', () => { + it('should update an existing page layout', async () => { + const pageLayoutName = generateRecordName( + 'Test Page Layout for Update', + ); + const pageLayout = await createTestPageLayoutWithRestApi({ + name: pageLayoutName, + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: testObjectMetadataId, + }); + + const updatedName = generateRecordName('Updated Page Layout'); + const updateData = { + name: updatedName, + type: PageLayoutType.DASHBOARD, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/page-layouts/${pageLayout.id}`, + body: updateData, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertPageLayoutStructure(response.body, { + id: pageLayout.id, + name: updatedName, + type: PageLayoutType.DASHBOARD, + objectMetadataId: testObjectMetadataId, + }); + + await deleteTestPageLayoutWithRestApi(pageLayout.id); + }); + + it('should update only provided fields', async () => { + const originalName = generateRecordName('Original Page Layout'); + const pageLayout = await createTestPageLayoutWithRestApi({ + name: originalName, + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: testObjectMetadataId, + }); + + const updatedName = generateRecordName('Updated Name Only'); + const updateData = { + name: updatedName, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/page-layouts/${pageLayout.id}`, + body: updateData, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertPageLayoutStructure(response.body, { + id: pageLayout.id, + name: updatedName, + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: testObjectMetadataId, + }); + + await deleteTestPageLayoutWithRestApi(pageLayout.id); + }); + + it('should return 404 error when updating non-existent page layout', async () => { + const updateData = { + name: 'Updated Page Layout', + type: PageLayoutType.DASHBOARD, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/page-layouts/${TEST_NOT_EXISTING_PAGE_LAYOUT_ID}`, + body: updateData, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + ), + ); + }); + }); + + describe('DELETE /rest/metadata/page-layouts/:id', () => { + it('should delete an existing page layout', async () => { + const pageLayoutName = generateRecordName( + 'Test Page Layout for Delete', + ); + const pageLayout = await createTestPageLayoutWithRestApi({ + name: pageLayoutName, + type: PageLayoutType.RECORD_INDEX, + objectMetadataId: testObjectMetadataId, + }); + + const deleteResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/page-layouts/${pageLayout.id}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteResponse); + assertPageLayoutStructure(deleteResponse.body, pageLayout); + + const getResponse = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/page-layouts/${pageLayout.id}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + getResponse, + 404, + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + pageLayout.id, + ), + ); + }); + + it('should return 404 error when deleting non-existent page layout', async () => { + const response = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/page-layouts/${TEST_NOT_EXISTING_PAGE_LAYOUT_ID}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generatePageLayoutExceptionMessage( + PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + ), + ); + }); + }); + + describe('Edge Cases', () => { + it('should handle multiple page layouts for same object', async () => { + const pageLayout1 = await createTestPageLayoutWithRestApi({ + name: generateRecordName('Page Layout 1'), + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: testObjectMetadataId, + }); + + const pageLayout2 = await createTestPageLayoutWithRestApi({ + name: generateRecordName('Page Layout 2'), + type: PageLayoutType.RECORD_INDEX, + objectMetadataId: testObjectMetadataId, + }); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/page-layouts?objectMetadataId=${testObjectMetadataId}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(response.body).toHaveLength(2); + + await Promise.all([ + deleteTestPageLayoutWithRestApi(pageLayout1.id), + deleteTestPageLayoutWithRestApi(pageLayout2.id), + ]); + }); + + it('should handle page layouts with null objectMetadataId', async () => { + const pageLayout = await createTestPageLayoutWithRestApi({ + name: generateRecordName('Global Page Layout'), + type: PageLayoutType.DASHBOARD, + objectMetadataId: null, + }); + + assertPageLayoutStructure(pageLayout, { + type: PageLayoutType.DASHBOARD, + objectMetadataId: null, + }); + + await deleteTestPageLayoutWithRestApi(pageLayout.id); + }); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/utils/page-layout-rest-api.util.ts b/packages/twenty-server/test/integration/rest/utils/page-layout-rest-api.util.ts new file mode 100644 index 00000000000..45c420bf7dd --- /dev/null +++ b/packages/twenty-server/test/integration/rest/utils/page-layout-rest-api.util.ts @@ -0,0 +1,41 @@ +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; + +import { type PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity'; +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; + +export const createTestPageLayoutWithRestApi = async ( + overrides: Partial = {}, +): Promise => { + const pageLayoutData = { + name: generateRecordName('Test Page Layout'), + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: null, + ...overrides, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/page-layouts', + body: pageLayoutData, + bearer: API_KEY_ACCESS_TOKEN, + }); + + if (response.status !== 201) { + throw new Error( + `Failed to create test page layout: ${response.status} - ${JSON.stringify(response.body)}`, + ); + } + + return response.body; +}; + +export const deleteTestPageLayoutWithRestApi = async ( + pageLayoutId: string, +): Promise => { + await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/page-layouts/${pageLayoutId}`, + bearer: API_KEY_ACCESS_TOKEN, + }).catch(() => {}); +}; diff --git a/packages/twenty-server/test/integration/utils/page-layout-test.util.ts b/packages/twenty-server/test/integration/utils/page-layout-test.util.ts new file mode 100644 index 00000000000..4ba370f27f3 --- /dev/null +++ b/packages/twenty-server/test/integration/utils/page-layout-test.util.ts @@ -0,0 +1,24 @@ +import { type PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity'; +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; + +export const cleanupPageLayoutRecords = async (): Promise => { + // @ts-expect-error legacy noImplicitAny + await global.testDataSource.query(`DELETE from "core"."pageLayout"`); +}; + +export const assertPageLayoutStructure = ( + pageLayout: PageLayoutEntity, + expectedFields?: Partial, +) => { + expect(pageLayout).toBeDefined(); + expect(pageLayout.id).toEqual(expect.any(String)); + expect(pageLayout.name).toEqual(expect.any(String)); + expect(Object.values(PageLayoutType)).toContain(pageLayout.type); + expect(pageLayout.workspaceId).toEqual(expect.any(String)); + expect(pageLayout.createdAt).toEqual(expect.any(String)); + expect(pageLayout.updatedAt).toEqual(expect.any(String)); + + if (expectedFields) { + expect(pageLayout).toMatchObject(expectedFields); + } +};