[feat] Enable deletion of custom fields in workspace (#4780)

**Context**
cf. feature request
[#4597](https://github.com/twentyhq/twenty/issues/4597)
Enables deletion of custom fields that aren't active nor of type
relation

Also 
1. renamed a misnamed file
2. deleted redundant hook BeforeDeleteOneField as it seemed best to move
the logic to the resolver instead

**How was it tested?**
Did not write unit tests as code is to be migrated (discussed with
@Weiko).
Locally tested.

---------

Co-authored-by: Marie Stoppa <mariestoppa@MacBook-Pro-de-Marie.local>
This commit is contained in:
Marie
2024-04-03 17:17:23 +02:00
committed by GitHub
parent 358269c60e
commit ff6abacc86
8 changed files with 132 additions and 62 deletions

View File

@ -0,0 +1,9 @@
import { InputType, ID } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
@InputType()
export class DeleteOneFieldInput {
@IDField(() => ID, { description: 'The id of the field to delete.' })
id!: string;
}

View File

@ -9,7 +9,6 @@ import {
import { GraphQLJSON } from 'graphql-type-json';
import {
Authorize,
BeforeDeleteOne,
FilterableField,
IDField,
QueryOptions,
@ -31,7 +30,6 @@ import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-met
import { RelationMetadataDTO } from 'src/engine/metadata-modules/relation-metadata/dtos/relation-metadata.dto';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { BeforeDeleteOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-delete-one-field.hook';
import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator';
import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
@ -52,7 +50,6 @@ registerEnumType(FieldMetadataType, {
disableSort: true,
maxResultsSize: 1000,
})
@BeforeDeleteOne(BeforeDeleteOneField)
@Relation('toRelationMetadata', () => RelationMetadataDTO, {
nullable: true,
})

View File

@ -61,7 +61,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
one: { disabled: true },
many: { disabled: true },
},
delete: { many: { disabled: true } },
delete: { disabled: true },
guards: [JwtAuthGuard],
},
],

View File

@ -1,4 +1,8 @@
import { UseGuards } from '@nestjs/common';
import {
BadRequestException,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import {
Args,
Mutation,
@ -11,9 +15,11 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { CreateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { RelationDefinitionDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation-definition.dto';
import { UpdateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
@UseGuards(JwtAuthGuard)
@ -43,6 +49,43 @@ export class FieldMetadataResolver {
});
}
@Mutation(() => FieldMetadataDTO)
async deleteOneField(
@Args('input') input: DeleteOneFieldInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
if (!workspaceId) {
throw new UnauthorizedException();
}
const fieldMetadata =
await this.fieldMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: input.id.toString(),
},
});
if (!fieldMetadata) {
throw new BadRequestException('Field does not exist');
}
if (!fieldMetadata.isCustom) {
throw new BadRequestException("Standard Fields can't be deleted");
}
if (fieldMetadata.isActive) {
throw new BadRequestException("Active fields can't be deleted");
}
if (fieldMetadata.type === FieldMetadataType.RELATION) {
throw new BadRequestException(
"Relation fields can't be deleted, you need to delete the RelationMetadata instead",
);
}
return this.fieldMetadataService.deleteOneField(input, workspaceId);
}
@ResolveField(() => RelationDefinitionDTO, { nullable: true })
async relationDefinition(
@Parent() fieldMetadata: FieldMetadataDTO,

View File

@ -16,6 +16,7 @@ import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metada
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnDrop,
WorkspaceMigrationTableAction,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { generateTargetColumnMap } from 'src/engine/metadata-modules/field-metadata/utils/generate-target-column-map.util';
@ -35,6 +36,8 @@ import {
RelationMetadataEntity,
RelationMetadataType,
} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input';
import { computeCustomName } from 'src/engine/utils/compute-custom-name.util';
import {
FieldMetadataEntity,
@ -358,6 +361,80 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
}
}
public async deleteOneField(
input: DeleteOneFieldInput,
workspaceId: string,
): Promise<FieldMetadataEntity> {
const queryRunner = this.metadataDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction(); // transaction not safe as a different queryRunner is used within workspaceMigrationRunnerService
try {
const fieldMetadataRepository =
queryRunner.manager.getRepository<FieldMetadataEntity<'default'>>(
FieldMetadataEntity,
);
const fieldMetadata = await fieldMetadataRepository.findOne({
where: {
id: input.id,
workspaceId: workspaceId,
},
});
if (!fieldMetadata) {
throw new NotFoundException('Field does not exist');
}
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: fieldMetadata?.objectMetadataId,
},
});
if (!objectMetadata) {
throw new NotFoundException('Object does not exist');
}
await fieldMetadataRepository.delete(fieldMetadata.id);
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`delete-${fieldMetadata.name}`),
workspaceId,
[
{
name: computeObjectTargetTable(objectMetadata),
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP,
columnName: computeCustomName(
fieldMetadata.name,
fieldMetadata.isCustom,
),
} satisfies WorkspaceMigrationColumnDrop,
],
} satisfies WorkspaceMigrationTableAction,
],
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
await queryRunner.commitTransaction();
return fieldMetadata;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
public async findOneOrFail(
id: string,
options?: FindOneOptions<FieldMetadataEntity>,

View File

@ -1,56 +0,0 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
BeforeDeleteOneHook,
DeleteOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
@Injectable()
export class BeforeDeleteOneField implements BeforeDeleteOneHook<any> {
constructor(readonly fieldMetadataService: FieldMetadataService) {}
async run(
instance: DeleteOneInputType,
context: any,
): Promise<DeleteOneInputType> {
const workspaceId = context?.req?.user?.workspace?.id;
if (!workspaceId) {
throw new UnauthorizedException();
}
const fieldMetadata =
await this.fieldMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
id: instance.id.toString(),
},
});
if (!fieldMetadata) {
throw new BadRequestException('Field does not exist');
}
if (!fieldMetadata.isCustom) {
throw new BadRequestException("Standard Fields can't be deleted");
}
if (fieldMetadata.isActive) {
throw new BadRequestException("Active fields can't be deleted");
}
if (fieldMetadata.type === FieldMetadataType.RELATION) {
throw new BadRequestException(
"Relation fields can't be deleted, you need to delete the RelationMetadata instead",
);
}
return instance;
}
}

View File

@ -17,7 +17,7 @@ import {
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { BeforeDeleteOneRelation } from 'src/engine/metadata-modules/relation-metadata/hooks/before-delete-one-field.hook';
import { BeforeDeleteOneRelation } from 'src/engine/metadata-modules/relation-metadata/hooks/before-delete-one-relation.hook';
registerEnumType(RelationMetadataType, {
name: 'RelationMetadataType',