Move view in metadata-modules/ and create atomic folder + module for each view entity (#14990)

# Introduction
Preparing view-filter and view-group introduction in v2 core engine
Moving view from `core-modules` to `metadata-modules`

## What happened

### Created dedicated modules for each view entity:
- ViewFieldModule
- ViewFilterModule
- ViewFilterGroupModule
- ViewGroupModule
- ViewSortModule

### Each module is now completely independent with its own:
- Controller
- Resolver
- Service
- Entity

### Created dedicated abstraction metadata module folder for:
- flat-view-field
- flat-view

### Dependencies 
- Eleminated circular dep on ViewModule to all others ones
- Granular import not importing the whole viewModule anymore everywhere


close https://github.com/twentyhq/core-team-issues/issues/1703
This commit is contained in:
Paul Rastoin
2025-10-09 15:18:15 +02:00
committed by GitHub
parent c9a1a110e1
commit 59fbe35a8c
198 changed files with 877 additions and 765 deletions
@@ -0,0 +1,115 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
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';
import { CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input';
import { UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
import { type ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto';
import {
generateViewGroupExceptionMessage,
generateViewGroupUserFriendlyExceptionMessage,
ViewGroupException,
ViewGroupExceptionCode,
ViewGroupExceptionMessageKey,
} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { ViewGroupRestApiExceptionFilter } from 'src/engine/metadata-modules/view-group/filters/view-group-rest-api-exception.filter';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
@Controller('rest/metadata/viewGroups')
@UseGuards(WorkspaceAuthGuard)
@UseFilters(ViewGroupRestApiExceptionFilter)
export class ViewGroupController {
constructor(private readonly viewGroupService: ViewGroupService) {}
@Get()
async findMany(
@AuthWorkspace() workspace: Workspace,
@Query('viewId') viewId?: string,
): Promise<ViewGroupDTO[]> {
if (viewId) {
return this.viewGroupService.findByViewId(workspace.id, viewId);
}
return this.viewGroupService.findByWorkspaceId(workspace.id);
}
@Get(':id')
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO> {
const viewGroup = await this.viewGroupService.findById(id, workspace.id);
if (!isDefined(viewGroup)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
),
},
);
}
return viewGroup;
}
@Post()
async create(
@Body() input: CreateViewGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO> {
return this.viewGroupService.create({
...input,
workspaceId: workspace.id,
});
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() input: UpdateViewGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO> {
const updatedViewGroup = await this.viewGroupService.update(
id,
workspace.id,
input,
);
return updatedViewGroup;
}
@Delete(':id')
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<{ success: boolean }> {
const deletedViewGroup = await this.viewGroupService.delete(
id,
workspace.id,
);
return { success: isDefined(deletedViewGroup) };
}
// TODO: the destroy endpoint will be implemented when we settle on a strategy
}
@@ -0,0 +1,24 @@
import { Field, InputType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateViewGroupInput {
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@Field({ nullable: true, defaultValue: true })
isVisible?: boolean;
@Field({ nullable: false })
fieldValue: string;
@Field({ nullable: true, defaultValue: 0 })
position?: number;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
}
@@ -0,0 +1,6 @@
import { InputType, PartialType } from '@nestjs/graphql';
import { CreateViewGroupInput } from './create-view-group.input';
@InputType()
export class UpdateViewGroupInput extends PartialType(CreateViewGroupInput) {}
@@ -0,0 +1,38 @@
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('CoreViewGroup')
export class ViewGroupDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType, { nullable: false })
fieldMetadataId: string;
@Field({ nullable: false, defaultValue: true })
isVisible: boolean;
@Field({ nullable: false })
fieldValue: string;
@Field({ nullable: false, defaultValue: 0 })
position: number;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@Field(() => UUIDScalarType, { nullable: false })
workspaceId: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
}
@@ -0,0 +1,73 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@Entity({ name: 'viewGroup', schema: 'core' })
@Index('IDX_VIEW_GROUP_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@Index('IDX_VIEW_GROUP_VIEW_ID', ['viewId'], {
where: '"deletedAt" IS NULL',
})
export class ViewGroupEntity extends SyncableEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
fieldMetadataId: string;
@ManyToOne(() => FieldMetadataEntity, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'fieldMetadataId' })
fieldMetadata: Relation<FieldMetadataEntity>;
@Column({ nullable: false, default: true })
isVisible: boolean;
@Column({ nullable: false, type: 'text' })
fieldValue: string;
@Column({ nullable: false, type: 'double precision', default: 0 })
position: number;
@Column({ nullable: false, type: 'uuid' })
viewId: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date | null;
@ManyToOne(() => Workspace, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
@ManyToOne(() => ViewEntity, (view) => view.viewGroups, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'viewId' })
view: Relation<ViewEntity>;
}
@@ -0,0 +1,61 @@
import { t } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export class ViewGroupException extends CustomException {
declare code: ViewGroupExceptionCode;
constructor(
message: string,
code: ViewGroupExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, { userFriendlyMessage });
}
}
export enum ViewGroupExceptionCode {
VIEW_GROUP_NOT_FOUND = 'VIEW_GROUP_NOT_FOUND',
INVALID_VIEW_GROUP_DATA = 'INVALID_VIEW_GROUP_DATA',
}
export enum ViewGroupExceptionMessageKey {
WORKSPACE_ID_REQUIRED = 'WORKSPACE_ID_REQUIRED',
VIEW_ID_REQUIRED = 'VIEW_ID_REQUIRED',
VIEW_GROUP_NOT_FOUND = 'VIEW_GROUP_NOT_FOUND',
INVALID_VIEW_GROUP_DATA = 'INVALID_VIEW_GROUP_DATA',
FIELD_METADATA_ID_REQUIRED = 'FIELD_METADATA_ID_REQUIRED',
}
export const generateViewGroupExceptionMessage = (
key: ViewGroupExceptionMessageKey,
id?: string,
) => {
switch (key) {
case ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED:
return 'WorkspaceId is required';
case ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED:
return 'ViewId is required';
case ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND:
return `View group${id ? ` (id: ${id})` : ''} not found`;
case ViewGroupExceptionMessageKey.INVALID_VIEW_GROUP_DATA:
return `Invalid view group data${id ? ` for view group id: ${id}` : ''}`;
case ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
return 'FieldMetadataId is required';
default:
assertUnreachable(key);
}
};
export const generateViewGroupUserFriendlyExceptionMessage = (
key: ViewGroupExceptionMessageKey,
) => {
switch (key) {
case ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED:
return t`WorkspaceId is required to create a view group.`;
case ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED:
return t`ViewId is required to create a view group.`;
case ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
return t`FieldMetadataId is required to create a view group.`;
}
};
@@ -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 {
ViewGroupException,
ViewGroupExceptionCode,
} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { type CustomException } from 'src/utils/custom-exception';
@Catch(ViewGroupException)
export class ViewGroupRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: ViewGroupException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
404,
);
case ViewGroupExceptionCode.INVALID_VIEW_GROUP_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,
);
}
}
}
@@ -0,0 +1,87 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { isDefined } from 'twenty-shared/utils';
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';
import { CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input';
import { UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
import { ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
@Resolver(() => ViewGroupDTO)
@UseFilters(ViewGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ViewGroupResolver {
constructor(private readonly viewGroupService: ViewGroupService) {}
@Query(() => [ViewGroupDTO])
async getCoreViewGroups(
@AuthWorkspace() workspace: Workspace,
@Args('viewId', { type: () => String, nullable: true })
viewId?: string,
): Promise<ViewGroupDTO[]> {
if (viewId) {
return this.viewGroupService.findByViewId(workspace.id, viewId);
}
return this.viewGroupService.findByWorkspaceId(workspace.id);
}
@Query(() => ViewGroupDTO, { nullable: true })
async getCoreViewGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO | null> {
return this.viewGroupService.findById(id, workspace.id);
}
@Mutation(() => ViewGroupDTO)
async createCoreViewGroup(
@Args('input') input: CreateViewGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO> {
return this.viewGroupService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => ViewGroupDTO)
async updateCoreViewGroup(
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdateViewGroupInput,
@AuthWorkspace() workspace: Workspace,
): Promise<ViewGroupDTO> {
return this.viewGroupService.update(id, workspace.id, input);
}
@Mutation(() => Boolean)
async deleteCoreViewGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const deletedViewGroup = await this.viewGroupService.delete(
id,
workspace.id,
);
return isDefined(deletedViewGroup);
}
@Mutation(() => Boolean)
async destroyCoreViewGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const deletedViewGroup = await this.viewGroupService.destroy(
id,
workspace.id,
);
return isDefined(deletedViewGroup);
}
}
@@ -0,0 +1,321 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import {
ViewGroupException,
ViewGroupExceptionCode,
ViewGroupExceptionMessageKey,
generateViewGroupExceptionMessage,
generateViewGroupUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
describe('ViewGroupService', () => {
let viewGroupService: ViewGroupService;
let viewGroupRepository: Repository<ViewGroupEntity>;
const mockViewGroup = {
id: 'view-group-id',
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
fieldValue: 'group-value',
isVisible: true,
position: 0,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as ViewGroupEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ViewGroupService,
{
provide: getRepositoryToken(ViewGroupEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
},
},
{
provide: WorkspaceCacheStorageService,
useValue: {
flushGraphQLOperation: jest.fn(),
},
},
],
}).compile();
viewGroupService = module.get<ViewGroupService>(ViewGroupService);
viewGroupRepository = module.get<Repository<ViewGroupEntity>>(
getRepositoryToken(ViewGroupEntity),
);
});
it('should be defined', () => {
expect(viewGroupService).toBeDefined();
});
describe('findByWorkspaceId', () => {
it('should return view groups for a workspace', async () => {
const workspaceId = 'workspace-id';
const expectedViewGroups = [mockViewGroup];
jest
.spyOn(viewGroupRepository, 'find')
.mockResolvedValue(expectedViewGroups);
const result = await viewGroupService.findByWorkspaceId(workspaceId);
expect(viewGroupRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
deletedAt: expect.anything(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
expect(result).toEqual(expectedViewGroups);
});
});
describe('findByViewId', () => {
it('should return view groups for a view', async () => {
const workspaceId = 'workspace-id';
const viewId = 'view-id';
const expectedViewGroups = [mockViewGroup];
jest
.spyOn(viewGroupRepository, 'find')
.mockResolvedValue(expectedViewGroups);
const result = await viewGroupService.findByViewId(workspaceId, viewId);
expect(viewGroupRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
viewId,
deletedAt: expect.anything(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
expect(result).toEqual(expectedViewGroups);
});
});
describe('findById', () => {
it('should return a view group by id', async () => {
const id = 'view-group-id';
const workspaceId = 'workspace-id';
jest
.spyOn(viewGroupRepository, 'findOne')
.mockResolvedValue(mockViewGroup);
const result = await viewGroupService.findById(id, workspaceId);
expect(viewGroupRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: expect.anything(),
},
relations: ['workspace', 'view'],
});
expect(result).toEqual(mockViewGroup);
});
it('should return null when view group is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewGroupRepository, 'findOne').mockResolvedValue(null);
const result = await viewGroupService.findById(id, workspaceId);
expect(result).toBeNull();
});
});
describe('create', () => {
const validViewGroupData = {
fieldMetadataId: 'field-id',
viewId: 'view-id',
workspaceId: 'workspace-id',
fieldValue: 'group-value',
isVisible: true,
position: 0,
};
it('should create a view group successfully', async () => {
jest.spyOn(viewGroupRepository, 'create').mockReturnValue(mockViewGroup);
jest.spyOn(viewGroupRepository, 'save').mockResolvedValue(mockViewGroup);
const result = await viewGroupService.create(validViewGroupData);
expect(viewGroupRepository.create).toHaveBeenCalledWith(
validViewGroupData,
);
expect(viewGroupRepository.save).toHaveBeenCalledWith(mockViewGroup);
expect(result).toEqual(mockViewGroup);
});
it('should throw exception when workspaceId is missing', async () => {
const invalidData = { ...validViewGroupData, workspaceId: undefined };
await expect(viewGroupService.create(invalidData)).rejects.toThrow(
new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when viewId is missing', async () => {
const invalidData = { ...validViewGroupData, viewId: undefined };
await expect(viewGroupService.create(invalidData)).rejects.toThrow(
new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
),
);
});
it('should throw exception when fieldMetadataId is missing', async () => {
const invalidData = { ...validViewGroupData, fieldMetadataId: undefined };
await expect(viewGroupService.create(invalidData)).rejects.toThrow(
new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
),
);
});
});
describe('update', () => {
it('should update a view group successfully', async () => {
const id = 'view-group-id';
const workspaceId = 'workspace-id';
const updateData = { isVisible: false };
const updatedViewGroup = { ...mockViewGroup, ...updateData };
jest.spyOn(viewGroupService, 'findById').mockResolvedValue(mockViewGroup);
jest
.spyOn(viewGroupRepository, 'save')
.mockResolvedValue(updatedViewGroup);
const result = await viewGroupService.update(id, workspaceId, updateData);
expect(viewGroupService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewGroupRepository.save).toHaveBeenCalledWith({
id,
...updateData,
});
expect(result).toEqual({ ...mockViewGroup, ...updatedViewGroup });
});
it('should throw exception when view group is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { isVisible: false };
jest.spyOn(viewGroupService, 'findById').mockResolvedValue(null);
await expect(
viewGroupService.update(id, workspaceId, updateData),
).rejects.toThrow(
new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
),
);
});
});
describe('delete', () => {
it('should delete a view group successfully', async () => {
const id = 'view-group-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewGroupService, 'findById').mockResolvedValue(mockViewGroup);
jest
.spyOn(viewGroupRepository, 'softDelete')
.mockResolvedValue({} as any);
const result = await viewGroupService.delete(id, workspaceId);
expect(viewGroupService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewGroupRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockViewGroup);
});
it('should throw exception when view group is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewGroupService, 'findById').mockResolvedValue(null);
await expect(viewGroupService.delete(id, workspaceId)).rejects.toThrow(
new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
),
);
});
});
describe('destroy', () => {
it('should destroy a view group successfully', async () => {
const id = 'view-group-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewGroupService, 'findById').mockResolvedValue(mockViewGroup);
jest.spyOn(viewGroupRepository, 'delete').mockResolvedValue({} as any);
const result = await viewGroupService.destroy(id, workspaceId);
expect(viewGroupService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewGroupRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual(true);
});
});
});
@@ -0,0 +1,197 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import {
ViewGroupException,
ViewGroupExceptionCode,
ViewGroupExceptionMessageKey,
generateViewGroupExceptionMessage,
generateViewGroupUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION } from 'src/engine/metadata-modules/view/constants/find-all-core-views-graphql-operation.constant';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class ViewGroupService {
constructor(
@InjectRepository(ViewGroupEntity)
private readonly viewGroupRepository: Repository<ViewGroupEntity>,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async findByWorkspaceId(workspaceId: string): Promise<ViewGroupEntity[]> {
return this.viewGroupRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
}
async findByViewId(
workspaceId: string,
viewId: string,
): Promise<ViewGroupEntity[]> {
return this.viewGroupRepository.find({
where: {
workspaceId,
viewId,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
relations: ['workspace', 'view'],
});
}
async findById(
id: string,
workspaceId: string,
): Promise<ViewGroupEntity | null> {
const viewGroup = await this.viewGroupRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['workspace', 'view'],
});
return viewGroup || null;
}
async create(
viewGroupData: Partial<ViewGroupEntity>,
): Promise<ViewGroupEntity> {
if (!isDefined(viewGroupData.workspaceId)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
},
);
}
if (!isDefined(viewGroupData.viewId)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_ID_REQUIRED,
),
},
);
}
if (!isDefined(viewGroupData.fieldMetadataId)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
),
},
);
}
const viewGroup = this.viewGroupRepository.create(viewGroupData);
await this.flushGraphQLCache(viewGroupData.workspaceId);
const savedViewGroup = await this.viewGroupRepository.save(viewGroup);
await this.flushGraphQLCache(viewGroupData.workspaceId);
return savedViewGroup;
}
async update(
id: string,
workspaceId: string,
updateData: Partial<ViewGroupEntity>,
): Promise<ViewGroupEntity> {
const existingViewGroup = await this.findById(id, workspaceId);
if (!isDefined(existingViewGroup)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
);
}
const updatedViewGroup = await this.viewGroupRepository.save({
id,
...updateData,
});
await this.flushGraphQLCache(workspaceId);
return { ...existingViewGroup, ...updatedViewGroup };
}
async delete(id: string, workspaceId: string): Promise<ViewGroupEntity> {
const viewGroup = await this.findById(id, workspaceId);
if (!isDefined(viewGroup)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
);
}
await this.viewGroupRepository.softDelete(id);
await this.flushGraphQLCache(workspaceId);
return viewGroup;
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
const viewGroup = await this.findById(id, workspaceId);
if (!isDefined(viewGroup)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
);
}
await this.viewGroupRepository.delete(id);
await this.flushGraphQLCache(workspaceId);
return true;
}
private async flushGraphQLCache(workspaceId: string): Promise<void> {
await this.workspaceCacheStorageService.flushGraphQLOperation({
operationName: FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION,
workspaceId,
});
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ViewGroupController } from 'src/engine/metadata-modules/view-group/controllers/view-group.controller';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { ViewGroupResolver } from 'src/engine/metadata-modules/view-group/resolvers/view-group.resolver';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([ViewGroupEntity]),
WorkspaceCacheStorageModule,
],
controllers: [ViewGroupController],
providers: [ViewGroupService, ViewGroupResolver],
exports: [ViewGroupService],
})
export class ViewGroupModule {}