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:
+115
@@ -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 { CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
|
||||
import { UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
|
||||
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
|
||||
import {
|
||||
generateViewFieldExceptionMessage,
|
||||
generateViewFieldUserFriendlyExceptionMessage,
|
||||
ViewFieldException,
|
||||
ViewFieldExceptionCode,
|
||||
ViewFieldExceptionMessageKey,
|
||||
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
|
||||
import { ViewFieldRestApiExceptionFilter } from 'src/engine/metadata-modules/view-field/filters/view-field-rest-api-exception.filter';
|
||||
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
|
||||
|
||||
@Controller('rest/metadata/viewFields')
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@UseFilters(ViewFieldRestApiExceptionFilter)
|
||||
export class ViewFieldController {
|
||||
constructor(private readonly viewFieldService: ViewFieldService) {}
|
||||
|
||||
@Get()
|
||||
async findMany(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Query('viewId') viewId?: string,
|
||||
): Promise<ViewFieldEntity[]> {
|
||||
if (viewId) {
|
||||
return this.viewFieldService.findByViewId(workspace.id, viewId);
|
||||
}
|
||||
|
||||
return this.viewFieldService.findByWorkspaceId(workspace.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param('id') id: string,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<ViewFieldEntity> {
|
||||
const viewField = await this.viewFieldService.findById(id, workspace.id);
|
||||
|
||||
if (!isDefined(viewField)) {
|
||||
throw new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
|
||||
id,
|
||||
),
|
||||
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
|
||||
{
|
||||
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return viewField;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() input: UpdateViewFieldInput['update'],
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<ViewFieldEntity> {
|
||||
const updatedViewField = await this.viewFieldService.update(
|
||||
id,
|
||||
workspace.id,
|
||||
input,
|
||||
);
|
||||
|
||||
return updatedViewField;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Body() input: CreateViewFieldInput,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<ViewFieldEntity> {
|
||||
return this.viewFieldService.create({
|
||||
...input,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(
|
||||
@Param('id') id: string,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ success: boolean }> {
|
||||
const deletedViewField = await this.viewFieldService.delete(
|
||||
id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return { success: isDefined(deletedViewField) };
|
||||
}
|
||||
|
||||
// TODO: the destroy endpoint will be implemented when we settle on a strategy
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class CreateViewFieldInput {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
id?: string;
|
||||
|
||||
@IsUUID()
|
||||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
fieldMetadataId: string;
|
||||
|
||||
@IsUUID()
|
||||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
viewId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Field({ nullable: true, defaultValue: true })
|
||||
isVisible?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Field({ nullable: true, defaultValue: 0 })
|
||||
size?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Field({ nullable: true, defaultValue: 0 })
|
||||
position?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AggregateOperations)
|
||||
@Field(() => AggregateOperations, { nullable: true })
|
||||
aggregateOperation?: AggregateOperations;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class DeleteViewFieldInput {
|
||||
@IDField(() => UUIDScalarType, {
|
||||
description: 'The id of the view field to delete.',
|
||||
})
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class DestroyViewFieldInput {
|
||||
@IDField(() => UUIDScalarType, {
|
||||
description: 'The id of the view field to destroy.',
|
||||
})
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
class UpdateViewFieldInputUpdates {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Field({ nullable: true })
|
||||
isVisible?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Field({ nullable: true })
|
||||
size?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Field({ nullable: true })
|
||||
position?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AggregateOperations)
|
||||
@Field(() => AggregateOperations, { nullable: true })
|
||||
aggregateOperation?: AggregateOperations;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateViewFieldInput {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@Field(() => UUIDScalarType, {
|
||||
description: 'The id of the view field to update',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@Type(() => UpdateViewFieldInputUpdates)
|
||||
@ValidateNested()
|
||||
@Field(() => UpdateViewFieldInputUpdates, {
|
||||
description: 'The view field to update',
|
||||
})
|
||||
update: UpdateViewFieldInputUpdates;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
registerEnumType(AggregateOperations, { name: 'AggregateOperations' });
|
||||
|
||||
@ObjectType('CoreViewField')
|
||||
export class ViewFieldDTO {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
fieldMetadataId: string;
|
||||
|
||||
@Field({ nullable: false, defaultValue: true })
|
||||
isVisible: boolean;
|
||||
|
||||
@Field({ nullable: false, defaultValue: 0 })
|
||||
size: number;
|
||||
|
||||
@Field({ nullable: false, defaultValue: 0 })
|
||||
position: number;
|
||||
|
||||
@Field(() => AggregateOperations, { nullable: true })
|
||||
aggregateOperation?: AggregateOperations | null;
|
||||
|
||||
@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;
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
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 { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
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: 'viewField', schema: 'core' })
|
||||
@Index('IDX_VIEW_FIELD_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
|
||||
@Index('IDX_VIEW_FIELD_VIEW_ID', ['viewId'], {
|
||||
where: '"deletedAt" IS NULL',
|
||||
})
|
||||
@Index(
|
||||
'IDX_VIEW_FIELD_FIELD_METADATA_ID_VIEW_ID_UNIQUE',
|
||||
['fieldMetadataId', 'viewId'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"deletedAt" IS NULL',
|
||||
},
|
||||
)
|
||||
export class ViewFieldEntity
|
||||
extends SyncableEntity
|
||||
implements Required<ViewFieldEntity>
|
||||
{
|
||||
@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: 'int', default: 0 })
|
||||
size: number;
|
||||
|
||||
@Column({ nullable: false, type: 'double precision', default: 0 })
|
||||
position: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: Object.values(AggregateOperations),
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
aggregateOperation: AggregateOperations | null;
|
||||
|
||||
@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.viewFields, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'viewId' })
|
||||
view: Relation<ViewEntity>;
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class ViewFieldException extends CustomException {
|
||||
declare code: ViewFieldExceptionCode;
|
||||
constructor(
|
||||
message: string,
|
||||
code: ViewFieldExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
|
||||
) {
|
||||
super(message, code, { userFriendlyMessage });
|
||||
}
|
||||
}
|
||||
|
||||
export enum ViewFieldExceptionCode {
|
||||
VIEW_FIELD_NOT_FOUND = 'VIEW_FIELD_NOT_FOUND',
|
||||
INVALID_VIEW_FIELD_DATA = 'INVALID_VIEW_FIELD_DATA',
|
||||
}
|
||||
|
||||
export enum ViewFieldExceptionMessageKey {
|
||||
WORKSPACE_ID_REQUIRED = 'WORKSPACE_ID_REQUIRED',
|
||||
VIEW_ID_REQUIRED = 'VIEW_ID_REQUIRED',
|
||||
VIEW_FIELD_NOT_FOUND = 'VIEW_FIELD_NOT_FOUND',
|
||||
INVALID_VIEW_FIELD_DATA = 'INVALID_VIEW_FIELD_DATA',
|
||||
FIELD_METADATA_ID_REQUIRED = 'FIELD_METADATA_ID_REQUIRED',
|
||||
VIEW_FIELD_ALREADY_EXISTS = 'VIEW_FIELD_ALREADY_EXISTS',
|
||||
}
|
||||
|
||||
export const generateViewFieldExceptionMessage = (
|
||||
key: ViewFieldExceptionMessageKey,
|
||||
id?: string,
|
||||
) => {
|
||||
switch (key) {
|
||||
case ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED:
|
||||
return 'WorkspaceId is required';
|
||||
case ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED:
|
||||
return 'ViewId is required';
|
||||
case ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND:
|
||||
return `View field${id ? ` (id: ${id})` : ''} not found`;
|
||||
case ViewFieldExceptionMessageKey.INVALID_VIEW_FIELD_DATA:
|
||||
return `Invalid view field data${id ? ` for view field id: ${id}` : ''}`;
|
||||
case ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
|
||||
return 'FieldMetadataId is required';
|
||||
case ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS:
|
||||
return 'View field already exists';
|
||||
default:
|
||||
assertUnreachable(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const generateViewFieldUserFriendlyExceptionMessage = (
|
||||
key: ViewFieldExceptionMessageKey,
|
||||
) => {
|
||||
switch (key) {
|
||||
case ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED:
|
||||
return t`WorkspaceId is required to create a view field.`;
|
||||
case ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED:
|
||||
return t`ViewId is required to create a view field.`;
|
||||
case ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED:
|
||||
return t`FieldMetadataId is required to create a view field.`;
|
||||
case ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS:
|
||||
return t`View field already exists.`;
|
||||
}
|
||||
};
|
||||
+48
@@ -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 {
|
||||
ViewFieldException,
|
||||
ViewFieldExceptionCode,
|
||||
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
|
||||
import { type CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
@Catch(ViewFieldException)
|
||||
export class ViewFieldRestApiExceptionFilter implements ExceptionFilter {
|
||||
constructor(
|
||||
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
catch(exception: ViewFieldException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
switch (exception.code) {
|
||||
case ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
404,
|
||||
);
|
||||
case ViewFieldExceptionCode.INVALID_VIEW_FIELD_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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.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';
|
||||
import { CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
|
||||
import { DeleteViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/delete-view-field.input';
|
||||
import { DestroyViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/destroy-view-field.input';
|
||||
import { UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
|
||||
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
|
||||
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
|
||||
import { ViewFieldV2Service } from 'src/engine/metadata-modules/view-field/services/view-field-v2.service';
|
||||
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
|
||||
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
|
||||
|
||||
@Resolver(() => ViewFieldDTO)
|
||||
@UseFilters(ViewGraphqlApiExceptionFilter)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
export class ViewFieldResolver {
|
||||
constructor(
|
||||
private readonly viewFieldService: ViewFieldService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly viewFieldV2Service: ViewFieldV2Service,
|
||||
) {}
|
||||
|
||||
@Query(() => [ViewFieldDTO])
|
||||
async getCoreViewFields(
|
||||
@Args('viewId', { type: () => String }) viewId: string,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<ViewFieldEntity[]> {
|
||||
return this.viewFieldService.findByViewId(workspace.id, viewId);
|
||||
}
|
||||
|
||||
@Query(() => ViewFieldDTO, { nullable: true })
|
||||
async getCoreViewField(
|
||||
@Args('id', { type: () => String }) id: string,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<ViewFieldEntity | null> {
|
||||
return this.viewFieldService.findById(id, workspace.id);
|
||||
}
|
||||
|
||||
@Mutation(() => ViewFieldDTO)
|
||||
async updateCoreViewField(
|
||||
@Args('input') updateViewFieldInput: UpdateViewFieldInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
): Promise<ViewFieldDTO> {
|
||||
const isWorkspaceMigrationV2Enabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (isWorkspaceMigrationV2Enabled) {
|
||||
return await this.viewFieldV2Service.updateOne({
|
||||
updateViewFieldInput,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
return this.viewFieldService.update(
|
||||
updateViewFieldInput.id,
|
||||
workspaceId,
|
||||
updateViewFieldInput.update,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => ViewFieldDTO)
|
||||
async createCoreViewField(
|
||||
@Args('input') createViewFieldInput: CreateViewFieldInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
): Promise<ViewFieldDTO> {
|
||||
const isWorkspaceMigrationV2Enabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (isWorkspaceMigrationV2Enabled) {
|
||||
return await this.viewFieldV2Service.createOne({
|
||||
createViewFieldInput,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
return this.viewFieldService.create({
|
||||
...createViewFieldInput,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => ViewFieldDTO)
|
||||
async deleteCoreViewField(
|
||||
@Args('input') deleteViewFieldInput: DeleteViewFieldInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
): Promise<ViewFieldDTO> {
|
||||
const isWorkspaceMigrationV2Enabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (isWorkspaceMigrationV2Enabled) {
|
||||
return await this.viewFieldV2Service.deleteOne({
|
||||
deleteViewFieldInput,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
const deletedViewField = await this.viewFieldService.delete(
|
||||
deleteViewFieldInput.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return deletedViewField;
|
||||
}
|
||||
|
||||
@Mutation(() => ViewFieldDTO)
|
||||
async destroyCoreViewField(
|
||||
@Args('input') destroyViewFieldInput: DestroyViewFieldInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
): Promise<ViewFieldDTO> {
|
||||
const isWorkspaceMigrationV2Enabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (isWorkspaceMigrationV2Enabled) {
|
||||
return await this.viewFieldV2Service.destroyOne({
|
||||
destroyViewFieldInput,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
const deletedViewField = await this.viewFieldService.destroy(
|
||||
destroyViewFieldInput.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return deletedViewField;
|
||||
}
|
||||
}
|
||||
+526
@@ -0,0 +1,526 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { UserInputError } from 'apollo-server-core';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
|
||||
import {
|
||||
ViewFieldException,
|
||||
ViewFieldExceptionCode,
|
||||
ViewFieldExceptionMessageKey,
|
||||
generateViewFieldExceptionMessage,
|
||||
generateViewFieldUserFriendlyExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
|
||||
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
|
||||
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
|
||||
describe('ViewFieldService', () => {
|
||||
let viewFieldService: ViewFieldService;
|
||||
let viewFieldRepository: Repository<ViewFieldEntity>;
|
||||
|
||||
const mockViewField = {
|
||||
id: 'view-field-id',
|
||||
fieldMetadataId: 'field-id',
|
||||
viewId: 'view-id',
|
||||
workspaceId: 'workspace-id',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 100,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
} as ViewFieldEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ViewFieldService,
|
||||
{
|
||||
provide: getRepositoryToken(ViewFieldEntity),
|
||||
useValue: {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ViewEntity),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheStorageService,
|
||||
useValue: {
|
||||
flushGraphQLOperation: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
viewFieldService = module.get<ViewFieldService>(ViewFieldService);
|
||||
viewFieldRepository = module.get<Repository<ViewFieldEntity>>(
|
||||
getRepositoryToken(ViewFieldEntity),
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(viewFieldService).toBeDefined();
|
||||
});
|
||||
|
||||
describe('findByWorkspaceId', () => {
|
||||
it('should return view fields for a workspace', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const expectedViewFields = [mockViewField];
|
||||
|
||||
jest
|
||||
.spyOn(viewFieldRepository, 'find')
|
||||
.mockResolvedValue(expectedViewFields);
|
||||
|
||||
const result = await viewFieldService.findByWorkspaceId(workspaceId);
|
||||
|
||||
expect(viewFieldRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId,
|
||||
deletedAt: expect.anything(),
|
||||
},
|
||||
order: { position: 'ASC' },
|
||||
relations: ['workspace', 'view'],
|
||||
});
|
||||
expect(result).toEqual(expectedViewFields);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByViewId', () => {
|
||||
it('should return view fields for a view', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const viewId = 'view-id';
|
||||
const expectedViewFields = [mockViewField];
|
||||
|
||||
jest
|
||||
.spyOn(viewFieldRepository, 'find')
|
||||
.mockResolvedValue(expectedViewFields);
|
||||
|
||||
const result = await viewFieldService.findByViewId(workspaceId, viewId);
|
||||
|
||||
expect(viewFieldRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId,
|
||||
viewId,
|
||||
deletedAt: expect.anything(),
|
||||
},
|
||||
order: { position: 'ASC' },
|
||||
relations: ['workspace', 'view'],
|
||||
});
|
||||
expect(result).toEqual(expectedViewFields);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return a view field by id', async () => {
|
||||
const id = 'view-field-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
|
||||
jest
|
||||
.spyOn(viewFieldRepository, 'findOne')
|
||||
.mockResolvedValue(mockViewField);
|
||||
|
||||
const result = await viewFieldService.findById(id, workspaceId);
|
||||
|
||||
expect(viewFieldRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
deletedAt: expect.anything(),
|
||||
},
|
||||
relations: ['workspace', 'view'],
|
||||
});
|
||||
expect(result).toEqual(mockViewField);
|
||||
});
|
||||
|
||||
it('should return null when view field is not found', async () => {
|
||||
const id = 'non-existent-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
|
||||
jest.spyOn(viewFieldRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
const result = await viewFieldService.findById(id, workspaceId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const validViewFieldData = {
|
||||
fieldMetadataId: 'field-id',
|
||||
viewId: 'view-id',
|
||||
workspaceId: 'workspace-id',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 100,
|
||||
};
|
||||
|
||||
it('should throw exception when workspaceId is missing', async () => {
|
||||
const invalidData = { ...validViewFieldData, workspaceId: undefined };
|
||||
|
||||
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
|
||||
new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
|
||||
),
|
||||
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
|
||||
{
|
||||
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw exception when viewId is missing', async () => {
|
||||
const invalidData = { ...validViewFieldData, viewId: undefined };
|
||||
|
||||
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
|
||||
new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
|
||||
),
|
||||
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
|
||||
{
|
||||
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw exception when fieldMetadataId is missing', async () => {
|
||||
const invalidData = { ...validViewFieldData, fieldMetadataId: undefined };
|
||||
|
||||
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
|
||||
new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
|
||||
),
|
||||
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
|
||||
{
|
||||
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw exception if position is lower than label metadata identifier', async () => {
|
||||
const labelIdentifierFieldMetadataId =
|
||||
'label-identifier-field-matadata-id';
|
||||
const labelIdentifierViewFieldId =
|
||||
'view-field-for-label-metadata-identifier-id';
|
||||
|
||||
const labelIdentifierViewField = {
|
||||
...mockViewField,
|
||||
id: labelIdentifierViewFieldId,
|
||||
fieldMetadataId: labelIdentifierFieldMetadataId,
|
||||
position: 0,
|
||||
};
|
||||
|
||||
const mockView = {
|
||||
id: 'view-id',
|
||||
objectMetadata: {
|
||||
labelIdentifierFieldMetadataId,
|
||||
},
|
||||
viewFields: [
|
||||
labelIdentifierViewField,
|
||||
{ ...mockViewField, position: 1 },
|
||||
],
|
||||
} as ViewEntity;
|
||||
|
||||
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
|
||||
if (id === mockViewField.id) {
|
||||
return Promise.resolve(mockViewField);
|
||||
}
|
||||
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
jest
|
||||
.spyOn(viewFieldService, 'findViewByIdWithRelations')
|
||||
.mockResolvedValue(mockView);
|
||||
|
||||
const invalidData = { ...validViewFieldData, position: -1 };
|
||||
|
||||
await expect(viewFieldService.create(invalidData)).rejects.toThrow(
|
||||
new UserInputError(
|
||||
'Label metadata identifier must keep the minimal position in the view.',
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'Record text must be in first position of the view.',
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update a view field successfully', async () => {
|
||||
const id = 'view-field-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
const updateData = { position: 1 };
|
||||
const updatedViewField = { ...mockViewField, ...updateData };
|
||||
|
||||
const mockView = {
|
||||
id: 'view-id',
|
||||
objectMetadata: {
|
||||
labelIdentifierFieldMetadataId: mockViewField.fieldMetadataId,
|
||||
},
|
||||
viewFields: [mockViewField],
|
||||
} as ViewEntity;
|
||||
|
||||
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(mockViewField);
|
||||
jest
|
||||
.spyOn(viewFieldRepository, 'save')
|
||||
.mockResolvedValue(updatedViewField);
|
||||
jest
|
||||
.spyOn(viewFieldService, 'findViewByIdWithRelations')
|
||||
.mockResolvedValue(mockView);
|
||||
|
||||
const result = await viewFieldService.update(id, workspaceId, updateData);
|
||||
|
||||
expect(viewFieldService.findById).toHaveBeenCalledWith(id, workspaceId);
|
||||
expect(viewFieldRepository.save).toHaveBeenCalledWith({
|
||||
id,
|
||||
...updateData,
|
||||
});
|
||||
expect(result).toEqual({ ...mockViewField, ...updatedViewField });
|
||||
});
|
||||
|
||||
it('should throw exception when view field is not found', async () => {
|
||||
const id = 'non-existent-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
const updateData = { position: 1 };
|
||||
|
||||
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
viewFieldService.update(id, workspaceId, updateData),
|
||||
).rejects.toThrow(
|
||||
new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
|
||||
id,
|
||||
),
|
||||
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw exception when label metadata identifier is not in first position (label metadata identifier field update case)', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const updateData = { position: 2 };
|
||||
const labelIdentifierFieldMetadataId =
|
||||
'label-identifier-field-matadata-id';
|
||||
const labelIdentifierViewFieldId =
|
||||
'view-field-for-label-metadata-identifier-id';
|
||||
|
||||
const labelIdentifierViewField = {
|
||||
...mockViewField,
|
||||
id: labelIdentifierViewFieldId,
|
||||
fieldMetadataId: labelIdentifierFieldMetadataId,
|
||||
position: 0,
|
||||
};
|
||||
|
||||
const mockView = {
|
||||
id: 'view-id',
|
||||
objectMetadata: {
|
||||
labelIdentifierFieldMetadataId,
|
||||
},
|
||||
viewFields: [
|
||||
labelIdentifierViewField,
|
||||
{ ...mockViewField, position: 1 },
|
||||
],
|
||||
} as ViewEntity;
|
||||
|
||||
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
|
||||
if (id === labelIdentifierViewFieldId) {
|
||||
return Promise.resolve(labelIdentifierViewField);
|
||||
}
|
||||
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
jest
|
||||
.spyOn(viewFieldService, 'findViewByIdWithRelations')
|
||||
.mockResolvedValue(mockView);
|
||||
|
||||
await expect(
|
||||
viewFieldService.update(
|
||||
labelIdentifierViewFieldId,
|
||||
workspaceId,
|
||||
updateData,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new UserInputError(
|
||||
'Label metadata identifier must keep the minimal position in the view.',
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'Record text must be in first position of the view.',
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw exception when label metadata identifier is not in first position (regular field update case)', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const updateData = { position: -1 };
|
||||
const labelIdentifierFieldMetadataId =
|
||||
'label-identifier-field-matadata-id';
|
||||
const labelIdentifierViewFieldId =
|
||||
'view-field-for-label-metadata-identifier-id';
|
||||
|
||||
const labelIdentifierViewField = {
|
||||
...mockViewField,
|
||||
id: labelIdentifierViewFieldId,
|
||||
fieldMetadataId: labelIdentifierFieldMetadataId,
|
||||
position: 0,
|
||||
};
|
||||
|
||||
const mockView = {
|
||||
id: 'view-id',
|
||||
objectMetadata: {
|
||||
labelIdentifierFieldMetadataId,
|
||||
},
|
||||
viewFields: [
|
||||
labelIdentifierViewField,
|
||||
{ ...mockViewField, position: 1 },
|
||||
],
|
||||
} as ViewEntity;
|
||||
|
||||
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
|
||||
if (id === mockViewField.id) {
|
||||
return Promise.resolve(mockViewField);
|
||||
}
|
||||
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
jest
|
||||
.spyOn(viewFieldService, 'findViewByIdWithRelations')
|
||||
.mockResolvedValue(mockView);
|
||||
|
||||
await expect(
|
||||
viewFieldService.update(mockViewField.id, workspaceId, updateData),
|
||||
).rejects.toThrow(
|
||||
new UserInputError(
|
||||
'Label metadata identifier must keep the minimal position in the view.',
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'Record text must be in first position of the view.',
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw exception when attempting to make label metadata identifier invisible', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const updateData = { isVisible: false };
|
||||
const labelIdentifierFieldMetadataId =
|
||||
'label-identifier-field-matadata-id';
|
||||
const labelIdentifierViewFieldId =
|
||||
'view-field-for-label-metadata-identifier-id';
|
||||
|
||||
const labelIdentifierViewField = {
|
||||
...mockViewField,
|
||||
id: labelIdentifierViewFieldId,
|
||||
fieldMetadataId: labelIdentifierFieldMetadataId,
|
||||
position: 0,
|
||||
};
|
||||
|
||||
const mockView = {
|
||||
id: 'view-id',
|
||||
objectMetadata: {
|
||||
labelIdentifierFieldMetadataId,
|
||||
},
|
||||
viewFields: [labelIdentifierViewField, mockViewField],
|
||||
} as ViewEntity;
|
||||
|
||||
jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
|
||||
if (id === labelIdentifierViewFieldId) {
|
||||
return Promise.resolve(labelIdentifierViewField);
|
||||
}
|
||||
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
jest
|
||||
.spyOn(viewFieldService, 'findViewByIdWithRelations')
|
||||
.mockResolvedValue(mockView);
|
||||
|
||||
await expect(
|
||||
viewFieldService.update(
|
||||
labelIdentifierViewField.id,
|
||||
workspaceId,
|
||||
updateData,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new UserInputError('Label metadata identifier must stay visible.', {
|
||||
userFriendlyMessage: 'Record text must stay visible.',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a view field successfully', async () => {
|
||||
const id = 'view-field-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
|
||||
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(mockViewField);
|
||||
jest
|
||||
.spyOn(viewFieldRepository, 'softDelete')
|
||||
.mockResolvedValue({} as any);
|
||||
|
||||
const result = await viewFieldService.delete(id, workspaceId);
|
||||
|
||||
expect(viewFieldService.findById).toHaveBeenCalledWith(id, workspaceId);
|
||||
expect(viewFieldRepository.softDelete).toHaveBeenCalledWith(id);
|
||||
expect(result).toEqual(mockViewField);
|
||||
});
|
||||
|
||||
it('should throw exception when view field is not found', async () => {
|
||||
const id = 'non-existent-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
|
||||
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(null);
|
||||
|
||||
await expect(viewFieldService.delete(id, workspaceId)).rejects.toThrow(
|
||||
new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
|
||||
id,
|
||||
),
|
||||
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should destroy a view field successfully', async () => {
|
||||
const id = 'view-field-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
|
||||
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(mockViewField);
|
||||
jest.spyOn(viewFieldRepository, 'delete').mockResolvedValue({} as any);
|
||||
|
||||
const result = await viewFieldService.destroy(id, workspaceId);
|
||||
|
||||
expect(viewFieldService.findById).toHaveBeenCalledWith(id, workspaceId);
|
||||
expect(viewFieldRepository.delete).toHaveBeenCalledWith(id);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { EMPTY_FLAT_ENTITY_MAPS } from 'src/engine/core-modules/common/constant/empty-flat-entity-maps.constant';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
|
||||
import { deleteFlatEntityFromFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/delete-flat-entity-from-flat-entity-maps-or-throw.util';
|
||||
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { getSubFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/get-sub-flat-entity-maps-or-throw.util';
|
||||
import { replaceFlatEntityInFlatEntityMapsOrThrow } from 'src/engine/core-modules/common/utils/replace-flat-entity-in-flat-entity-maps-or-throw.util';
|
||||
import { fromCreateViewFieldInputToFlatViewFieldToCreate } from 'src/engine/metadata-modules/flat-view-field/utils/from-create-view-field-input-to-flat-view-field-to-create.util';
|
||||
import { fromDeleteViewFieldInputToFlatViewFieldOrThrow } from 'src/engine/metadata-modules/flat-view-field/utils/from-delete-view-field-input-to-flat-view-field-or-throw.util';
|
||||
import { fromDestroyViewFieldInputToFlatViewFieldOrThrow } from 'src/engine/metadata-modules/flat-view-field/utils/from-destroy-view-field-input-to-flat-view-field-or-throw.util';
|
||||
import { fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow } from 'src/engine/metadata-modules/flat-view-field/utils/from-update-view-field-input-to-flat-view-field-to-update-or-throw.util';
|
||||
import { CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
|
||||
import { DeleteViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/delete-view-field.input';
|
||||
import { DestroyViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/destroy-view-field.input';
|
||||
import { UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
|
||||
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
|
||||
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
|
||||
|
||||
@Injectable()
|
||||
export class ViewFieldV2Service {
|
||||
constructor(
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
) {}
|
||||
|
||||
async createOne({
|
||||
createViewFieldInput,
|
||||
workspaceId,
|
||||
}: {
|
||||
createViewFieldInput: CreateViewFieldInput;
|
||||
workspaceId: string;
|
||||
}): Promise<ViewFieldDTO> {
|
||||
const {
|
||||
flatViewFieldMaps: existingFlatViewFieldMaps,
|
||||
flatViewMaps,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: [
|
||||
'flatViewFieldMaps',
|
||||
'flatViewMaps',
|
||||
'flatFieldMetadataMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const flatViewFieldToCreate =
|
||||
fromCreateViewFieldInputToFlatViewFieldToCreate({
|
||||
createViewFieldInput,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const toFlatViewFieldMaps = addFlatEntityToFlatEntityMapsOrThrow({
|
||||
flatEntity: flatViewFieldToCreate,
|
||||
flatEntityMaps: existingFlatViewFieldMaps,
|
||||
});
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
fromToAllFlatEntityMaps: {
|
||||
flatViewFieldMaps: {
|
||||
from: existingFlatViewFieldMaps,
|
||||
to: toFlatViewFieldMaps,
|
||||
},
|
||||
},
|
||||
dependencyAllFlatEntityMaps: {
|
||||
flatFieldMetadataMaps,
|
||||
flatViewMaps,
|
||||
flatObjectMetadataMaps,
|
||||
},
|
||||
buildOptions: {
|
||||
isSystemBuild: false,
|
||||
inferDeletionFromMissingEntities: false,
|
||||
},
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (isDefined(validateAndBuildResult)) {
|
||||
throw new WorkspaceMigrationBuilderExceptionV2(
|
||||
validateAndBuildResult,
|
||||
'Multiple validation errors occurred while creating view field',
|
||||
);
|
||||
}
|
||||
|
||||
const { flatViewFieldMaps: recomputedExistingFlatViewFieldMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatViewFieldMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
return findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityId: flatViewFieldToCreate.id,
|
||||
flatEntityMaps: recomputedExistingFlatViewFieldMaps,
|
||||
});
|
||||
}
|
||||
|
||||
async updateOne({
|
||||
updateViewFieldInput,
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
updateViewFieldInput: UpdateViewFieldInput;
|
||||
}): Promise<ViewFieldDTO> {
|
||||
const {
|
||||
flatViewFieldMaps: existingFlatViewFieldMaps,
|
||||
flatObjectMetadataMaps,
|
||||
flatViewMaps,
|
||||
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: [
|
||||
'flatViewFieldMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
'flatViewMaps',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const optimisticallyUpdatedFlatView =
|
||||
fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow({
|
||||
flatViewFieldMaps: existingFlatViewFieldMaps,
|
||||
updateViewFieldInput,
|
||||
});
|
||||
|
||||
const toFlatViewFieldMaps = addFlatEntityToFlatEntityMapsOrThrow({
|
||||
flatEntity: optimisticallyUpdatedFlatView,
|
||||
flatEntityMaps: EMPTY_FLAT_ENTITY_MAPS,
|
||||
});
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
fromToAllFlatEntityMaps: {
|
||||
flatViewFieldMaps: {
|
||||
from: existingFlatViewFieldMaps,
|
||||
to: toFlatViewFieldMaps,
|
||||
},
|
||||
},
|
||||
dependencyAllFlatEntityMaps: {
|
||||
flatObjectMetadataMaps,
|
||||
flatViewMaps,
|
||||
},
|
||||
buildOptions: {
|
||||
isSystemBuild: false,
|
||||
inferDeletionFromMissingEntities: false,
|
||||
},
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (isDefined(validateAndBuildResult)) {
|
||||
throw new WorkspaceMigrationBuilderExceptionV2(
|
||||
validateAndBuildResult,
|
||||
'Multiple validation errors occurred while updating view field',
|
||||
);
|
||||
}
|
||||
|
||||
const { flatViewFieldMaps: recomputedExistingFlatViewFieldMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatViewFieldMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
return findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityId: optimisticallyUpdatedFlatView.id,
|
||||
flatEntityMaps: recomputedExistingFlatViewFieldMaps,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOne({
|
||||
deleteViewFieldInput,
|
||||
workspaceId,
|
||||
}: {
|
||||
deleteViewFieldInput: DeleteViewFieldInput;
|
||||
workspaceId: string;
|
||||
}): Promise<ViewFieldDTO> {
|
||||
const {
|
||||
flatViewFieldMaps: existingFlatViewFieldMaps,
|
||||
flatObjectMetadataMaps,
|
||||
flatViewMaps,
|
||||
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: [
|
||||
'flatViewFieldMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
'flatViewMaps',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const optimisticallyUpdatedFlatViewWithDeletedAt =
|
||||
fromDeleteViewFieldInputToFlatViewFieldOrThrow({
|
||||
flatViewFieldMaps: existingFlatViewFieldMaps,
|
||||
deleteViewFieldInput,
|
||||
});
|
||||
|
||||
const toFlatViewFieldMaps = replaceFlatEntityInFlatEntityMapsOrThrow({
|
||||
flatEntity: optimisticallyUpdatedFlatViewWithDeletedAt,
|
||||
flatEntityMaps: existingFlatViewFieldMaps,
|
||||
});
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
fromToAllFlatEntityMaps: {
|
||||
flatViewFieldMaps: {
|
||||
from: existingFlatViewFieldMaps,
|
||||
to: toFlatViewFieldMaps,
|
||||
},
|
||||
},
|
||||
dependencyAllFlatEntityMaps: {
|
||||
flatObjectMetadataMaps,
|
||||
flatViewMaps,
|
||||
},
|
||||
buildOptions: {
|
||||
isSystemBuild: false,
|
||||
inferDeletionFromMissingEntities: false,
|
||||
},
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (isDefined(validateAndBuildResult)) {
|
||||
throw new WorkspaceMigrationBuilderExceptionV2(
|
||||
validateAndBuildResult,
|
||||
'Multiple validation errors occurred while deleting view field',
|
||||
);
|
||||
}
|
||||
|
||||
const { flatViewFieldMaps: recomputedExistingFlatViewFieldMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatViewFieldMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
return findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityId: optimisticallyUpdatedFlatViewWithDeletedAt.id,
|
||||
flatEntityMaps: recomputedExistingFlatViewFieldMaps,
|
||||
});
|
||||
}
|
||||
|
||||
async destroyOne({
|
||||
destroyViewFieldInput,
|
||||
workspaceId,
|
||||
}: {
|
||||
destroyViewFieldInput: DestroyViewFieldInput;
|
||||
workspaceId: string;
|
||||
}): Promise<ViewFieldDTO> {
|
||||
const {
|
||||
flatViewFieldMaps: existingFlatViewFieldMaps,
|
||||
flatViewMaps: existingFlatViewMaps,
|
||||
} =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatViewFieldMaps', 'flatViewMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const existingViewFieldToDelete =
|
||||
fromDestroyViewFieldInputToFlatViewFieldOrThrow({
|
||||
destroyViewFieldInput,
|
||||
flatViewFieldMaps: existingFlatViewFieldMaps,
|
||||
});
|
||||
|
||||
const fromFlatViewFieldMaps = getSubFlatEntityMapsOrThrow({
|
||||
flatEntityIds: [existingViewFieldToDelete.id],
|
||||
flatEntityMaps: existingFlatViewFieldMaps,
|
||||
});
|
||||
const toFlatViewFieldMaps = deleteFlatEntityFromFlatEntityMapsOrThrow({
|
||||
flatEntityMaps: fromFlatViewFieldMaps,
|
||||
entityToDeleteId: existingViewFieldToDelete.id,
|
||||
});
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
fromToAllFlatEntityMaps: {
|
||||
flatViewFieldMaps: {
|
||||
from: fromFlatViewFieldMaps,
|
||||
to: toFlatViewFieldMaps,
|
||||
},
|
||||
},
|
||||
dependencyAllFlatEntityMaps: {
|
||||
flatViewMaps: existingFlatViewMaps,
|
||||
},
|
||||
buildOptions: {
|
||||
isSystemBuild: false,
|
||||
inferDeletionFromMissingEntities: true,
|
||||
},
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (isDefined(validateAndBuildResult)) {
|
||||
throw new WorkspaceMigrationBuilderExceptionV2(
|
||||
validateAndBuildResult,
|
||||
'Multiple validation errors occurred while deleting view field',
|
||||
);
|
||||
}
|
||||
|
||||
return existingViewFieldToDelete;
|
||||
}
|
||||
}
|
||||
+462
@@ -0,0 +1,462 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
|
||||
import {
|
||||
ViewFieldException,
|
||||
ViewFieldExceptionCode,
|
||||
ViewFieldExceptionMessageKey,
|
||||
generateViewFieldExceptionMessage,
|
||||
generateViewFieldUserFriendlyExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
|
||||
import { FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION } from 'src/engine/metadata-modules/view/constants/find-all-core-views-graphql-operation.constant';
|
||||
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class ViewFieldService {
|
||||
constructor(
|
||||
@InjectRepository(ViewFieldEntity)
|
||||
private readonly viewFieldRepository: Repository<ViewFieldEntity>,
|
||||
@InjectRepository(ViewEntity)
|
||||
private readonly viewRepository: Repository<ViewEntity>,
|
||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
||||
) {}
|
||||
|
||||
async findByWorkspaceId(workspaceId: string): Promise<ViewFieldEntity[]> {
|
||||
return this.viewFieldRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
order: { position: 'ASC' },
|
||||
relations: ['workspace', 'view'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByViewId(
|
||||
workspaceId: string,
|
||||
viewId: string,
|
||||
): Promise<ViewFieldEntity[]> {
|
||||
return this.viewFieldRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
viewId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
order: { position: 'ASC' },
|
||||
relations: ['workspace', 'view'],
|
||||
});
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<ViewFieldEntity | null> {
|
||||
const viewField = await this.viewFieldRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
relations: ['workspace', 'view'],
|
||||
});
|
||||
|
||||
return viewField || null;
|
||||
}
|
||||
|
||||
async create(
|
||||
viewFieldData: Partial<ViewFieldEntity>,
|
||||
): Promise<ViewFieldEntity> {
|
||||
if (this.hasRequiredFields(viewFieldData)) {
|
||||
try {
|
||||
if (isDefined(viewFieldData.position)) {
|
||||
const viewFieldDataWithPosition =
|
||||
viewFieldData as typeof viewFieldData & { position: number };
|
||||
|
||||
await this.verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
|
||||
viewFieldDataWithPosition,
|
||||
viewFieldData.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
await this.verifyLabelMetadataIdentifierIsVisibleOrThrow(
|
||||
viewFieldData,
|
||||
viewFieldData.workspaceId,
|
||||
);
|
||||
|
||||
const viewFieldDataWithPosition = await this.formatViewFieldData(
|
||||
viewFieldData,
|
||||
viewFieldData.workspaceId,
|
||||
);
|
||||
|
||||
const viewField = this.viewFieldRepository.create(
|
||||
viewFieldDataWithPosition,
|
||||
);
|
||||
|
||||
const savedViewField = await this.viewFieldRepository.save(viewField);
|
||||
|
||||
await this.flushGraphQLCache(viewFieldData.workspaceId);
|
||||
|
||||
const createdViewField = await this.findById(
|
||||
savedViewField.id,
|
||||
viewFieldData.workspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(createdViewField)) {
|
||||
throw new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
|
||||
),
|
||||
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
|
||||
{
|
||||
userFriendlyMessage:
|
||||
generateViewFieldUserFriendlyExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return createdViewField;
|
||||
} catch (error) {
|
||||
if (
|
||||
error.message.includes(
|
||||
'duplicate key value violates unique constraint',
|
||||
)
|
||||
) {
|
||||
throw new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS,
|
||||
),
|
||||
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
|
||||
{
|
||||
userFriendlyMessage:
|
||||
generateViewFieldUserFriendlyExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
if (!isDefined(viewFieldData.workspaceId)) {
|
||||
throw new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
|
||||
),
|
||||
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
|
||||
{
|
||||
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(viewFieldData.viewId)) {
|
||||
throw new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
|
||||
),
|
||||
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
|
||||
{
|
||||
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
|
||||
),
|
||||
ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
|
||||
{
|
||||
userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
updateData: Partial<ViewFieldEntity>,
|
||||
): Promise<ViewFieldEntity> {
|
||||
const existingViewField = await this.findById(id, workspaceId);
|
||||
|
||||
if (!isDefined(existingViewField)) {
|
||||
throw new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
|
||||
id,
|
||||
),
|
||||
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
const viewId = existingViewField.viewId;
|
||||
|
||||
if (this.updatesPosition(updateData)) {
|
||||
await this.verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
|
||||
{
|
||||
...updateData,
|
||||
viewId,
|
||||
id,
|
||||
fieldMetadataId: existingViewField.fieldMetadataId,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.disablesVisibility(updateData)) {
|
||||
await this.verifyLabelMetadataIdentifierIsVisibleOrThrow(
|
||||
{ ...updateData, viewId, id },
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedViewField = await this.viewFieldRepository.save({
|
||||
id,
|
||||
...updateData,
|
||||
});
|
||||
|
||||
await this.flushGraphQLCache(workspaceId);
|
||||
|
||||
return { ...existingViewField, ...updatedViewField };
|
||||
}
|
||||
|
||||
async delete(id: string, workspaceId: string): Promise<ViewFieldEntity> {
|
||||
const viewField = await this.findById(id, workspaceId);
|
||||
|
||||
if (!isDefined(viewField)) {
|
||||
throw new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
|
||||
id,
|
||||
),
|
||||
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await this.viewFieldRepository.softDelete(id);
|
||||
|
||||
await this.flushGraphQLCache(workspaceId);
|
||||
|
||||
return viewField;
|
||||
}
|
||||
|
||||
async destroy(id: string, workspaceId: string): Promise<ViewFieldEntity> {
|
||||
const viewField = await this.findById(id, workspaceId);
|
||||
|
||||
if (!isDefined(viewField)) {
|
||||
throw new ViewFieldException(
|
||||
generateViewFieldExceptionMessage(
|
||||
ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
|
||||
id,
|
||||
),
|
||||
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await this.viewFieldRepository.delete(id);
|
||||
|
||||
await this.flushGraphQLCache(workspaceId);
|
||||
|
||||
return viewField;
|
||||
}
|
||||
|
||||
private updatesPosition(
|
||||
data: Partial<ViewFieldEntity>,
|
||||
): data is Partial<ViewFieldEntity> & { position: number } {
|
||||
return isDefined(data.position);
|
||||
}
|
||||
|
||||
private disablesVisibility(
|
||||
data: Partial<ViewFieldEntity>,
|
||||
): data is Partial<ViewFieldEntity> & { isVisible: boolean } {
|
||||
return data.isVisible === false;
|
||||
}
|
||||
|
||||
private async verifyLabelMetadataIdentifierIsVisibleOrThrow(
|
||||
newOrUpdatedViewField: Partial<ViewFieldEntity> & {
|
||||
viewId: string;
|
||||
},
|
||||
workspaceId: string,
|
||||
) {
|
||||
const view = await this.findViewByIdWithRelations(
|
||||
newOrUpdatedViewField.viewId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(view)) {
|
||||
throw new Error(`View not found: ${newOrUpdatedViewField.viewId}`);
|
||||
}
|
||||
|
||||
const labelMetadataIdentifierFieldMetadataId =
|
||||
view.objectMetadata.labelIdentifierFieldMetadataId;
|
||||
|
||||
const labelMetadataIdentifierViewField = view.viewFields.find(
|
||||
(viewField) =>
|
||||
viewField.fieldMetadataId === labelMetadataIdentifierFieldMetadataId,
|
||||
);
|
||||
|
||||
if (
|
||||
!isDefined(labelMetadataIdentifierViewField) ||
|
||||
labelMetadataIdentifierViewField.id !== newOrUpdatedViewField.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newOrUpdatedViewField.isVisible === false) {
|
||||
throw new UserInputError('Label metadata identifier must stay visible.', {
|
||||
userFriendlyMessage: 'Record text must stay visible.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
|
||||
newOrUpdatedViewField: Partial<ViewFieldEntity> & { viewId: string } & {
|
||||
position: number;
|
||||
},
|
||||
workspaceId: string,
|
||||
) {
|
||||
const view = await this.findViewByIdWithRelations(
|
||||
newOrUpdatedViewField.viewId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(view)) {
|
||||
throw new Error(`View not found: ${newOrUpdatedViewField.viewId}`);
|
||||
}
|
||||
|
||||
const viewFieldsWithoutUpdatedViewField = view.viewFields.filter(
|
||||
(viewField) => viewField.id !== newOrUpdatedViewField?.id,
|
||||
);
|
||||
|
||||
if (viewFieldsWithoutUpdatedViewField.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelMetadataIdentifierFieldMetadataId =
|
||||
view.objectMetadata.labelIdentifierFieldMetadataId;
|
||||
|
||||
if (
|
||||
labelMetadataIdentifierFieldMetadataId ===
|
||||
newOrUpdatedViewField.fieldMetadataId
|
||||
) {
|
||||
const minPositionInViewWithoutUpdatedViewField =
|
||||
viewFieldsWithoutUpdatedViewField.reduce(
|
||||
(minViewField, viewField) =>
|
||||
viewField.position < minViewField.position
|
||||
? viewField
|
||||
: minViewField,
|
||||
viewFieldsWithoutUpdatedViewField[0],
|
||||
).position;
|
||||
|
||||
if (
|
||||
newOrUpdatedViewField.position >=
|
||||
minPositionInViewWithoutUpdatedViewField
|
||||
) {
|
||||
throw new UserInputError(
|
||||
'Label metadata identifier must keep the minimal position in the view.',
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'Record text must be in first position of the view.',
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const labelMetadataIdentifierViewFieldPosition = view.viewFields.find(
|
||||
(viewField) =>
|
||||
viewField.fieldMetadataId === labelMetadataIdentifierFieldMetadataId,
|
||||
)?.position;
|
||||
|
||||
if (
|
||||
isDefined(labelMetadataIdentifierViewFieldPosition) &&
|
||||
newOrUpdatedViewField.position <=
|
||||
labelMetadataIdentifierViewFieldPosition
|
||||
) {
|
||||
throw new UserInputError(
|
||||
'Label metadata identifier must keep the minimal position in the view.',
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'Record text must be in first position of the view.',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async formatViewFieldData(
|
||||
viewFieldData: Partial<ViewFieldEntity> & { viewId: string },
|
||||
workspaceId: string,
|
||||
): Promise<Partial<ViewFieldEntity>> {
|
||||
if (!isDefined(viewFieldData.position)) {
|
||||
const view = await this.findViewByIdWithRelations(
|
||||
viewFieldData.viewId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(view)) {
|
||||
throw new Error(`View not found: ${viewFieldData.viewId}`);
|
||||
}
|
||||
|
||||
const highestPositionInView = view.viewFields.reduce(
|
||||
(maxViewField, viewField) =>
|
||||
viewField.position > maxViewField.position ? viewField : maxViewField,
|
||||
view.viewFields[0],
|
||||
);
|
||||
|
||||
return { ...viewFieldData, position: highestPositionInView.position + 1 };
|
||||
} else {
|
||||
return viewFieldData;
|
||||
}
|
||||
}
|
||||
|
||||
private hasRequiredFields(
|
||||
data: Partial<ViewFieldEntity>,
|
||||
): data is Partial<ViewFieldEntity> & {
|
||||
viewId: string;
|
||||
fieldMetadataId: string;
|
||||
workspaceId: string;
|
||||
} {
|
||||
return (
|
||||
isDefined(data.viewId) &&
|
||||
isDefined(data.fieldMetadataId) &&
|
||||
isDefined(data.workspaceId)
|
||||
);
|
||||
}
|
||||
|
||||
public async findViewByIdWithRelations(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<ViewEntity | null> {
|
||||
const view = await this.viewRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
relations: ['workspace', 'objectMetadata', 'viewFields'],
|
||||
});
|
||||
|
||||
return view || null;
|
||||
}
|
||||
|
||||
private async flushGraphQLCache(workspaceId: string): Promise<void> {
|
||||
await this.workspaceCacheStorageService.flushGraphQLOperation({
|
||||
operationName: FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { ViewFieldController } from 'src/engine/metadata-modules/view-field/controllers/view-field.controller';
|
||||
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
|
||||
import { ViewFieldResolver } from 'src/engine/metadata-modules/view-field/resolvers/view-field.resolver';
|
||||
import { ViewFieldV2Service } from 'src/engine/metadata-modules/view-field/services/view-field-v2.service';
|
||||
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
|
||||
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ViewFieldEntity, ViewEntity]),
|
||||
WorkspaceCacheStorageModule,
|
||||
FeatureFlagModule,
|
||||
WorkspaceMigrationV2Module,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
],
|
||||
controllers: [ViewFieldController],
|
||||
providers: [ViewFieldService, ViewFieldResolver, ViewFieldV2Service],
|
||||
exports: [ViewFieldService, ViewFieldV2Service],
|
||||
})
|
||||
export class ViewFieldModule {}
|
||||
Reference in New Issue
Block a user