Create PageLayout resolver and controller (#14219)

Closes https://github.com/twentyhq/core-team-issues/issues/1393
This commit is contained in:
Raphaël Bosi
2025-09-02 16:12:18 +02:00
committed by GitHub
parent 92c27b337a
commit fed09339f6
35 changed files with 2545 additions and 7 deletions
@@ -0,0 +1,88 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input';
import { UpdatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input';
import { type PageLayoutDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout.dto';
import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity';
import { PageLayoutRestApiExceptionFilter } from 'src/engine/core-modules/page-layout/filters/page-layout-rest-api-exception.filter';
import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Controller('rest/metadata/page-layouts')
@UseGuards(WorkspaceAuthGuard)
@UseFilters(PageLayoutRestApiExceptionFilter)
export class PageLayoutController {
constructor(private readonly pageLayoutService: PageLayoutService) {}
@Get()
async findMany(
@AuthWorkspace() workspace: Workspace,
@Query('objectMetadataId') objectMetadataId?: string,
): Promise<PageLayoutDTO[]> {
if (objectMetadataId) {
return this.pageLayoutService.findByObjectMetadataId(
workspace.id,
objectMetadataId,
);
}
return this.pageLayoutService.findByWorkspaceId(workspace.id);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutDTO | null> {
return this.pageLayoutService.findByIdOrThrow(id, workspace.id);
}
@Post()
async create(
@Body() input: CreatePageLayoutInput,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.create(input, workspace.id);
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() input: UpdatePageLayoutInput,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutDTO> {
const updatedPageLayout = await this.pageLayoutService.update(
id,
workspace.id,
input,
);
return updatedPageLayout;
}
@Delete(':id')
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutEntity> {
const deletedPageLayout = await this.pageLayoutService.delete(
id,
workspace.id,
);
return deletedPageLayout;
}
}
@@ -0,0 +1,33 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
@InputType()
export class CreatePageLayoutInput {
@Field({ nullable: false })
@IsString()
@IsNotEmpty()
name: string;
@Field(() => PageLayoutType, {
nullable: true,
defaultValue: PageLayoutType.RECORD_PAGE,
})
@IsEnum(PageLayoutType)
@IsOptional()
type?: PageLayoutType;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
objectMetadataId?: string;
}
@@ -0,0 +1,24 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
@InputType()
export class UpdatePageLayoutInput {
@Field({ nullable: true })
@IsString()
@IsOptional()
name?: string;
@Field(() => PageLayoutType, { nullable: true })
@IsEnum(PageLayoutType)
@IsOptional()
type?: PageLayoutType;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
objectMetadataId?: string;
}
@@ -0,0 +1,29 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('PageLayoutTab')
export class PageLayoutTabDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field({ nullable: false })
title: string;
@Field({ nullable: false, defaultValue: 0 })
position: number;
@Field(() => UUIDScalarType, { nullable: false })
pageLayoutId: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
}
@@ -0,0 +1,42 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PageLayoutTabDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout-tab.dto';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
registerEnumType(PageLayoutType, { name: 'PageLayoutType' });
@ObjectType('PageLayout')
export class PageLayoutDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field({ nullable: false })
name: string;
@Field(() => UUIDScalarType, { nullable: false })
workspaceId: string;
@Field(() => PageLayoutType, {
nullable: false,
defaultValue: PageLayoutType.RECORD_PAGE,
})
type: PageLayoutType;
@Field(() => UUIDScalarType, { nullable: true })
objectMetadataId?: string | null;
@Field(() => [PageLayoutTabDTO], { nullable: true })
tabs?: PageLayoutTabDTO[] | null;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
}
@@ -18,10 +18,11 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Entity({ name: 'pageLayout', schema: 'core' })
@Index('IDX_PAGE_LAYOUT_WORKSPACE_ID_OBJECT_METADATA_ID', [
'workspaceId',
'objectMetadataId',
])
@Index(
'IDX_PAGE_LAYOUT_WORKSPACE_ID_OBJECT_METADATA_ID',
['workspaceId', 'objectMetadataId'],
{ where: '"deletedAt" IS NULL' },
)
export class PageLayoutEntity implements Required<PageLayoutEntity> {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -0,0 +1,29 @@
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum PageLayoutExceptionCode {
PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND',
INVALID_PAGE_LAYOUT_DATA = 'INVALID_PAGE_LAYOUT_DATA',
}
export enum PageLayoutExceptionMessageKey {
PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND',
NAME_REQUIRED = 'NAME_REQUIRED',
}
export class PageLayoutException extends CustomException<PageLayoutExceptionCode> {}
export const generatePageLayoutExceptionMessage = (
key: PageLayoutExceptionMessageKey,
value?: string,
): string => {
switch (key) {
case PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND:
return `Page layout with ID "${value}" not found`;
case PageLayoutExceptionMessageKey.NAME_REQUIRED:
return 'Page layout name is required';
default:
assertUnreachable(key);
}
};
@@ -0,0 +1,48 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
import { type CustomException } from 'src/utils/custom-exception';
@Catch(PageLayoutException)
export class PageLayoutRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: PageLayoutException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
404,
);
case PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
default:
// TODO: change to 500 when we have input validation
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
}
}
}
@@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PageLayoutController } from 'src/engine/core-modules/page-layout/controllers/page-layout.controller';
import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity';
import { PageLayoutWidgetEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-widget.entity';
import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity';
import { PageLayoutResolver } from 'src/engine/core-modules/page-layout/resolvers/page-layout.resolver';
import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service';
@Module({
imports: [
@@ -13,6 +17,8 @@ import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/p
PageLayoutWidgetEntity,
]),
],
exports: [],
controllers: [PageLayoutController],
providers: [PageLayoutService, PageLayoutTabService, PageLayoutResolver],
exports: [PageLayoutService, PageLayoutTabService],
})
export class PageLayoutModule {}
@@ -0,0 +1,95 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { isDefined } from 'twenty-shared/utils';
import { CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input';
import { UpdatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input';
import { PageLayoutDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout.dto';
import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Resolver(() => PageLayoutDTO)
@UseFilters(PageLayoutGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class PageLayoutResolver {
constructor(private readonly pageLayoutService: PageLayoutService) {}
@Query(() => [PageLayoutDTO])
async getPageLayouts(
@AuthWorkspace() workspace: Workspace,
@Args('objectMetadataId', { type: () => String, nullable: true })
objectMetadataId?: string,
): Promise<PageLayoutDTO[]> {
if (objectMetadataId) {
return this.pageLayoutService.findByObjectMetadataId(
workspace.id,
objectMetadataId,
);
}
return this.pageLayoutService.findByWorkspaceId(workspace.id);
}
@Query(() => PageLayoutDTO, { nullable: true })
async getPageLayout(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutDTO | null> {
return this.pageLayoutService.findByIdOrThrow(id, workspace.id);
}
@Mutation(() => PageLayoutDTO)
async createPageLayout(
@Args('input') input: CreatePageLayoutInput,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.create(input, workspace.id);
}
@Mutation(() => PageLayoutDTO)
async updatePageLayout(
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdatePageLayoutInput,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.update(id, workspace.id, input);
}
@Mutation(() => PageLayoutDTO)
async deletePageLayout(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutDTO> {
const deletedPageLayout = await this.pageLayoutService.delete(
id,
workspace.id,
);
return deletedPageLayout;
}
@Mutation(() => Boolean)
async destroyPageLayout(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const deletedPageLayout = await this.pageLayoutService.destroy(
id,
workspace.id,
);
return isDefined(deletedPageLayout);
}
@Mutation(() => PageLayoutDTO)
async restorePageLayout(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.restore(id, workspace.id);
}
}
@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity';
@Injectable()
export class PageLayoutTabService {
constructor(
@InjectRepository(PageLayoutTabEntity)
private readonly pageLayoutTabRepository: Repository<PageLayoutTabEntity>,
) {}
async findByPageLayoutId(
workspaceId: string,
pageLayoutId: string,
): Promise<PageLayoutTabEntity[]> {
return this.pageLayoutTabRepository.find({
where: {
pageLayoutId,
pageLayout: { workspaceId },
deletedAt: IsNull(),
},
order: { position: 'ASC' },
relations: ['widgets'],
});
}
}
@@ -0,0 +1,172 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity';
import {
PageLayoutException,
PageLayoutExceptionCode,
PageLayoutExceptionMessageKey,
generatePageLayoutExceptionMessage,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
@Injectable()
export class PageLayoutService {
constructor(
@InjectRepository(PageLayoutEntity)
private readonly pageLayoutRepository: Repository<PageLayoutEntity>,
) {}
async findByWorkspaceId(workspaceId: string): Promise<PageLayoutEntity[]> {
return this.pageLayoutRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
relations: ['tabs'],
});
}
async findByObjectMetadataId(
workspaceId: string,
objectMetadataId: string,
): Promise<PageLayoutEntity[]> {
return this.pageLayoutRepository.find({
where: {
workspaceId,
objectMetadataId,
deletedAt: IsNull(),
},
relations: ['tabs'],
});
}
async findByIdOrThrow(
id: string,
workspaceId: string,
): Promise<PageLayoutEntity> {
const pageLayout = await this.pageLayoutRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['tabs'],
});
if (!isDefined(pageLayout)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
return pageLayout;
}
async create(
pageLayoutData: Partial<PageLayoutEntity>,
workspaceId: string,
): Promise<PageLayoutEntity> {
if (!isDefined(pageLayoutData.name)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.NAME_REQUIRED,
),
PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA,
);
}
const pageLayout = this.pageLayoutRepository.create({
...pageLayoutData,
workspaceId,
});
return this.pageLayoutRepository.save(pageLayout);
}
async update(
id: string,
workspaceId: string,
updateData: Partial<PageLayoutEntity>,
): Promise<PageLayoutEntity> {
const existingPageLayout = await this.findByIdOrThrow(id, workspaceId);
const updatedPageLayout = await this.pageLayoutRepository.save({
...existingPageLayout,
...updateData,
});
return updatedPageLayout;
}
async delete(id: string, workspaceId: string): Promise<PageLayoutEntity> {
const pageLayout = await this.findByIdOrThrow(id, workspaceId);
await this.pageLayoutRepository.softDelete(id);
return pageLayout;
}
async destroy(id: string, workspaceId: string): Promise<PageLayoutEntity> {
const pageLayout = await this.pageLayoutRepository.findOne({
where: {
id,
workspaceId,
},
withDeleted: true,
});
if (!isDefined(pageLayout)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
await this.pageLayoutRepository.delete(id);
return pageLayout;
}
async restore(id: string, workspaceId: string): Promise<PageLayoutEntity> {
const pageLayout = await this.pageLayoutRepository.findOne({
where: {
id,
workspaceId,
},
withDeleted: true,
});
if (!isDefined(pageLayout)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
if (!isDefined(pageLayout.deletedAt)) {
throw new PageLayoutException(
'Page layout is not deleted and cannot be restored',
PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA,
);
}
await this.pageLayoutRepository.restore(id);
const restoredPageLayout = await this.findByIdOrThrow(id, workspaceId);
return restoredPageLayout;
}
}
@@ -0,0 +1,164 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity';
import { type PageLayoutWidgetEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-widget.entity';
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service';
describe('PageLayoutTabService', () => {
let pageLayoutTabService: PageLayoutTabService;
let pageLayoutTabRepository: Repository<PageLayoutTabEntity>;
const mockPageLayoutTab = {
id: 'page-layout-tab-id',
title: 'Test Tab',
position: 0,
pageLayoutId: 'page-layout-id',
pageLayout: {} as any,
widgets: [],
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as PageLayoutTabEntity;
const mockWidget = {
id: 'widget-1',
title: 'Test Widget',
type: WidgetType.VIEW,
pageLayoutTabId: 'page-layout-tab-id',
objectMetadataId: 'object-metadata-id',
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
configuration: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as PageLayoutWidgetEntity;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
PageLayoutTabService,
{
provide: getRepositoryToken(PageLayoutTabEntity),
useValue: {
find: jest.fn(),
},
},
],
}).compile();
pageLayoutTabService =
module.get<PageLayoutTabService>(PageLayoutTabService);
pageLayoutTabRepository = module.get<Repository<PageLayoutTabEntity>>(
getRepositoryToken(PageLayoutTabEntity),
);
});
it('should be defined', () => {
expect(pageLayoutTabService).toBeDefined();
});
describe('findByPageLayoutId', () => {
it('should return page layout tabs for a page layout id', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
const expectedTabs = [mockPageLayoutTab];
jest
.spyOn(pageLayoutTabRepository, 'find')
.mockResolvedValue(expectedTabs);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(pageLayoutTabRepository.find).toHaveBeenCalledWith({
where: {
pageLayoutId,
pageLayout: { workspaceId },
deletedAt: expect.anything(),
},
order: { position: 'ASC' },
relations: ['widgets'],
});
expect(result).toEqual(expectedTabs);
});
it('should return empty array when no tabs are found', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
jest.spyOn(pageLayoutTabRepository, 'find').mockResolvedValue([]);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(result).toEqual([]);
});
it('should order tabs by position in ascending order', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
const tab1 = { ...mockPageLayoutTab, id: 'tab-1', position: 2 };
const tab2 = { ...mockPageLayoutTab, id: 'tab-2', position: 0 };
const tab3 = { ...mockPageLayoutTab, id: 'tab-3', position: 1 };
const expectedTabs = [tab2, tab3, tab1];
jest
.spyOn(pageLayoutTabRepository, 'find')
.mockResolvedValue(expectedTabs);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(pageLayoutTabRepository.find).toHaveBeenCalledWith({
where: {
pageLayoutId,
pageLayout: { workspaceId },
deletedAt: expect.anything(),
},
order: { position: 'ASC' },
relations: ['widgets'],
});
expect(result).toEqual(expectedTabs);
});
it('should include widgets relation', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
const widget1 = { ...mockWidget, id: 'widget-1', type: WidgetType.VIEW };
const widget2 = {
...mockWidget,
id: 'widget-2',
type: WidgetType.FIELDS,
};
const tabWithWidgets = {
...mockPageLayoutTab,
widgets: [widget1, widget2],
};
jest
.spyOn(pageLayoutTabRepository, 'find')
.mockResolvedValue([tabWithWidgets]);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(result[0].widgets).toHaveLength(2);
expect(result[0].widgets[0].id).toEqual('widget-1');
expect(result[0].widgets[1].id).toEqual('widget-2');
});
});
});
@@ -0,0 +1,398 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { IsNull, type Repository } from 'typeorm';
import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
import {
PageLayoutException,
PageLayoutExceptionCode,
PageLayoutExceptionMessageKey,
generatePageLayoutExceptionMessage,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service';
describe('PageLayoutService', () => {
let pageLayoutService: PageLayoutService;
let pageLayoutRepository: Repository<PageLayoutEntity>;
const mockPageLayout = {
id: 'page-layout-id',
name: 'Test Page Layout',
workspaceId: 'workspace-id',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'object-metadata-id',
tabs: [],
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as unknown as PageLayoutEntity;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
PageLayoutService,
{
provide: getRepositoryToken(PageLayoutEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
},
},
],
}).compile();
pageLayoutService = module.get<PageLayoutService>(PageLayoutService);
pageLayoutRepository = module.get<Repository<PageLayoutEntity>>(
getRepositoryToken(PageLayoutEntity),
);
});
describe('findByWorkspaceId', () => {
it('should return page layouts for a workspace', async () => {
const workspaceId = 'workspace-id';
const expectedPageLayouts = [mockPageLayout];
jest
.spyOn(pageLayoutRepository, 'find')
.mockResolvedValue(expectedPageLayouts);
const result = await pageLayoutService.findByWorkspaceId(workspaceId);
expect(pageLayoutRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
deletedAt: IsNull(),
},
relations: ['tabs'],
});
expect(result).toEqual(expectedPageLayouts);
});
});
describe('findByObjectMetadataId', () => {
it('should return page layouts for an object metadata id', async () => {
const workspaceId = 'workspace-id';
const objectMetadataId = 'object-metadata-id';
const expectedPageLayouts = [mockPageLayout];
jest
.spyOn(pageLayoutRepository, 'find')
.mockResolvedValue(expectedPageLayouts);
const result = await pageLayoutService.findByObjectMetadataId(
workspaceId,
objectMetadataId,
);
expect(pageLayoutRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
objectMetadataId,
deletedAt: IsNull(),
},
relations: ['tabs'],
});
expect(result).toEqual(expectedPageLayouts);
});
});
describe('findByIdOrThrow', () => {
it('should return a page layout by id', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockResolvedValue(mockPageLayout);
const result = await pageLayoutService.findByIdOrThrow(id, workspaceId);
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutService.findByIdOrThrow(id, workspaceId),
).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
describe('create', () => {
const validPageLayoutData = {
name: 'Test Page Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'object-metadata-id',
};
it('should create a page layout successfully', async () => {
jest
.spyOn(pageLayoutRepository, 'create')
.mockReturnValue(mockPageLayout);
jest
.spyOn(pageLayoutRepository, 'save')
.mockResolvedValue(mockPageLayout);
const result = await pageLayoutService.create(
validPageLayoutData,
'workspace-id',
);
expect(pageLayoutRepository.create).toHaveBeenCalledWith({
...validPageLayoutData,
workspaceId: 'workspace-id',
});
expect(pageLayoutRepository.save).toHaveBeenCalledWith(mockPageLayout);
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when name is missing', async () => {
const invalidData = { ...validPageLayoutData, name: undefined };
const workspaceId = 'workspace-id';
await expect(
pageLayoutService.create(invalidData, workspaceId),
).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.NAME_REQUIRED,
),
PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA,
),
);
});
});
describe('update', () => {
it('should update a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
const updateData = { name: 'Updated Page Layout' };
const updatedPageLayout = { ...mockPageLayout, ...updateData };
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout);
jest
.spyOn(pageLayoutRepository, 'save')
.mockResolvedValue(updatedPageLayout);
const result = await pageLayoutService.update(
id,
workspaceId,
updateData,
);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
);
expect(pageLayoutRepository.save).toHaveBeenCalledWith({
...mockPageLayout,
...updateData,
});
expect(result).toEqual(updatedPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { name: 'Updated Page Layout' };
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(
pageLayoutService.update(id, workspaceId, updateData),
).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
describe('delete', () => {
it('should delete a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout);
jest
.spyOn(pageLayoutRepository, 'softDelete')
.mockResolvedValue({} as any);
const result = await pageLayoutService.delete(id, workspaceId);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
);
expect(pageLayoutRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(pageLayoutService.delete(id, workspaceId)).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
describe('destroy', () => {
it('should destroy a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockResolvedValue(mockPageLayout);
jest.spyOn(pageLayoutRepository, 'delete').mockResolvedValue({} as any);
const result = await pageLayoutService.destroy(id, workspaceId);
expect(pageLayoutRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockRejectedValue(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(pageLayoutService.destroy(id, workspaceId)).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
describe('restore', () => {
it('should restore a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
const deletedPageLayout = { ...mockPageLayout, deletedAt: new Date() };
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockResolvedValue(deletedPageLayout);
jest.spyOn(pageLayoutRepository, 'restore').mockResolvedValue({} as any);
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout);
const result = await pageLayoutService.restore(id, workspaceId);
expect(pageLayoutRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutRepository.restore).toHaveBeenCalledWith(id);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
);
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue(null);
await expect(pageLayoutService.restore(id, workspaceId)).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
});
@@ -0,0 +1,28 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
export const pageLayoutGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof PageLayoutException) {
switch (error.code) {
case PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND:
throw new NotFoundError(error.message);
case PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -0,0 +1,12 @@
import { ArgumentsHost, Catch } from '@nestjs/common';
import { GqlExceptionFilter } from '@nestjs/graphql';
import { PageLayoutException } from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
import { pageLayoutGraphqlApiExceptionHandler } from 'src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception-handler.util';
@Catch(PageLayoutException)
export class PageLayoutGraphqlApiExceptionFilter implements GqlExceptionFilter {
catch(exception: PageLayoutException, _host: ArgumentsHost) {
return pageLayoutGraphqlApiExceptionHandler(exception);
}
}
@@ -29,7 +29,6 @@ export class ViewController {
constructor(private readonly viewService: ViewService) {}
@Get()
@UseGuards(WorkspaceAuthGuard)
async findMany(
@AuthWorkspace() workspace: Workspace,
@Query('objectMetadataId') objectMetadataId?: string,
@@ -45,8 +45,8 @@ export class ViewResolver {
private readonly viewFieldService: ViewFieldService,
private readonly viewFilterService: ViewFilterService,
private readonly viewFilterGroupService: ViewFilterGroupService,
private readonly viewGroupService: ViewGroupService,
private readonly viewSortService: ViewSortService,
private readonly viewGroupService: ViewGroupService,
private readonly i18nService: I18nService,
) {}