Create PageLayoutTab resolver and controller (#14284)

Closes https://github.com/twentyhq/core-team-issues/issues/1394
This commit is contained in:
Raphaël Bosi
2025-09-04 18:07:33 +02:00
committed by GitHub
parent 0f806126ac
commit 9746c3a787
38 changed files with 2241 additions and 32 deletions
@@ -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<PageLayoutTabDTO[]> {
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<PageLayoutTabDTO | null> {
return this.pageLayoutTabService.findByIdOrThrow(id, workspace.id);
}
@Post()
async create(
@Body() input: CreatePageLayoutTabInput,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.create(input, workspace.id);
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() input: UpdatePageLayoutTabInput,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.update(id, workspace.id, input);
}
@Delete(':id')
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.delete(id, workspace.id);
}
}
@@ -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<PageLayoutDTO[]> {
if (objectMetadataId) {
if (isDefined(objectMetadataId)) {
return this.pageLayoutService.findByObjectMetadataId(
workspace.id,
objectMetadataId,
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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<PageLayoutTabEntity> {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -24,7 +29,16 @@ export class PageLayoutTabEntity implements Required<PageLayoutTabEntity> {
@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<Workspace>;
@Column({ nullable: false, type: 'float', default: 0 })
position: number;
@Column({ nullable: false, type: 'uuid' })
@@ -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<PageLayoutWidgetEntity>
{
@@ -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<Workspace>;
@ManyToOne(() => PageLayoutTabEntity, {
onDelete: 'CASCADE',
})
@@ -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<PageLayoutTabExceptionCode> {}
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);
}
};
@@ -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,
);
@@ -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<Response>();
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,
);
}
}
}
@@ -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 {}
@@ -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<PageLayoutTabDTO[]> {
return this.pageLayoutTabService.findByPageLayoutId(
workspace.id,
pageLayoutId,
);
}
@Query(() => PageLayoutTabDTO)
async getPageLayoutTab(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.findByIdOrThrow(id, workspace.id);
}
@Mutation(() => PageLayoutTabDTO)
async createPageLayoutTab(
@Args('input') input: CreatePageLayoutTabInput,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutTabDTO> {
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<PageLayoutTabDTO> {
return this.pageLayoutTabService.update(id, workspace.id, input);
}
@Mutation(() => Boolean)
async deletePageLayoutTab(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
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<boolean> {
return this.pageLayoutTabService.destroy(id, workspace.id);
}
@Mutation(() => PageLayoutTabDTO)
async restorePageLayoutTab(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.restore(id, workspace.id);
}
}
@@ -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<PageLayoutTabEntity>,
private readonly pageLayoutService: PageLayoutService,
) {}
async findByPageLayoutId(
@@ -26,4 +40,179 @@ export class PageLayoutTabService {
relations: ['widgets'],
});
}
async findByIdOrThrow(
id: string,
workspaceId: string,
): Promise<PageLayoutTabEntity> {
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<PageLayoutTabEntity>,
workspaceId: string,
): Promise<PageLayoutTabEntity> {
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<PageLayoutTabEntity>,
): Promise<PageLayoutTabEntity> {
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<PageLayoutTabEntity> {
const pageLayoutTab = await this.findByIdOrThrow(id, workspaceId);
await this.pageLayoutTabRepository.softDelete(id);
return pageLayoutTab;
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
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<PageLayoutTabEntity> {
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;
}
}
@@ -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<PageLayoutEntity>,
updateData: QueryDeepPartialEntity<PageLayoutEntity>,
): Promise<PageLayoutEntity> {
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<PageLayoutEntity> {
const pageLayout = await this.pageLayoutRepository.findOne({
select: {
id: true,
deletedAt: true,
},
where: {
id,
workspaceId,
@@ -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<PageLayoutTabEntity>;
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<Repository<PageLayoutTabEntity>>(
getRepositoryToken(PageLayoutTabEntity),
);
pageLayoutService = module.get<PageLayoutService>(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,
);
});
});
});
@@ -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,
@@ -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;
};
@@ -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);
}
}