From 9746c3a787faebb88cb6cb4795811b421faaa7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 4 Sep 2025 18:07:33 +0200 Subject: [PATCH] Create PageLayoutTab resolver and controller (#14284) Closes https://github.com/twentyhq/core-team-issues/issues/1394 --- .../src/generated-metadata/graphql.ts | 55 +++ .../twenty-front/src/generated/graphql.ts | 55 +++ ...7652-changePageLayoutTabPositionToFloat.ts | 25 ++ ...aceIdToPageLayoutTabAndPageLayoutWidget.ts | 61 +++ .../controllers/page-layout-tab.controller.ts | 89 ++++ .../controllers/page-layout.controller.ts | 4 +- .../inputs/create-page-layout-tab.input.ts | 29 ++ .../inputs/update-page-layout-tab.input.ts | 16 + .../page-layout/dtos/page-layout-tab.dto.ts | 7 +- .../entities/page-layout-tab.entity.ts | 18 +- .../entities/page-layout-widget.entity.ts | 16 +- .../exceptions/page-layout-tab.exception.ts | 38 ++ .../page-layout-rest-api-exception.filter.ts | 7 +- ...ge-layout-tab-rest-api-exception.filter.ts | 47 ++ .../page-layout/page-layout.module.ts | 11 +- .../resolvers/page-layout-tab.resolver.ts | 85 ++++ .../services/page-layout-tab.service.ts | 189 ++++++++ .../services/page-layout.service.ts | 14 +- .../tests/page-layout-tab.service.spec.ts | 380 ++++++++++++++++ .../tests/page-layout.service.spec.ts | 17 +- ...yout-graphql-api-exception-handler.util.ts | 18 + ...age-layout-graphql-api-exception.filter.ts | 8 +- .../page-layout-gql-fields.constants.ts | 1 + .../test-page-layout-tab-ids.constants.ts | 5 + ...ge-layout-tab-resolver.integration-spec.ts | 406 ++++++++++++++++++ ...eate-page-layout-operation-factory.util.ts | 6 +- ...-page-layout-tab-operation-factory.util.ts | 25 ++ ...-page-layout-tab-operation-factory.util.ts | 18 + ...-page-layout-tab-operation-factory.util.ts | 18 + ...-page-layout-tab-operation-factory.util.ts | 23 + ...page-layout-tabs-operation-factory.util.ts | 23 + .../utils/page-layout-tab-graphql.util.ts | 59 +++ ...-page-layout-tab-operation-factory.util.ts | 23 + ...date-page-layout-operation-factory.util.ts | 4 +- ...-page-layout-tab-operation-factory.util.ts | 28 ++ .../page-layout-tab.integration-spec.ts | 383 +++++++++++++++++ .../utils/page-layout-tab-rest-api.util.ts | 39 ++ .../utils/page-layout-tab-test.util.ts | 23 + 38 files changed, 2241 insertions(+), 32 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1756977847652-changePageLayoutTabPositionToFloat.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1756997911163-addWorkspaceIdToPageLayoutTabAndPageLayoutWidget.ts create mode 100644 packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-tab.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-tab.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/page-layout/filters/page-layout-tab-rest-api-exception.filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts create mode 100644 packages/twenty-server/test/integration/constants/test-page-layout-tab-ids.constants.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-tab-resolver.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/create-page-layout-tab-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/delete-page-layout-tab-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/destroy-page-layout-tab-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-page-layout-tab-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/find-page-layout-tabs-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/page-layout-tab-graphql.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/restore-page-layout-tab-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/update-page-layout-tab-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/rest/suites/page-layout-tab.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/rest/utils/page-layout-tab-rest-api.util.ts create mode 100644 packages/twenty-server/test/integration/utils/page-layout-tab-test.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index c3a0309c48..37af70989f 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -709,6 +709,12 @@ export type CreatePageLayoutInput = { type?: InputMaybe; }; +export type CreatePageLayoutTabInput = { + pageLayoutId: Scalars['UUID']; + position?: InputMaybe; + title: Scalars['String']; +}; + export type CreateRemoteServerInput = { foreignDataWrapperOptions: Scalars['JSON']; foreignDataWrapperType: Scalars['String']; @@ -1389,6 +1395,7 @@ export type Mutation = { createOneRole: Role; createOneServerlessFunction: ServerlessFunction; createPageLayout: PageLayout; + createPageLayoutTab: PageLayoutTab; createSAMLIdentityProvider: SetupSsoOutput; createWebhook: Webhook; createWorkflowVersionEdge: WorkflowVersionStepChanges; @@ -1411,6 +1418,7 @@ export type Mutation = { deleteOneRole: Scalars['String']; deleteOneServerlessFunction: ServerlessFunction; deletePageLayout: PageLayout; + deletePageLayoutTab: Scalars['Boolean']; deleteSSOIdentityProvider: DeleteSsoOutput; deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; @@ -1425,6 +1433,7 @@ export type Mutation = { destroyCoreViewGroup: Scalars['Boolean']; destroyCoreViewSort: Scalars['Boolean']; destroyPageLayout: Scalars['Boolean']; + destroyPageLayoutTab: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -1449,6 +1458,7 @@ export type Mutation = { resendEmailVerificationToken: ResendEmailVerificationTokenOutput; resendWorkspaceInvitation: SendInvitationsOutput; restorePageLayout: PageLayout; + restorePageLayoutTab: PageLayoutTab; revokeApiKey?: Maybe; runWorkflowVersion: WorkflowRun; saveImapSmtpCaldavAccount: ImapSmtpCaldavConnectionSuccess; @@ -1482,6 +1492,7 @@ export type Mutation = { updateOneRole: Role; updateOneServerlessFunction: ServerlessFunction; updatePageLayout: PageLayout; + updatePageLayoutTab: PageLayoutTab; updatePasswordViaResetToken: InvalidatePassword; updateSubscriptionItemPrice: BillingUpdateOutput; updateWebhook?: Maybe; @@ -1665,6 +1676,11 @@ export type MutationCreatePageLayoutArgs = { }; +export type MutationCreatePageLayoutTabArgs = { + input: CreatePageLayoutTabInput; +}; + + export type MutationCreateSamlIdentityProviderArgs = { input: SetupSamlSsoInput; }; @@ -1770,6 +1786,11 @@ export type MutationDeletePageLayoutArgs = { }; +export type MutationDeletePageLayoutTabArgs = { + id: Scalars['String']; +}; + + export type MutationDeleteSsoIdentityProviderArgs = { input: DeleteSsoInput; }; @@ -1835,6 +1856,11 @@ export type MutationDestroyPageLayoutArgs = { }; +export type MutationDestroyPageLayoutTabArgs = { + id: Scalars['String']; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1949,6 +1975,11 @@ export type MutationRestorePageLayoutArgs = { }; +export type MutationRestorePageLayoutTabArgs = { + id: Scalars['String']; +}; + + export type MutationRevokeApiKeyArgs = { input: RevokeApiKeyDto; }; @@ -2118,6 +2149,12 @@ export type MutationUpdatePageLayoutArgs = { }; +export type MutationUpdatePageLayoutTabArgs = { + id: Scalars['String']; + input: UpdatePageLayoutTabInput; +}; + + export type MutationUpdatePasswordViaResetTokenArgs = { newPassword: Scalars['String']; passwordResetToken: Scalars['String']; @@ -2406,6 +2443,7 @@ export type PageLayoutTab = { position: Scalars['Float']; title: Scalars['String']; updatedAt: Scalars['DateTime']; + workspaceId: Scalars['UUID']; }; export enum PageLayoutType { @@ -2533,6 +2571,8 @@ export type Query = { getIndicatorHealthStatus: AdminPanelHealthServiceData; getMeteredProductsUsage: Array; getPageLayout?: Maybe; + getPageLayoutTab: PageLayoutTab; + getPageLayoutTabs: Array; getPageLayouts: Array; getPostgresCredentials?: Maybe; getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput; @@ -2747,6 +2787,16 @@ export type QueryGetPageLayoutArgs = { }; +export type QueryGetPageLayoutTabArgs = { + id: Scalars['String']; +}; + + +export type QueryGetPageLayoutTabsArgs = { + pageLayoutId: Scalars['String']; +}; + + export type QueryGetPageLayoutsArgs = { objectMetadataId?: InputMaybe; }; @@ -3374,6 +3424,11 @@ export type UpdatePageLayoutInput = { type?: InputMaybe; }; +export type UpdatePageLayoutTabInput = { + position?: InputMaybe; + title?: 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 bcbb3e46d1..3e53467d78 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -681,6 +681,12 @@ export type CreatePageLayoutInput = { type?: InputMaybe; }; +export type CreatePageLayoutTabInput = { + pageLayoutId: Scalars['UUID']; + position?: InputMaybe; + title: Scalars['String']; +}; + export type CreateRoleInput = { canAccessAllTools?: InputMaybe; canBeAssignedToAgents?: InputMaybe; @@ -1345,6 +1351,7 @@ export type Mutation = { createOneRole: Role; createOneServerlessFunction: ServerlessFunction; createPageLayout: PageLayout; + createPageLayoutTab: PageLayoutTab; createSAMLIdentityProvider: SetupSsoOutput; createWebhook: Webhook; createWorkflowVersionEdge: WorkflowVersionStepChanges; @@ -1366,6 +1373,7 @@ export type Mutation = { deleteOneRole: Scalars['String']; deleteOneServerlessFunction: ServerlessFunction; deletePageLayout: PageLayout; + deletePageLayoutTab: Scalars['Boolean']; deleteSSOIdentityProvider: DeleteSsoOutput; deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput; deleteUser: User; @@ -1380,6 +1388,7 @@ export type Mutation = { destroyCoreViewGroup: Scalars['Boolean']; destroyCoreViewSort: Scalars['Boolean']; destroyPageLayout: Scalars['Boolean']; + destroyPageLayoutTab: Scalars['Boolean']; disablePostgresProxy: PostgresCredentials; editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; @@ -1404,6 +1413,7 @@ export type Mutation = { resendEmailVerificationToken: ResendEmailVerificationTokenOutput; resendWorkspaceInvitation: SendInvitationsOutput; restorePageLayout: PageLayout; + restorePageLayoutTab: PageLayoutTab; revokeApiKey?: Maybe; runWorkflowVersion: WorkflowRun; saveImapSmtpCaldavAccount: ImapSmtpCaldavConnectionSuccess; @@ -1433,6 +1443,7 @@ export type Mutation = { updateOneRole: Role; updateOneServerlessFunction: ServerlessFunction; updatePageLayout: PageLayout; + updatePageLayoutTab: PageLayoutTab; updatePasswordViaResetToken: InvalidatePassword; updateSubscriptionItemPrice: BillingUpdateOutput; updateWebhook?: Maybe; @@ -1601,6 +1612,11 @@ export type MutationCreatePageLayoutArgs = { }; +export type MutationCreatePageLayoutTabArgs = { + input: CreatePageLayoutTabInput; +}; + + export type MutationCreateSamlIdentityProviderArgs = { input: SetupSamlSsoInput; }; @@ -1701,6 +1717,11 @@ export type MutationDeletePageLayoutArgs = { }; +export type MutationDeletePageLayoutTabArgs = { + id: Scalars['String']; +}; + + export type MutationDeleteSsoIdentityProviderArgs = { input: DeleteSsoInput; }; @@ -1766,6 +1787,11 @@ export type MutationDestroyPageLayoutArgs = { }; +export type MutationDestroyPageLayoutTabArgs = { + id: Scalars['String']; +}; + + export type MutationEditSsoIdentityProviderArgs = { input: EditSsoInput; }; @@ -1880,6 +1906,11 @@ export type MutationRestorePageLayoutArgs = { }; +export type MutationRestorePageLayoutTabArgs = { + id: Scalars['String']; +}; + + export type MutationRevokeApiKeyArgs = { input: RevokeApiKeyDto; }; @@ -2029,6 +2060,12 @@ export type MutationUpdatePageLayoutArgs = { }; +export type MutationUpdatePageLayoutTabArgs = { + id: Scalars['String']; + input: UpdatePageLayoutTabInput; +}; + + export type MutationUpdatePasswordViaResetTokenArgs = { newPassword: Scalars['String']; passwordResetToken: Scalars['String']; @@ -2317,6 +2354,7 @@ export type PageLayoutTab = { position: Scalars['Float']; title: Scalars['String']; updatedAt: Scalars['DateTime']; + workspaceId: Scalars['UUID']; }; export enum PageLayoutType { @@ -2441,6 +2479,8 @@ export type Query = { getIndicatorHealthStatus: AdminPanelHealthServiceData; getMeteredProductsUsage: Array; getPageLayout?: Maybe; + getPageLayoutTab: PageLayoutTab; + getPageLayoutTabs: Array; getPageLayouts: Array; getPostgresCredentials?: Maybe; getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput; @@ -2629,6 +2669,16 @@ export type QueryGetPageLayoutArgs = { }; +export type QueryGetPageLayoutTabArgs = { + id: Scalars['String']; +}; + + +export type QueryGetPageLayoutTabsArgs = { + pageLayoutId: Scalars['String']; +}; + + export type QueryGetPageLayoutsArgs = { objectMetadataId?: InputMaybe; }; @@ -3220,6 +3270,11 @@ export type UpdatePageLayoutInput = { type?: InputMaybe; }; +export type UpdatePageLayoutTabInput = { + position?: InputMaybe; + title?: 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/1756977847652-changePageLayoutTabPositionToFloat.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756977847652-changePageLayoutTabPositionToFloat.ts new file mode 100644 index 0000000000..79ebab9b46 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756977847652-changePageLayoutTabPositionToFloat.ts @@ -0,0 +1,25 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class ChangePageLayoutTabPositionToFloat1756977847652 + implements MigrationInterface +{ + name = 'ChangePageLayoutTabPositionToFloat1756977847652'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutTab" DROP COLUMN "position"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutTab" ADD "position" double precision NOT NULL DEFAULT '0'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutTab" DROP COLUMN "position"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutTab" ADD "position" integer NOT NULL DEFAULT '0'`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1756997911163-addWorkspaceIdToPageLayoutTabAndPageLayoutWidget.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756997911163-addWorkspaceIdToPageLayoutTabAndPageLayoutWidget.ts new file mode 100644 index 0000000000..910a4c9ed0 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1756997911163-addWorkspaceIdToPageLayoutTabAndPageLayoutWidget.ts @@ -0,0 +1,61 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddWorkspaceIdToPageLayoutTabAndPageLayoutWidget1756997911163 + implements MigrationInterface +{ + name = 'AddWorkspaceIdToPageLayoutTabAndPageLayoutWidget1756997911163'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "core"."IDX_PAGE_LAYOUT_WIDGET_PAGE_LAYOUT_TAB_ID"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_PAGE_LAYOUT_TAB_PAGE_LAYOUT_ID"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutWidget" ADD "workspaceId" uuid NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutTab" ADD "workspaceId" uuid NOT NULL`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_PAGE_LAYOUT_WIDGET_WORKSPACE_ID_PAGE_LAYOUT_TAB_ID" ON "core"."pageLayoutWidget" ("workspaceId", "pageLayoutTabId") WHERE "deletedAt" IS NULL`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_PAGE_LAYOUT_TAB_WORKSPACE_ID_PAGE_LAYOUT_ID" ON "core"."pageLayoutTab" ("workspaceId", "pageLayoutId") WHERE "deletedAt" IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutWidget" ADD CONSTRAINT "FK_555948f84165dce1fe1f5f955ce" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutTab" ADD CONSTRAINT "FK_2528e67c8c0c953d8303172989e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutTab" DROP CONSTRAINT "FK_2528e67c8c0c953d8303172989e"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutWidget" DROP CONSTRAINT "FK_555948f84165dce1fe1f5f955ce"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_PAGE_LAYOUT_TAB_WORKSPACE_ID_PAGE_LAYOUT_ID"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_PAGE_LAYOUT_WIDGET_WORKSPACE_ID_PAGE_LAYOUT_TAB_ID"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutTab" DROP COLUMN "workspaceId"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutWidget" DROP COLUMN "workspaceId"`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_PAGE_LAYOUT_TAB_PAGE_LAYOUT_ID" ON "core"."pageLayoutTab" ("pageLayoutId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_PAGE_LAYOUT_WIDGET_PAGE_LAYOUT_TAB_ID" ON "core"."pageLayoutWidget" ("pageLayoutTabId") `, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-tab.controller.ts b/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-tab.controller.ts new file mode 100644 index 0000000000..9d99f8ce2b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-tab.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreatePageLayoutTabInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-tab.input'; +import { UpdatePageLayoutTabInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab.input'; +import { type PageLayoutTabDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout-tab.dto'; +import { + generatePageLayoutTabExceptionMessage, + PageLayoutTabException, + PageLayoutTabExceptionCode, + PageLayoutTabExceptionMessageKey, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception'; +import { PageLayoutTabRestApiExceptionFilter } from 'src/engine/core-modules/page-layout/filters/page-layout-tab-rest-api-exception.filter'; +import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.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-layout-tabs') +@UseGuards(WorkspaceAuthGuard) +@UseFilters(PageLayoutTabRestApiExceptionFilter) +export class PageLayoutTabController { + constructor(private readonly pageLayoutTabService: PageLayoutTabService) {} + + @Get() + async findMany( + @AuthWorkspace() workspace: Workspace, + @Query('pageLayoutId') pageLayoutId: string, + ): Promise { + if (!isDefined(pageLayoutId)) { + throw new PageLayoutTabException( + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_ID_REQUIRED, + ), + PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA, + ); + } + + return this.pageLayoutTabService.findByPageLayoutId( + workspace.id, + pageLayoutId, + ); + } + + @Get(':id') + async findOne( + @Param('id') id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutTabService.findByIdOrThrow(id, workspace.id); + } + + @Post() + async create( + @Body() input: CreatePageLayoutTabInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutTabService.create(input, workspace.id); + } + + @Patch(':id') + async update( + @Param('id') id: string, + @Body() input: UpdatePageLayoutTabInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutTabService.update(id, workspace.id, input); + } + + @Delete(':id') + async delete( + @Param('id') id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutTabService.delete(id, workspace.id); + } +} 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 index 2661a74aff..75108b25ee 100644 --- 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 @@ -11,6 +11,8 @@ import { UseGuards, } from '@nestjs/common'; +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 { type PageLayoutDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout.dto'; @@ -32,7 +34,7 @@ export class PageLayoutController { @AuthWorkspace() workspace: Workspace, @Query('objectMetadataId') objectMetadataId?: string, ): Promise { - if (objectMetadataId) { + if (isDefined(objectMetadataId)) { return this.pageLayoutService.findByObjectMetadataId( workspace.id, objectMetadataId, diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-tab.input.ts b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-tab.input.ts new file mode 100644 index 0000000000..6be51db393 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-tab.input.ts @@ -0,0 +1,29 @@ +import { Field, Float, InputType } from '@nestjs/graphql'; + +import { + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@InputType() +export class CreatePageLayoutTabInput { + @Field({ nullable: false }) + @IsString() + @IsNotEmpty() + title: string; + + @Field(() => Float, { nullable: true }) + @IsNumber() + @IsOptional() + position?: number; + + @Field(() => UUIDScalarType, { nullable: false }) + @IsUUID() + @IsNotEmpty() + pageLayoutId: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab.input.ts b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab.input.ts new file mode 100644 index 0000000000..272986b8dc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab.input.ts @@ -0,0 +1,16 @@ +import { Field, Float, InputType } from '@nestjs/graphql'; + +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +@InputType() +export class UpdatePageLayoutTabInput { + @Field({ nullable: true }) + @IsString() + @IsOptional() + title?: string; + + @Field(() => Float, { nullable: true }) + @IsNumber() + @IsOptional() + position?: number; +} 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 index 1aa67b1536..eb84280f5e 100644 --- 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 @@ -1,4 +1,4 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, Float, ObjectType } from '@nestjs/graphql'; import { IDField } from '@ptc-org/nestjs-query-graphql'; @@ -12,12 +12,15 @@ export class PageLayoutTabDTO { @Field({ nullable: false }) title: string; - @Field({ nullable: false, defaultValue: 0 }) + @Field(() => Float, { nullable: false, defaultValue: 0 }) position: number; @Field(() => UUIDScalarType, { nullable: false }) pageLayoutId: string; + @Field(() => UUIDScalarType, { nullable: false }) + workspaceId: string; + @Field() createdAt: Date; diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout-tab.entity.ts b/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout-tab.entity.ts index 7e822ff013..ecc51505eb 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout-tab.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout-tab.entity.ts @@ -14,9 +14,14 @@ import { 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @Entity({ name: 'pageLayoutTab', schema: 'core' }) -@Index('IDX_PAGE_LAYOUT_TAB_PAGE_LAYOUT_ID', ['pageLayoutId']) +@Index( + 'IDX_PAGE_LAYOUT_TAB_WORKSPACE_ID_PAGE_LAYOUT_ID', + ['workspaceId', 'pageLayoutId'], + { where: '"deletedAt" IS NULL' }, +) export class PageLayoutTabEntity implements Required { @PrimaryGeneratedColumn('uuid') id: string; @@ -24,7 +29,16 @@ export class PageLayoutTabEntity implements Required { @Column({ nullable: false }) title: string; - @Column({ nullable: false, type: 'int', default: 0 }) + @Column({ nullable: false, type: 'uuid' }) + workspaceId: string; + + @ManyToOne(() => Workspace, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation; + + @Column({ nullable: false, type: 'float', default: 0 }) position: number; @Column({ nullable: false, type: 'uuid' }) diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout-widget.entity.ts b/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout-widget.entity.ts index 8d157e0dd1..c7280fbbdb 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout-widget.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/entities/page-layout-widget.entity.ts @@ -14,10 +14,15 @@ import { import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity'; import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum'; import { GridPosition } from 'src/engine/core-modules/page-layout/types/grid-position.type'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @Entity({ name: 'pageLayoutWidget', schema: 'core' }) -@Index('IDX_PAGE_LAYOUT_WIDGET_PAGE_LAYOUT_TAB_ID', ['pageLayoutTabId']) +@Index( + 'IDX_PAGE_LAYOUT_WIDGET_WORKSPACE_ID_PAGE_LAYOUT_TAB_ID', + ['workspaceId', 'pageLayoutTabId'], + { where: '"deletedAt" IS NULL' }, +) export class PageLayoutWidgetEntity implements Required { @@ -27,6 +32,15 @@ export class PageLayoutWidgetEntity @Column({ nullable: false, type: 'uuid' }) pageLayoutTabId: string; + @Column({ nullable: false, type: 'uuid' }) + workspaceId: string; + + @ManyToOne(() => Workspace, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation; + @ManyToOne(() => PageLayoutTabEntity, { onDelete: 'CASCADE', }) diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception.ts b/packages/twenty-server/src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception.ts new file mode 100644 index 0000000000..a92f28cb6e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception.ts @@ -0,0 +1,38 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { CustomException } from 'src/utils/custom-exception'; + +export enum PageLayoutTabExceptionCode { + PAGE_LAYOUT_TAB_NOT_FOUND = 'PAGE_LAYOUT_TAB_NOT_FOUND', + INVALID_PAGE_LAYOUT_TAB_DATA = 'INVALID_PAGE_LAYOUT_TAB_DATA', +} + +export enum PageLayoutTabExceptionMessageKey { + PAGE_LAYOUT_TAB_NOT_FOUND = 'PAGE_LAYOUT_TAB_NOT_FOUND', + TITLE_REQUIRED = 'TITLE_REQUIRED', + PAGE_LAYOUT_ID_REQUIRED = 'PAGE_LAYOUT_ID_REQUIRED', + PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND', + PAGE_LAYOUT_TAB_NOT_DELETED = 'PAGE_LAYOUT_TAB_NOT_DELETED', +} + +export class PageLayoutTabException extends CustomException {} + +export const generatePageLayoutTabExceptionMessage = ( + key: PageLayoutTabExceptionMessageKey, + value?: string, +): string => { + switch (key) { + case PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND: + return `Page layout tab with ID "${value}" not found`; + case PageLayoutTabExceptionMessageKey.TITLE_REQUIRED: + return 'Page layout tab title is required'; + case PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_ID_REQUIRED: + return 'Page layout ID is required'; + case PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND: + return 'Page layout not found'; + case PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_DELETED: + return 'Page layout tab is not deleted and cannot be restored'; + 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 index 475f719eb1..af443dec38 100644 --- 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 @@ -11,7 +11,6 @@ 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 { @@ -26,20 +25,20 @@ export class PageLayoutRestApiExceptionFilter implements ExceptionFilter { switch (exception.code) { case PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND: return this.httpExceptionHandlerService.handleError( - exception as CustomException, + exception, response, 404, ); case PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA: return this.httpExceptionHandlerService.handleError( - exception as CustomException, + exception, response, 400, ); default: // TODO: change to 500 when we have input validation return this.httpExceptionHandlerService.handleError( - exception as CustomException, + exception, response, 400, ); diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/filters/page-layout-tab-rest-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/page-layout/filters/page-layout-tab-rest-api-exception.filter.ts new file mode 100644 index 0000000000..7eb21b63ee --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/filters/page-layout-tab-rest-api-exception.filter.ts @@ -0,0 +1,47 @@ +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 { + PageLayoutTabException, + PageLayoutTabExceptionCode, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception'; + +@Catch(PageLayoutTabException) +export class PageLayoutTabRestApiExceptionFilter implements ExceptionFilter { + constructor( + private readonly httpExceptionHandlerService: HttpExceptionHandlerService, + ) {} + + catch(exception: PageLayoutTabException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + switch (exception.code) { + case PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND: + return this.httpExceptionHandlerService.handleError( + exception, + response, + 404, + ); + case PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA: + return this.httpExceptionHandlerService.handleError( + exception, + response, + 400, + ); + default: + // TODO: change to 500 when we have input validation + return this.httpExceptionHandlerService.handleError( + exception, + 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 b4c6ea639f..8d1c734f3c 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,10 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PageLayoutTabController } from 'src/engine/core-modules/page-layout/controllers/page-layout-tab.controller'; 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 { PageLayoutTabResolver } from 'src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver'; 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'; @@ -17,8 +19,13 @@ import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/ PageLayoutWidgetEntity, ]), ], - controllers: [PageLayoutController], - providers: [PageLayoutService, PageLayoutTabService, PageLayoutResolver], + controllers: [PageLayoutController, PageLayoutTabController], + providers: [ + PageLayoutService, + PageLayoutTabService, + PageLayoutResolver, + PageLayoutTabResolver, + ], exports: [PageLayoutService, PageLayoutTabService], }) export class PageLayoutModule {} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts new file mode 100644 index 0000000000..1375e4a838 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts @@ -0,0 +1,85 @@ +import { UseFilters, UseGuards } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CreatePageLayoutTabInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-tab.input'; +import { UpdatePageLayoutTabInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab.input'; +import { PageLayoutTabDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout-tab.dto'; +import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.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(() => PageLayoutTabDTO) +@UseFilters(PageLayoutGraphqlApiExceptionFilter) +@UseGuards(WorkspaceAuthGuard) +export class PageLayoutTabResolver { + constructor(private readonly pageLayoutTabService: PageLayoutTabService) {} + + @Query(() => [PageLayoutTabDTO]) + async getPageLayoutTabs( + @AuthWorkspace() workspace: Workspace, + @Args('pageLayoutId', { type: () => String }) pageLayoutId: string, + ): Promise { + return this.pageLayoutTabService.findByPageLayoutId( + workspace.id, + pageLayoutId, + ); + } + + @Query(() => PageLayoutTabDTO) + async getPageLayoutTab( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutTabService.findByIdOrThrow(id, workspace.id); + } + + @Mutation(() => PageLayoutTabDTO) + async createPageLayoutTab( + @Args('input') input: CreatePageLayoutTabInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutTabService.create(input, workspace.id); + } + + @Mutation(() => PageLayoutTabDTO) + async updatePageLayoutTab( + @Args('id', { type: () => String }) id: string, + @Args('input') input: UpdatePageLayoutTabInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutTabService.update(id, workspace.id, input); + } + + @Mutation(() => Boolean) + async deletePageLayoutTab( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + const deletedPageLayoutTab = await this.pageLayoutTabService.delete( + id, + workspace.id, + ); + + return isDefined(deletedPageLayoutTab); + } + + @Mutation(() => Boolean) + async destroyPageLayoutTab( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutTabService.destroy(id, workspace.id); + } + + @Mutation(() => PageLayoutTabDTO) + async restorePageLayoutTab( + @Args('id', { type: () => String }) id: string, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutTabService.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 index b908aa6d21..efd68cb0fa 100644 --- 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 @@ -1,15 +1,29 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { isDefined } from 'twenty-shared/utils'; import { IsNull, Repository } from 'typeorm'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity'; +import { + PageLayoutTabException, + PageLayoutTabExceptionCode, + PageLayoutTabExceptionMessageKey, + generatePageLayoutTabExceptionMessage, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception'; +import { + PageLayoutException, + PageLayoutExceptionCode, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception'; +import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service'; @Injectable() export class PageLayoutTabService { constructor( @InjectRepository(PageLayoutTabEntity) private readonly pageLayoutTabRepository: Repository, + private readonly pageLayoutService: PageLayoutService, ) {} async findByPageLayoutId( @@ -26,4 +40,179 @@ export class PageLayoutTabService { relations: ['widgets'], }); } + + async findByIdOrThrow( + id: string, + workspaceId: string, + ): Promise { + const pageLayoutTab = await this.pageLayoutTabRepository.findOne({ + where: { + id, + workspaceId, + deletedAt: IsNull(), + }, + relations: ['widgets'], + }); + + if (!isDefined(pageLayoutTab)) { + throw new PageLayoutTabException( + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + id, + ), + PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND, + ); + } + + return pageLayoutTab; + } + + async create( + pageLayoutTabData: Partial, + workspaceId: string, + ): Promise { + if (!isDefined(pageLayoutTabData.title)) { + throw new PageLayoutTabException( + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.TITLE_REQUIRED, + ), + PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA, + ); + } + + if (!isDefined(pageLayoutTabData.pageLayoutId)) { + throw new PageLayoutTabException( + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_ID_REQUIRED, + ), + PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA, + ); + } + + try { + await this.pageLayoutService.findByIdOrThrow( + pageLayoutTabData.pageLayoutId, + workspaceId, + ); + + const pageLayoutTab = this.pageLayoutTabRepository.create({ + ...pageLayoutTabData, + workspaceId, + }); + + return this.pageLayoutTabRepository.save(pageLayoutTab); + } catch (error) { + if ( + error instanceof PageLayoutException && + error.code === PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND + ) { + throw new PageLayoutTabException( + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + ), + PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA, + ); + } + throw error; + } + } + + async update( + id: string, + workspaceId: string, + updateData: QueryDeepPartialEntity, + ): Promise { + const existingTab = await this.pageLayoutTabRepository.findOne({ + where: { + id, + workspaceId, + deletedAt: IsNull(), + }, + }); + + if (!isDefined(existingTab)) { + throw new PageLayoutTabException( + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + id, + ), + PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND, + ); + } + + await this.pageLayoutTabRepository.update({ id }, updateData); + + return this.findByIdOrThrow(id, workspaceId); + } + + async delete(id: string, workspaceId: string): Promise { + const pageLayoutTab = await this.findByIdOrThrow(id, workspaceId); + + await this.pageLayoutTabRepository.softDelete(id); + + return pageLayoutTab; + } + + async destroy(id: string, workspaceId: string): Promise { + const pageLayoutTab = await this.pageLayoutTabRepository.findOne({ + where: { + id, + workspaceId, + }, + withDeleted: true, + }); + + if (!isDefined(pageLayoutTab)) { + throw new PageLayoutTabException( + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + id, + ), + PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND, + ); + } + + await this.pageLayoutTabRepository.delete(id); + + return true; + } + + async restore(id: string, workspaceId: string): Promise { + const pageLayoutTab = await this.pageLayoutTabRepository.findOne({ + select: { + id: true, + deletedAt: true, + }, + where: { + id, + workspaceId, + }, + withDeleted: true, + }); + + if (!isDefined(pageLayoutTab)) { + throw new PageLayoutTabException( + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + id, + ), + PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND, + ); + } + + if (!isDefined(pageLayoutTab.deletedAt)) { + throw new PageLayoutTabException( + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_DELETED, + ), + PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA, + ); + } + + await this.pageLayoutTabRepository.restore(id); + + const restoredPageLayoutTab = await this.findByIdOrThrow(id, workspaceId); + + return restoredPageLayoutTab; + } } 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 index a4e5c36429..cf3e00e786 100644 --- 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 @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; import { IsNull, Repository } from 'typeorm'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity'; import { @@ -93,14 +94,11 @@ export class PageLayoutService { async update( id: string, workspaceId: string, - updateData: Partial, + updateData: QueryDeepPartialEntity, ): Promise { - const existingPageLayout = await this.findByIdOrThrow(id, workspaceId); + await this.pageLayoutRepository.update({ id, workspaceId }, updateData); - const updatedPageLayout = await this.pageLayoutRepository.save({ - ...existingPageLayout, - ...updateData, - }); + const updatedPageLayout = await this.findByIdOrThrow(id, workspaceId); return updatedPageLayout; } @@ -139,6 +137,10 @@ export class PageLayoutService { async restore(id: string, workspaceId: string): Promise { const pageLayout = await this.pageLayoutRepository.findOne({ + select: { + id: true, + deletedAt: true, + }, where: { id, workspaceId, 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 index 20a1ad796e..37abf6e81f 100644 --- 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 @@ -6,11 +6,21 @@ 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 { + PageLayoutTabException, + PageLayoutTabExceptionCode, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception'; +import { + PageLayoutException, + PageLayoutExceptionCode, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception'; 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'; describe('PageLayoutTabService', () => { let pageLayoutTabService: PageLayoutTabService; let pageLayoutTabRepository: Repository; + let pageLayoutService: PageLayoutService; const mockPageLayoutTab = { id: 'page-layout-tab-id', @@ -18,6 +28,8 @@ describe('PageLayoutTabService', () => { position: 0, pageLayoutId: 'page-layout-id', pageLayout: {} as any, + workspaceId: 'workspace-id', + workspace: {} as any, widgets: [], createdAt: new Date(), updatedAt: new Date(), @@ -37,6 +49,15 @@ describe('PageLayoutTabService', () => { deletedAt: null, } as PageLayoutWidgetEntity; + const mockPageLayout = { + id: 'page-layout-id', + workspaceId: 'workspace-id', + title: 'Test Layout', + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }; + beforeEach(async () => { jest.clearAllMocks(); @@ -47,6 +68,19 @@ describe('PageLayoutTabService', () => { provide: getRepositoryToken(PageLayoutTabEntity), useValue: { find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + delete: jest.fn(), + restore: jest.fn(), + }, + }, + { + provide: PageLayoutService, + useValue: { + findByIdOrThrow: jest.fn(), }, }, ], @@ -57,6 +91,7 @@ describe('PageLayoutTabService', () => { pageLayoutTabRepository = module.get>( getRepositoryToken(PageLayoutTabEntity), ); + pageLayoutService = module.get(PageLayoutService); }); it('should be defined', () => { @@ -161,4 +196,349 @@ describe('PageLayoutTabService', () => { expect(result[0].widgets[1].id).toEqual('widget-2'); }); }); + + describe('findByIdOrThrow', () => { + it('should return page layout tab when found', async () => { + const id = 'page-layout-tab-id'; + const workspaceId = 'workspace-id'; + + jest + .spyOn(pageLayoutTabRepository, 'findOne') + .mockResolvedValue(mockPageLayoutTab); + + const result = await pageLayoutTabService.findByIdOrThrow( + id, + workspaceId, + ); + + expect(pageLayoutTabRepository.findOne).toHaveBeenCalledWith({ + where: { + id, + workspaceId, + deletedAt: expect.anything(), + }, + relations: ['widgets'], + }); + expect(result).toEqual(mockPageLayoutTab); + }); + + it('should throw exception when page layout tab is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + + jest.spyOn(pageLayoutTabRepository, 'findOne').mockResolvedValue(null); + + await expect( + pageLayoutTabService.findByIdOrThrow(id, workspaceId), + ).rejects.toThrow(PageLayoutTabException); + }); + }); + + describe('create', () => { + it('should create a new page layout tab successfully', async () => { + const workspaceId = 'workspace-id'; + const pageLayoutTabData = { + title: 'New Tab', + pageLayoutId: 'page-layout-id', + position: 1, + }; + + jest + .spyOn(pageLayoutService, 'findByIdOrThrow') + .mockResolvedValue(mockPageLayout as any); + jest + .spyOn(pageLayoutTabRepository, 'create') + .mockReturnValue(mockPageLayoutTab); + jest + .spyOn(pageLayoutTabRepository, 'save') + .mockResolvedValue(mockPageLayoutTab); + + const result = await pageLayoutTabService.create( + pageLayoutTabData, + workspaceId, + ); + + expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith( + pageLayoutTabData.pageLayoutId, + workspaceId, + ); + expect(pageLayoutTabRepository.create).toHaveBeenCalledWith({ + ...pageLayoutTabData, + workspaceId, + }); + expect(pageLayoutTabRepository.save).toHaveBeenCalledWith( + mockPageLayoutTab, + ); + expect(result).toEqual(mockPageLayoutTab); + }); + + it('should throw an exception when title is not provided', async () => { + const workspaceId = 'workspace-id'; + const pageLayoutTabData = { + pageLayoutId: 'page-layout-id', + }; + + await expect( + pageLayoutTabService.create(pageLayoutTabData, workspaceId), + ).rejects.toThrow(PageLayoutTabException); + await expect( + pageLayoutTabService.create(pageLayoutTabData, workspaceId), + ).rejects.toHaveProperty( + 'code', + PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA, + ); + }); + + it('should throw an exception when page layout does not exist', async () => { + const workspaceId = 'workspace-id'; + const pageLayoutTabData = { + title: 'New Tab', + pageLayoutId: 'non-existent-page-layout-id', + }; + + jest + .spyOn(pageLayoutService, 'findByIdOrThrow') + .mockRejectedValue( + new PageLayoutException( + 'Page layout not found', + PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND, + ), + ); + + await expect( + pageLayoutTabService.create(pageLayoutTabData, workspaceId), + ).rejects.toThrow(PageLayoutTabException); + await expect( + pageLayoutTabService.create(pageLayoutTabData, workspaceId), + ).rejects.toHaveProperty( + 'code', + PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA, + ); + }); + + it('should throw an exception when page layout is not found', async () => { + const workspaceId = 'workspace-id'; + const pageLayoutTabData = { + title: 'New Tab', + pageLayoutId: 'non-existent-page-layout-id', + }; + + jest + .spyOn(pageLayoutService, 'findByIdOrThrow') + .mockRejectedValue(new Error('Page layout not found')); + + await expect( + pageLayoutTabService.create(pageLayoutTabData, workspaceId), + ).rejects.toThrow(); + }); + }); + + describe('update', () => { + it('should update a page layout tab successfully', async () => { + const id = 'page-layout-tab-id'; + const workspaceId = 'workspace-id'; + const updateData = { title: 'Updated Tab' }; + const updatedTab = { ...mockPageLayoutTab, title: 'Updated Tab' }; + + jest + .spyOn(pageLayoutTabRepository, 'findOne') + .mockResolvedValue(mockPageLayoutTab); + + jest.spyOn(pageLayoutTabRepository, 'update').mockResolvedValue({ + affected: 1, + generatedMaps: [], + raw: {}, + }); + jest + .spyOn(pageLayoutTabService, 'findByIdOrThrow') + .mockResolvedValue(updatedTab); + + const result = await pageLayoutTabService.update( + id, + workspaceId, + updateData, + ); + + expect(pageLayoutTabRepository.update).toHaveBeenCalledWith( + { id }, + updateData, + ); + expect(result).toEqual(updatedTab); + }); + + it('should throw an exception when tab to update is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + const updateData = { title: 'Updated Tab' }; + + jest.spyOn(pageLayoutTabRepository, 'update').mockResolvedValue({ + affected: 1, + generatedMaps: [], + raw: {}, + }); + jest + .spyOn(pageLayoutTabService, 'findByIdOrThrow') + .mockRejectedValue( + new PageLayoutTabException( + 'Page layout tab not found', + PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND, + ), + ); + + await expect( + pageLayoutTabService.update(id, workspaceId, updateData), + ).rejects.toThrow(PageLayoutTabException); + }); + }); + + describe('delete', () => { + it('should soft delete a page layout tab successfully', async () => { + const id = 'page-layout-tab-id'; + const workspaceId = 'workspace-id'; + + jest + .spyOn(pageLayoutTabService, 'findByIdOrThrow') + .mockResolvedValue(mockPageLayoutTab); + jest + .spyOn(pageLayoutTabRepository, 'softDelete') + .mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} }); + + const result = await pageLayoutTabService.delete(id, workspaceId); + + expect(pageLayoutTabRepository.softDelete).toHaveBeenCalledWith(id); + expect(result).toEqual(mockPageLayoutTab); + }); + + it('should throw an exception when tab to delete is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + + jest + .spyOn(pageLayoutTabService, 'findByIdOrThrow') + .mockRejectedValue( + new PageLayoutTabException( + 'Page layout tab not found', + PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND, + ), + ); + + await expect( + pageLayoutTabService.delete(id, workspaceId), + ).rejects.toThrow(PageLayoutTabException); + }); + }); + + describe('destroy', () => { + it('should permanently delete a page layout tab successfully', async () => { + const id = 'page-layout-tab-id'; + const workspaceId = 'workspace-id'; + + jest + .spyOn(pageLayoutTabRepository, 'findOne') + .mockResolvedValue(mockPageLayoutTab); + jest + .spyOn(pageLayoutTabRepository, 'delete') + .mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} }); + + const result = await pageLayoutTabService.destroy(id, workspaceId); + + expect(pageLayoutTabRepository.findOne).toHaveBeenCalledWith({ + where: { + id, + workspaceId, + }, + withDeleted: true, + }); + expect(pageLayoutTabRepository.delete).toHaveBeenCalledWith(id); + expect(result).toBe(true); + }); + + it('should throw an exception when tab to destroy is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + + jest.spyOn(pageLayoutTabRepository, 'findOne').mockResolvedValue(null); + + await expect( + pageLayoutTabService.destroy(id, workspaceId), + ).rejects.toThrow(PageLayoutTabException); + await expect( + pageLayoutTabService.destroy(id, workspaceId), + ).rejects.toHaveProperty( + 'code', + PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND, + ); + }); + }); + + describe('restore', () => { + it('should restore a deleted page layout tab successfully', async () => { + const id = 'page-layout-tab-id'; + const workspaceId = 'workspace-id'; + const deletedTab = { ...mockPageLayoutTab, deletedAt: new Date() }; + + jest + .spyOn(pageLayoutTabRepository, 'findOne') + .mockResolvedValue(deletedTab); + jest + .spyOn(pageLayoutTabRepository, 'restore') + .mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} }); + jest + .spyOn(pageLayoutTabService, 'findByIdOrThrow') + .mockResolvedValue(mockPageLayoutTab); + + const result = await pageLayoutTabService.restore(id, workspaceId); + + expect(pageLayoutTabRepository.findOne).toHaveBeenCalledWith({ + select: { + id: true, + deletedAt: true, + }, + where: { + id, + workspaceId, + }, + withDeleted: true, + }); + expect(pageLayoutTabRepository.restore).toHaveBeenCalledWith(id); + expect(result).toEqual(mockPageLayoutTab); + }); + + it('should throw an exception when tab to restore is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + + jest.spyOn(pageLayoutTabRepository, 'findOne').mockResolvedValue(null); + + await expect( + pageLayoutTabService.restore(id, workspaceId), + ).rejects.toThrow(PageLayoutTabException); + await expect( + pageLayoutTabService.restore(id, workspaceId), + ).rejects.toHaveProperty( + 'code', + PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND, + ); + }); + + it('should throw an exception when tab is not deleted', async () => { + const id = 'page-layout-tab-id'; + const workspaceId = 'workspace-id'; + const notDeletedTab = { ...mockPageLayoutTab, deletedAt: null }; + + jest + .spyOn(pageLayoutTabRepository, 'findOne') + .mockResolvedValue(notDeletedTab); + + await expect( + pageLayoutTabService.restore(id, workspaceId), + ).rejects.toThrow(PageLayoutTabException); + await expect( + pageLayoutTabService.restore(id, workspaceId), + ).rejects.toHaveProperty( + 'code', + PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA, + ); + }); + }); }); 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 index d3e4aaff79..394d5ac5d8 100644 --- 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 @@ -42,6 +42,7 @@ describe('PageLayoutService', () => { findOne: jest.fn(), create: jest.fn(), save: jest.fn(), + update: jest.fn(), softDelete: jest.fn(), delete: jest.fn(), restore: jest.fn(), @@ -191,11 +192,9 @@ describe('PageLayoutService', () => { const updateData = { name: 'Updated Page Layout' }; const updatedPageLayout = { ...mockPageLayout, ...updateData }; + jest.spyOn(pageLayoutRepository, 'update').mockResolvedValue({} as any); jest .spyOn(pageLayoutService, 'findByIdOrThrow') - .mockResolvedValue(mockPageLayout); - jest - .spyOn(pageLayoutRepository, 'save') .mockResolvedValue(updatedPageLayout); const result = await pageLayoutService.update( @@ -204,14 +203,14 @@ describe('PageLayoutService', () => { updateData, ); + expect(pageLayoutRepository.update).toHaveBeenCalledWith( + { id, workspaceId }, + updateData, + ); expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith( id, workspaceId, ); - expect(pageLayoutRepository.save).toHaveBeenCalledWith({ - ...mockPageLayout, - ...updateData, - }); expect(result).toEqual(updatedPageLayout); }); @@ -364,6 +363,10 @@ describe('PageLayoutService', () => { const result = await pageLayoutService.restore(id, workspaceId); expect(pageLayoutRepository.findOne).toHaveBeenCalledWith({ + select: { + id: true, + deletedAt: true, + }, where: { id, workspaceId, 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 index b2c6b52e43..c27d4933f7 100644 --- 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 @@ -4,6 +4,10 @@ import { NotFoundError, UserInputError, } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + PageLayoutTabException, + PageLayoutTabExceptionCode, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception'; import { PageLayoutException, PageLayoutExceptionCode, @@ -24,5 +28,19 @@ export const pageLayoutGraphqlApiExceptionHandler = (error: Error) => { } } + if (error instanceof PageLayoutTabException) { + switch (error.code) { + case PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND: + throw new NotFoundError(error.message); + case PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_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 index e3e3c50428..9246ae503b 100644 --- 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 @@ -1,12 +1,16 @@ import { ArgumentsHost, Catch } from '@nestjs/common'; import { GqlExceptionFilter } from '@nestjs/graphql'; +import { PageLayoutTabException } from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception'; 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) +@Catch(PageLayoutException, PageLayoutTabException) export class PageLayoutGraphqlApiExceptionFilter implements GqlExceptionFilter { - catch(exception: PageLayoutException, _host: ArgumentsHost) { + catch( + exception: PageLayoutException | PageLayoutTabException, + _host: ArgumentsHost, + ) { return pageLayoutGraphqlApiExceptionHandler(exception); } } 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 index 47b4963a01..c51445808b 100644 --- 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 @@ -11,6 +11,7 @@ export const PAGE_LAYOUT_GQL_FIELDS = ` export const PAGE_LAYOUT_TAB_GQL_FIELDS = ` id + workspaceId title position pageLayoutId diff --git a/packages/twenty-server/test/integration/constants/test-page-layout-tab-ids.constants.ts b/packages/twenty-server/test/integration/constants/test-page-layout-tab-ids.constants.ts new file mode 100644 index 0000000000..e0b3d089f3 --- /dev/null +++ b/packages/twenty-server/test/integration/constants/test-page-layout-tab-ids.constants.ts @@ -0,0 +1,5 @@ +export const TEST_PAGE_LAYOUT_TAB_1_ID = '20202020-6837-414c-a15f-ed2cd033da81'; +export const TEST_PAGE_LAYOUT_TAB_2_ID = '20202020-e6fa-47af-95f0-07cce1901493'; + +export const TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID = + '20202020-f22e-417f-ae78-7d74ea2652fe'; diff --git a/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-tab-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-tab-resolver.integration-spec.ts new file mode 100644 index 0000000000..7fef7bb9f2 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-tab-resolver.integration-spec.ts @@ -0,0 +1,406 @@ +import { TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID } from 'test/integration/constants/test-page-layout-tab-ids.constants'; +import { createPageLayoutTabOperationFactory } from 'test/integration/graphql/utils/create-page-layout-tab-operation-factory.util'; +import { deletePageLayoutTabOperationFactory } from 'test/integration/graphql/utils/delete-page-layout-tab-operation-factory.util'; +import { destroyPageLayoutTabOperationFactory } from 'test/integration/graphql/utils/destroy-page-layout-tab-operation-factory.util'; +import { findPageLayoutTabOperationFactory } from 'test/integration/graphql/utils/find-page-layout-tab-operation-factory.util'; +import { findPageLayoutTabsOperationFactory } from 'test/integration/graphql/utils/find-page-layout-tabs-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 { + cleanupPageLayoutTabRecordsWithGraphQL, + createTestPageLayoutTabWithGraphQL, +} from 'test/integration/graphql/utils/page-layout-tab-graphql.util'; +import { restorePageLayoutTabOperationFactory } from 'test/integration/graphql/utils/restore-page-layout-tab-operation-factory.util'; +import { updatePageLayoutTabOperationFactory } from 'test/integration/graphql/utils/update-page-layout-tab-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 { assertPageLayoutTabStructure } from 'test/integration/utils/page-layout-tab-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 { + PageLayoutTabExceptionMessageKey, + generatePageLayoutTabExceptionMessage, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception'; + +describe('Page Layout Tab Resolver', () => { + let testObjectMetadataId: string; + let testPageLayoutId: string; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'myTestPageLayoutTabObject', + namePlural: 'myTestPageLayoutTabObjects', + labelSingular: 'My Test Page Layout Tab Object', + labelPlural: 'My Test Page Layout Tab Objects', + icon: 'IconTab', + }, + }); + + testObjectMetadataId = objectMetadataId; + + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: 'Test Page Layout for Tabs', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: testObjectMetadataId, + }); + + testPageLayoutId = pageLayout.id; + }); + + afterAll(async () => { + await cleanupPageLayoutRecordsWithGraphQL(); + await deleteOneObjectMetadata({ + input: { idToDelete: testObjectMetadataId }, + }); + }); + + afterEach(async () => { + await cleanupPageLayoutTabRecordsWithGraphQL(testPageLayoutId); + }); + + describe('getPageLayoutTabs', () => { + it('should return empty array when no page layout tabs exist', async () => { + const operation = findPageLayoutTabsOperationFactory({ + pageLayoutId: testPageLayoutId, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getPageLayoutTabs).toEqual([]); + }); + + it('should return all page layout tabs for a specific page layout', async () => { + const input1 = { + title: 'Tab 1', + position: 0, + pageLayoutId: testPageLayoutId, + }; + const input2 = { + title: 'Tab 2', + position: 1, + pageLayoutId: testPageLayoutId, + }; + + await Promise.all([ + createTestPageLayoutTabWithGraphQL(input1), + createTestPageLayoutTabWithGraphQL(input2), + ]); + + const operation = findPageLayoutTabsOperationFactory({ + pageLayoutId: testPageLayoutId, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + expect(response.body.data.getPageLayoutTabs).toHaveLength(2); + + const tabs = response.body.data.getPageLayoutTabs.sort( + (a: { position: number }, b: { position: number }) => + a.position - b.position, + ); + + assertPageLayoutTabStructure(tabs[0], input1); + assertPageLayoutTabStructure(tabs[1], input2); + }); + }); + + describe('getPageLayoutTab', () => { + it('should throw when page layout tab does not exist', async () => { + const operation = findPageLayoutTabOperationFactory({ + pageLayoutTabId: TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + ), + ); + }); + + it('should return page layout tab when it exists', async () => { + const tabTitle = 'Tab'; + + const input = { + title: tabTitle, + position: 2, + pageLayoutId: testPageLayoutId, + }; + + const tab = await createTestPageLayoutTabWithGraphQL(input); + + const operation = findPageLayoutTabOperationFactory({ + pageLayoutTabId: tab.id, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertPageLayoutTabStructure(response.body.data.getPageLayoutTab, input); + }); + }); + + describe('createPageLayoutTab', () => { + it('should create a new page layout tab with all properties', async () => { + const input = { + title: 'New Tab', + position: 5, + pageLayoutId: testPageLayoutId, + }; + + const operation = createPageLayoutTabOperationFactory({ data: input }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + + const createdTab = response.body.data.createPageLayoutTab; + + assertPageLayoutTabStructure(createdTab, input); + }); + + it('should create a page layout tab with minimum required fields', async () => { + const input = { + title: 'Minimal Tab', + pageLayoutId: testPageLayoutId, + }; + + const operation = createPageLayoutTabOperationFactory({ data: input }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + + const createdTab = response.body.data.createPageLayoutTab; + + assertPageLayoutTabStructure(createdTab, { + title: input.title, + position: 0, + pageLayoutId: input.pageLayoutId, + deletedAt: null, + }); + }); + }); + + describe('updatePageLayoutTab', () => { + it('should update an existing page layout tab', async () => { + const input = { + title: 'Original Tab', + position: 1, + pageLayoutId: testPageLayoutId, + }; + + const tab = await createTestPageLayoutTabWithGraphQL(input); + + const updateInput = { + title: 'Updated Tab', + position: 3, + }; + + const operation = updatePageLayoutTabOperationFactory({ + pageLayoutTabId: tab.id, + data: updateInput, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertPageLayoutTabStructure( + response.body.data.updatePageLayoutTab, + updateInput, + ); + }); + + it('should update only provided fields', async () => { + const input = { + title: 'Original Tab', + position: 1, + pageLayoutId: testPageLayoutId, + }; + + const tab = await createTestPageLayoutTabWithGraphQL(input); + + const updateInput = { + title: 'Updated Title Only', + }; + + const operation = updatePageLayoutTabOperationFactory({ + pageLayoutTabId: tab.id, + data: updateInput, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLSuccessfulResponse(response); + assertPageLayoutTabStructure(response.body.data.updatePageLayoutTab, { + ...input, + ...updateInput, + }); + }); + + it('should throw error when updating non-existent page layout tab', async () => { + const operation = updatePageLayoutTabOperationFactory({ + pageLayoutTabId: TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + data: { title: 'Non-existent Tab' }, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + ), + ); + }); + }); + + describe('deletePageLayoutTab', () => { + it('should delete an existing page layout tab (soft delete)', async () => { + const tab = await createTestPageLayoutTabWithGraphQL({ + title: 'Tab to Delete', + pageLayoutId: testPageLayoutId, + }); + + const deleteOperation = deletePageLayoutTabOperationFactory({ + pageLayoutTabId: tab.id, + }); + const deleteResponse = await makeGraphqlAPIRequest(deleteOperation); + + assertGraphQLSuccessfulResponse(deleteResponse); + expect(deleteResponse.body.data.deletePageLayoutTab).toBe(true); + + const getOperation = findPageLayoutTabOperationFactory({ + pageLayoutTabId: tab.id, + }); + const getResponse = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLErrorResponse( + getResponse, + ErrorCode.NOT_FOUND, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + tab.id, + ), + ); + }); + + it('should throw an error when deleting non-existent page layout tab', async () => { + const operation = deletePageLayoutTabOperationFactory({ + pageLayoutTabId: TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + ), + ); + }); + }); + + describe('destroyPageLayoutTab', () => { + it('should destroy an existing page layout tab (hard delete)', async () => { + const tab = await createTestPageLayoutTabWithGraphQL({ + title: 'Tab to Destroy', + pageLayoutId: testPageLayoutId, + }); + + const destroyOperation = destroyPageLayoutTabOperationFactory({ + pageLayoutTabId: tab.id, + }); + const destroyResponse = await makeGraphqlAPIRequest(destroyOperation); + + assertGraphQLSuccessfulResponse(destroyResponse); + expect(destroyResponse.body.data.destroyPageLayoutTab).toBe(true); + }); + + it('should throw an error when destroying non-existent page layout tab', async () => { + const operation = destroyPageLayoutTabOperationFactory({ + pageLayoutTabId: TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + ), + ); + }); + }); + + describe('restorePageLayoutTab', () => { + it('should restore a soft deleted page layout tab', async () => { + const input = { + title: 'Tab to Restore', + position: 2, + pageLayoutId: testPageLayoutId, + }; + + const tab = await createTestPageLayoutTabWithGraphQL(input); + + const deleteOperation = deletePageLayoutTabOperationFactory({ + pageLayoutTabId: tab.id, + }); + const deleteResponse = await makeGraphqlAPIRequest(deleteOperation); + + assertGraphQLSuccessfulResponse(deleteResponse); + + const restoreOperation = restorePageLayoutTabOperationFactory({ + pageLayoutTabId: tab.id, + }); + const restoreResponse = await makeGraphqlAPIRequest(restoreOperation); + + assertGraphQLSuccessfulResponse(restoreResponse); + assertPageLayoutTabStructure( + restoreResponse.body.data.restorePageLayoutTab, + input, + ); + + const getOperation = findPageLayoutTabOperationFactory({ + pageLayoutTabId: tab.id, + }); + const getResponse = await makeGraphqlAPIRequest(getOperation); + + assertGraphQLSuccessfulResponse(getResponse); + assertPageLayoutTabStructure( + getResponse.body.data.getPageLayoutTab, + input, + ); + }); + + it('should throw an error when restoring non-existent page layout tab', async () => { + const operation = restorePageLayoutTabOperationFactory({ + pageLayoutTabId: TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + }); + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse( + response, + ErrorCode.NOT_FOUND, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + ), + ); + }); + }); +}); 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 index d0c5b48565..238e144632 100644 --- 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 @@ -5,13 +5,13 @@ import { type CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/ type CreatePageLayoutOperationFactoryParams = { gqlFields?: string; - data?: CreatePageLayoutInput; + data: CreatePageLayoutInput; }; export const createPageLayoutOperationFactory = ({ gqlFields = PAGE_LAYOUT_GQL_FIELDS, - data = {} as CreatePageLayoutInput, -}: CreatePageLayoutOperationFactoryParams = {}) => ({ + data, +}: CreatePageLayoutOperationFactoryParams) => ({ query: gql` mutation CreatePageLayout($input: CreatePageLayoutInput!) { createPageLayout(input: $input) { diff --git a/packages/twenty-server/test/integration/graphql/utils/create-page-layout-tab-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-page-layout-tab-operation-factory.util.ts new file mode 100644 index 0000000000..eb004eda2f --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/create-page-layout-tab-operation-factory.util.ts @@ -0,0 +1,25 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_TAB_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +import { type CreatePageLayoutTabInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-tab.input'; + +type CreatePageLayoutTabOperationFactoryParams = { + gqlFields?: string; + data: CreatePageLayoutTabInput; +}; + +export const createPageLayoutTabOperationFactory = ({ + gqlFields = PAGE_LAYOUT_TAB_GQL_FIELDS, + data, +}: CreatePageLayoutTabOperationFactoryParams) => ({ + query: gql` + mutation CreatePageLayoutTab($input: CreatePageLayoutTabInput!) { + createPageLayoutTab(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/delete-page-layout-tab-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/delete-page-layout-tab-operation-factory.util.ts new file mode 100644 index 0000000000..30917d0466 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/delete-page-layout-tab-operation-factory.util.ts @@ -0,0 +1,18 @@ +import gql from 'graphql-tag'; + +type DeletePageLayoutTabOperationFactoryParams = { + pageLayoutTabId: string; +}; + +export const deletePageLayoutTabOperationFactory = ({ + pageLayoutTabId, +}: DeletePageLayoutTabOperationFactoryParams) => ({ + query: gql` + mutation DeletePageLayoutTab($id: String!) { + deletePageLayoutTab(id: $id) + } + `, + variables: { + id: pageLayoutTabId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/destroy-page-layout-tab-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/destroy-page-layout-tab-operation-factory.util.ts new file mode 100644 index 0000000000..f06f60b6de --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/destroy-page-layout-tab-operation-factory.util.ts @@ -0,0 +1,18 @@ +import gql from 'graphql-tag'; + +type DestroyPageLayoutTabOperationFactoryParams = { + pageLayoutTabId: string; +}; + +export const destroyPageLayoutTabOperationFactory = ({ + pageLayoutTabId, +}: DestroyPageLayoutTabOperationFactoryParams) => ({ + query: gql` + mutation DestroyPageLayoutTab($id: String!) { + destroyPageLayoutTab(id: $id) + } + `, + variables: { + id: pageLayoutTabId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-page-layout-tab-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-page-layout-tab-operation-factory.util.ts new file mode 100644 index 0000000000..991f5023b4 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-page-layout-tab-operation-factory.util.ts @@ -0,0 +1,23 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_TAB_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +type FindPageLayoutTabOperationFactoryParams = { + gqlFields?: string; + pageLayoutTabId: string; +}; + +export const findPageLayoutTabOperationFactory = ({ + gqlFields = PAGE_LAYOUT_TAB_GQL_FIELDS, + pageLayoutTabId, +}: FindPageLayoutTabOperationFactoryParams) => ({ + query: gql` + query GetPageLayoutTab($id: String!) { + getPageLayoutTab(id: $id) { + ${gqlFields} + } + } + `, + variables: { + id: pageLayoutTabId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/find-page-layout-tabs-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/find-page-layout-tabs-operation-factory.util.ts new file mode 100644 index 0000000000..03d045392d --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/find-page-layout-tabs-operation-factory.util.ts @@ -0,0 +1,23 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_TAB_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +type FindPageLayoutTabsOperationFactoryParams = { + gqlFields?: string; + pageLayoutId: string; +}; + +export const findPageLayoutTabsOperationFactory = ({ + gqlFields = PAGE_LAYOUT_TAB_GQL_FIELDS, + pageLayoutId, +}: FindPageLayoutTabsOperationFactoryParams) => ({ + query: gql` + query GetPageLayoutTabs($pageLayoutId: String!) { + getPageLayoutTabs(pageLayoutId: $pageLayoutId) { + ${gqlFields} + } + } + `, + variables: { + pageLayoutId, + }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/page-layout-tab-graphql.util.ts b/packages/twenty-server/test/integration/graphql/utils/page-layout-tab-graphql.util.ts new file mode 100644 index 0000000000..1366744641 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/page-layout-tab-graphql.util.ts @@ -0,0 +1,59 @@ +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 PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity'; + +import { createPageLayoutTabOperationFactory } from './create-page-layout-tab-operation-factory.util'; +import { destroyPageLayoutTabOperationFactory } from './destroy-page-layout-tab-operation-factory.util'; +import { findPageLayoutTabsOperationFactory } from './find-page-layout-tabs-operation-factory.util'; + +interface CreatePageLayoutTabResponse extends Record { + createPageLayoutTab: PageLayoutTabEntity; +} + +export const createTestPageLayoutTabWithGraphQL = async (data: { + title: string; + position?: number; + pageLayoutId: string; +}): Promise => { + const operation = createPageLayoutTabOperationFactory({ + data: { + title: data.title, + position: data.position || 0, + pageLayoutId: data.pageLayoutId, + }, + }); + + const response = (await makeGraphqlAPIRequest( + operation, + )) as GraphQLResponse; + + if (response.body.errors) { + throw new Error( + `Failed to create test page layout tab: ${JSON.stringify(response.body.errors)}`, + ); + } + + if (!response.body.data) { + throw new Error('No data returned from createTestPageLayoutTabWithGraphQL'); + } + + return response.body.data.createPageLayoutTab; +}; + +export const cleanupPageLayoutTabRecordsWithGraphQL = async ( + pageLayoutId: string, +): Promise => { + const operation = findPageLayoutTabsOperationFactory({ pageLayoutId }); + const response = await makeGraphqlAPIRequest(operation); + + if (response.body.data?.getPageLayoutTabs) { + for (const pageLayoutTab of response.body.data.getPageLayoutTabs) { + const destroyOperation = destroyPageLayoutTabOperationFactory({ + pageLayoutTabId: pageLayoutTab.id, + }); + + await makeGraphqlAPIRequest(destroyOperation); + } + } +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/restore-page-layout-tab-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/restore-page-layout-tab-operation-factory.util.ts new file mode 100644 index 0000000000..f547ad2952 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/restore-page-layout-tab-operation-factory.util.ts @@ -0,0 +1,23 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_TAB_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +type RestorePageLayoutTabOperationFactoryParams = { + gqlFields?: string; + pageLayoutTabId: string; +}; + +export const restorePageLayoutTabOperationFactory = ({ + gqlFields = PAGE_LAYOUT_TAB_GQL_FIELDS, + pageLayoutTabId, +}: RestorePageLayoutTabOperationFactoryParams) => ({ + query: gql` + mutation RestorePageLayoutTab($id: String!) { + restorePageLayoutTab(id: $id) { + ${gqlFields} + } + } + `, + variables: { + id: pageLayoutTabId, + }, +}); 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 index 7525fbb8d2..ae3812b652 100644 --- 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 @@ -6,13 +6,13 @@ import { type UpdatePageLayoutInput } from 'src/engine/core-modules/page-layout/ type UpdatePageLayoutOperationFactoryParams = { gqlFields?: string; pageLayoutId: string; - data?: UpdatePageLayoutInput; + data: UpdatePageLayoutInput; }; export const updatePageLayoutOperationFactory = ({ gqlFields = PAGE_LAYOUT_GQL_FIELDS, pageLayoutId, - data = {} as UpdatePageLayoutInput, + data, }: UpdatePageLayoutOperationFactoryParams) => ({ query: gql` mutation UpdatePageLayout($id: String!, $input: UpdatePageLayoutInput!) { diff --git a/packages/twenty-server/test/integration/graphql/utils/update-page-layout-tab-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/update-page-layout-tab-operation-factory.util.ts new file mode 100644 index 0000000000..7d6b3ffd5f --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/update-page-layout-tab-operation-factory.util.ts @@ -0,0 +1,28 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_TAB_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +import { type UpdatePageLayoutTabInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab.input'; + +type UpdatePageLayoutTabOperationFactoryParams = { + gqlFields?: string; + pageLayoutTabId: string; + data: UpdatePageLayoutTabInput; +}; + +export const updatePageLayoutTabOperationFactory = ({ + gqlFields = PAGE_LAYOUT_TAB_GQL_FIELDS, + pageLayoutTabId, + data, +}: UpdatePageLayoutTabOperationFactoryParams) => ({ + query: gql` + mutation UpdatePageLayoutTab($id: String!, $input: UpdatePageLayoutTabInput!) { + updatePageLayoutTab(id: $id, input: $input) { + ${gqlFields} + } + } + `, + variables: { + id: pageLayoutTabId, + input: data, + }, +}); diff --git a/packages/twenty-server/test/integration/rest/suites/page-layout-tab.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/page-layout-tab.integration-spec.ts new file mode 100644 index 0000000000..4e689dee32 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/page-layout-tab.integration-spec.ts @@ -0,0 +1,383 @@ +import { TEST_NOT_EXISTING_PAGE_LAYOUT_ID } from 'test/integration/constants/test-page-layout-ids.constants'; +import { TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID } from 'test/integration/constants/test-page-layout-tab-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 { + createTestPageLayoutTabWithRestApi, + deleteTestPageLayoutTabWithRestApi, +} from 'test/integration/rest/utils/page-layout-tab-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 { + assertPageLayoutTabStructure, + cleanupPageLayoutTabRecords, +} from 'test/integration/utils/page-layout-tab-test.util'; + +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; +import { + PageLayoutTabExceptionMessageKey, + generatePageLayoutTabExceptionMessage, +} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception'; + +describe('Page Layout Tab REST API', () => { + let testObjectMetadataId: string; + let testPageLayoutId: string; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'myTestPageLayoutTabObject', + namePlural: 'myTestPageLayoutTabObjects', + labelSingular: 'My Test Page Layout Tab Object', + labelPlural: 'My Test Page Layout Tab Objects', + icon: 'IconLayout', + }, + }); + + testObjectMetadataId = objectMetadataId; + + const testPageLayout = await createTestPageLayoutWithRestApi({ + name: generateRecordName('Test Page Layout for Tabs'), + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: testObjectMetadataId, + }); + + testPageLayoutId = testPageLayout.id; + }); + + afterAll(async () => { + await deleteTestPageLayoutWithRestApi(testPageLayoutId); + await deleteOneObjectMetadata({ + input: { idToDelete: testObjectMetadataId }, + }); + }); + + afterEach(async () => { + await cleanupPageLayoutTabRecords(); + }); + + describe('GET /rest/metadata/page-layout-tabs', () => { + it('should return page layout tabs filtered by pageLayoutId', async () => { + const input1 = { + title: 'Tab 1', + pageLayoutId: testPageLayoutId, + position: 0, + }; + const input2 = { + title: 'Tab 2', + pageLayoutId: testPageLayoutId, + position: 1, + }; + + await createTestPageLayoutTabWithRestApi(input1); + await createTestPageLayoutTabWithRestApi(input2); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/page-layout-tabs?pageLayoutId=${testPageLayoutId}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + expect(Array.isArray(response.body)).toBe(true); + + if (response.body.length > 0) { + assertPageLayoutTabStructure(response.body[0], input1); + } + }); + + it('should return empty array when no page layout tabs match pageLayoutId', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/page-layout-tabs?pageLayoutId=${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); + }); + + it('should return error when pageLayoutId is missing', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: '/metadata/page-layout-tabs', + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 400, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_ID_REQUIRED, + ), + ); + }); + }); + + describe('POST /rest/metadata/page-layout-tabs', () => { + it('should create a new page layout tab with all properties', async () => { + const input = { + title: 'Test Tab', + pageLayoutId: testPageLayoutId, + position: 1, + }; + + const pageLayoutTab = await createTestPageLayoutTabWithRestApi(input); + + assertPageLayoutTabStructure(pageLayoutTab, input); + + await deleteTestPageLayoutTabWithRestApi(pageLayoutTab.id); + }); + + it('should create a page layout tab with minimum required fields', async () => { + const input = { + title: 'Minimal Tab', + pageLayoutId: testPageLayoutId, + }; + + const pageLayoutTab = await createTestPageLayoutTabWithRestApi({ + title: input.title, + pageLayoutId: input.pageLayoutId, + }); + + assertPageLayoutTabStructure(pageLayoutTab, { + ...input, + position: 0, + }); + + await deleteTestPageLayoutTabWithRestApi(pageLayoutTab.id); + }); + + it('should return error when creating tab with invalid pageLayoutId', async () => { + const pageLayoutTabData = { + title: 'Invalid Tab', + pageLayoutId: TEST_NOT_EXISTING_PAGE_LAYOUT_ID, + position: 0, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/page-layout-tabs', + body: pageLayoutTabData, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 400, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND, + ), + ); + }); + + it('should return error when creating tab without title', async () => { + const pageLayoutTabData = { + pageLayoutId: testPageLayoutId, + position: 0, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/page-layout-tabs', + body: pageLayoutTabData, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse(response, 400); + }); + }); + + describe('GET /rest/metadata/page-layout-tabs/:id', () => { + it('should return a page layout tab by id', async () => { + const input = { + title: 'Tab', + pageLayoutId: testPageLayoutId, + position: 2, + }; + const pageLayoutTab = await createTestPageLayoutTabWithRestApi(input); + + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/page-layout-tabs/${pageLayoutTab.id}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertPageLayoutTabStructure(response.body, { + id: pageLayoutTab.id, + ...input, + }); + + await deleteTestPageLayoutTabWithRestApi(pageLayoutTab.id); + }); + + it('should return error for non-existent page layout tab', async () => { + const response = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/page-layout-tabs/${TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + ), + ); + }); + }); + + describe('PATCH /rest/metadata/page-layout-tabs/:id', () => { + it('should update an existing page layout tab', async () => { + const input = { + title: 'Test Tab for Update', + pageLayoutId: testPageLayoutId, + position: 0, + }; + const pageLayoutTab = await createTestPageLayoutTabWithRestApi(input); + + const updateData = { + title: 'Updated Tab', + position: 3, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/page-layout-tabs/${pageLayoutTab.id}`, + body: updateData, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertPageLayoutTabStructure(response.body, { + id: pageLayoutTab.id, + ...input, + ...updateData, + }); + + await deleteTestPageLayoutTabWithRestApi(pageLayoutTab.id); + }); + + it('should update only provided fields', async () => { + const input = { + title: 'Original Tab', + pageLayoutId: testPageLayoutId, + position: 1, + }; + const pageLayoutTab = await createTestPageLayoutTabWithRestApi(input); + + const updatedTitle = 'Updated Title Only'; + const updateData = { + title: updatedTitle, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/page-layout-tabs/${pageLayoutTab.id}`, + body: updateData, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(response); + assertPageLayoutTabStructure(response.body, { + id: pageLayoutTab.id, + title: updatedTitle, + pageLayoutId: testPageLayoutId, + position: 1, + }); + + await deleteTestPageLayoutTabWithRestApi(pageLayoutTab.id); + }); + + it('should return error when updating non-existent page layout tab', async () => { + const updateData = { + title: 'Updated Tab', + position: 5, + }; + + const response = await makeRestAPIRequest({ + method: 'patch', + path: `/metadata/page-layout-tabs/${TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID}`, + body: updateData, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + ), + ); + }); + }); + + describe('DELETE /rest/metadata/page-layout-tabs/:id', () => { + it('should delete an existing page layout tab', async () => { + const pageLayoutTabTitle = generateRecordName('Test Tab for Delete'); + const pageLayoutTab = await createTestPageLayoutTabWithRestApi({ + title: pageLayoutTabTitle, + pageLayoutId: testPageLayoutId, + position: 0, + }); + + const deleteResponse = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/page-layout-tabs/${pageLayoutTab.id}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiSuccessfulResponse(deleteResponse); + assertPageLayoutTabStructure(deleteResponse.body, pageLayoutTab); + + const getResponse = await makeRestAPIRequest({ + method: 'get', + path: `/metadata/page-layout-tabs/${pageLayoutTab.id}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + getResponse, + 404, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + pageLayoutTab.id, + ), + ); + }); + + it('should return error when deleting non-existent page layout tab', async () => { + const response = await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/page-layout-tabs/${TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID}`, + bearer: API_KEY_ACCESS_TOKEN, + }); + + assertRestApiErrorResponse( + response, + 404, + generatePageLayoutTabExceptionMessage( + PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND, + TEST_NOT_EXISTING_PAGE_LAYOUT_TAB_ID, + ), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/rest/utils/page-layout-tab-rest-api.util.ts b/packages/twenty-server/test/integration/rest/utils/page-layout-tab-rest-api.util.ts new file mode 100644 index 0000000000..7c2940661a --- /dev/null +++ b/packages/twenty-server/test/integration/rest/utils/page-layout-tab-rest-api.util.ts @@ -0,0 +1,39 @@ +import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; + +import { type PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity'; + +export const createTestPageLayoutTabWithRestApi = async ( + overrides: Partial = {}, +): Promise => { + const pageLayoutTabData = { + title: generateRecordName('Test Page Layout Tab'), + position: 0, + ...overrides, + }; + + const response = await makeRestAPIRequest({ + method: 'post', + path: '/metadata/page-layout-tabs', + body: pageLayoutTabData, + bearer: API_KEY_ACCESS_TOKEN, + }); + + if (response.status !== 201) { + throw new Error( + `Failed to create test page layout tab: ${response.status} - ${JSON.stringify(response.body)}`, + ); + } + + return response.body; +}; + +export const deleteTestPageLayoutTabWithRestApi = async ( + pageLayoutTabId: string, +): Promise => { + await makeRestAPIRequest({ + method: 'delete', + path: `/metadata/page-layout-tabs/${pageLayoutTabId}`, + bearer: API_KEY_ACCESS_TOKEN, + }); +}; diff --git a/packages/twenty-server/test/integration/utils/page-layout-tab-test.util.ts b/packages/twenty-server/test/integration/utils/page-layout-tab-test.util.ts new file mode 100644 index 0000000000..37422f7012 --- /dev/null +++ b/packages/twenty-server/test/integration/utils/page-layout-tab-test.util.ts @@ -0,0 +1,23 @@ +import { type PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity'; + +export const cleanupPageLayoutTabRecords = async (): Promise => { + // @ts-expect-error legacy noImplicitAny + await global.testDataSource.query(`DELETE from "core"."pageLayoutTab"`); +}; + +export const assertPageLayoutTabStructure = ( + pageLayoutTab: PageLayoutTabEntity, + expectedFields?: Partial, +) => { + expect(pageLayoutTab).toBeDefined(); + expect(pageLayoutTab.id).toEqual(expect.any(String)); + expect(pageLayoutTab.title).toEqual(expect.any(String)); + expect(pageLayoutTab.position).toEqual(expect.any(Number)); + expect(pageLayoutTab.pageLayoutId).toEqual(expect.any(String)); + expect(pageLayoutTab.createdAt).toEqual(expect.any(String)); + expect(pageLayoutTab.updatedAt).toEqual(expect.any(String)); + + if (expectedFields) { + expect(pageLayoutTab).toMatchObject(expectedFields); + } +};