Support custom object renaming (#7504)

This PR was created by [GitStart](https://gitstart.com/) to address the
requirements from this ticket:
[TWNTY-5491](https://clients.gitstart.com/twenty/5449/tickets/TWNTY-5491).
This ticket was imported from:
[TWNTY-5491](https://github.com/twentyhq/twenty/issues/5491)

 --- 

### Description

**How To Test:**\
1. Reset db using `npx nx database:reset twenty-server` on this PR

1. Run both backend and frontend
2. Navigate to `settings/data-model/objects/ `page
3. Select a `Custom `object from the list or create a new `Custom
`object
4. Navigate to custom object details page and click on edit button
5. Finally edit the object details.

**Issues and bugs**
The Typecheck is failing but we could not see this error locally
There is a bug after updating the label of a custom object. View title
is not updated till refreshing the page. We could not find a consistent
way to update this, should we reload the page after editing an object?


![](https://assets-service.gitstart.com/45430/03cd560f-a4f6-4ce2-9d78-6d3a9f56d197.png)###
Demo



<https://www.loom.com/share/64ecb57efad7498d99085cb11480b5dd?sid=28d0868c-e54f-454d-8432-3f789be9e2b7>

### Refs

#5491

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com>
Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
gitstart-app[bot]
2024-10-24 11:52:30 +00:00
committed by GitHub
parent c6ef14acc4
commit 414f2ac498
29 changed files with 900 additions and 192 deletions

View File

@ -13,8 +13,8 @@ import GraphQLJSON from 'graphql-type-json';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { BeforeCreateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-create-one-object.hook';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { BeforeCreateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-create-one-object.hook';
@InputType()
@BeforeCreateOne(BeforeCreateOneObject)
@ -81,4 +81,9 @@ export class CreateObjectInput {
primaryKeyFieldMetadataSettings?: FieldMetadataSettings<
FieldMetadataType | 'default'
>;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
shouldSyncLabelAndName?: boolean;
}

View File

@ -79,4 +79,7 @@ export class ObjectMetadataDTO {
@Field(() => String, { nullable: true })
imageIdentifierFieldMetadataId?: string | null;
@Field()
shouldSyncLabelAndName: boolean;
}

View File

@ -61,6 +61,11 @@ export class UpdateObjectPayload {
@IsOptional()
@Field({ nullable: true })
imageIdentifierFieldMetadataId?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
shouldSyncLabelAndName?: boolean;
}
@InputType()

View File

@ -14,7 +14,6 @@ import { Equal, In, Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Injectable()
export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
@ -99,47 +98,6 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
}
}
this.checkIfFieldIsEditable(instance.update, objectMetadata);
return instance;
}
// This is temporary until we properly use the MigrationRunner to update column names
private checkIfFieldIsEditable(
update: UpdateObjectPayload,
objectMetadata: ObjectMetadataEntity,
) {
if (
update.nameSingular &&
update.nameSingular !== objectMetadata.nameSingular
) {
throw new BadRequestException(
"Object's nameSingular can't be updated. Please create a new object instead",
);
}
if (
update.labelSingular &&
update.labelSingular !== objectMetadata.labelSingular
) {
throw new BadRequestException(
"Object's labelSingular can't be updated. Please create a new object instead",
);
}
if (update.namePlural && update.namePlural !== objectMetadata.namePlural) {
throw new BadRequestException(
"Object's namePlural can't be updated. Please create a new object instead",
);
}
if (
update.labelPlural &&
update.labelPlural !== objectMetadata.labelPlural
) {
throw new BadRequestException(
"Object's labelPlural can't be updated. Please create a new object instead",
);
}
}
}

View File

@ -75,6 +75,9 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface {
@Column({ nullable: true, type: 'uuid' })
imageIdentifierFieldMetadataId?: string | null;
@Column({ default: true })
shouldSyncLabelAndName: boolean;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;

View File

@ -5,7 +5,8 @@ import console from 'console';
import { Query, QueryOptions } from '@ptc-org/nestjs-query-core';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FindManyOptions, FindOneOptions, In, Repository } from 'typeorm';
import { isDefined } from 'class-validator';
import { FindManyOptions, FindOneOptions, In, Not, Repository } from 'typeorm';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
@ -25,6 +26,7 @@ import {
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { buildMigrationsForCustomObjectRelations } from 'src/engine/metadata-modules/object-metadata/utils/build-migrations-for-custom-object-relations.util';
import { validateObjectMetadataInputOrThrow } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-sync-label-name.util';
import {
RelationMetadataEntity,
RelationMetadataType,
@ -35,6 +37,7 @@ import { RemoteTableRelationsService } from 'src/engine/metadata-modules/remote-
import { mapUdtNameToFieldType } from 'src/engine/metadata-modules/remote-server/remote-table/utils/udt-name-mapper.util';
import { SearchService } from 'src/engine/metadata-modules/search/search.service';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
@ -201,34 +204,23 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
);
}
const objectAlreadyExists = await this.objectMetadataRepository.findOne({
where: [
{
nameSingular: objectMetadataInput.nameSingular,
workspaceId: objectMetadataInput.workspaceId,
},
{
nameSingular: objectMetadataInput.namePlural,
workspaceId: objectMetadataInput.workspaceId,
},
{
namePlural: objectMetadataInput.nameSingular,
workspaceId: objectMetadataInput.workspaceId,
},
{
namePlural: objectMetadataInput.namePlural,
workspaceId: objectMetadataInput.workspaceId,
},
],
});
if (objectAlreadyExists) {
throw new ObjectMetadataException(
'Object already exists',
ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS,
if (objectMetadataInput.shouldSyncLabelAndName === true) {
validateNameAndLabelAreSyncOrThrow(
objectMetadataInput.labelSingular,
objectMetadataInput.nameSingular,
);
validateNameAndLabelAreSyncOrThrow(
objectMetadataInput.labelPlural,
objectMetadataInput.namePlural,
);
}
this.validatesNoOtherObjectWithSameNameExistsOrThrows({
objectMetadataNamePlural: objectMetadataInput.namePlural,
objectMetadataNameSingular: objectMetadataInput.nameSingular,
workspaceId: objectMetadataInput.workspaceId,
});
const isCustom = !objectMetadataInput.isRemote;
const createdObjectMetadata = await super.createOne({
@ -421,12 +413,55 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
): Promise<ObjectMetadataEntity> {
validateObjectMetadataInputOrThrow(input.update);
const existingObjectMetadata = await this.objectMetadataRepository.findOne({
where: { id: input.id, workspaceId: workspaceId },
});
if (!existingObjectMetadata) {
throw new ObjectMetadataException(
'Object does not exist',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const fullObjectMetadataAfterUpdate = {
...existingObjectMetadata,
...input.update,
};
await this.validatesNoOtherObjectWithSameNameExistsOrThrows({
objectMetadataNameSingular: fullObjectMetadataAfterUpdate.nameSingular,
objectMetadataNamePlural: fullObjectMetadataAfterUpdate.namePlural,
workspaceId: workspaceId,
existingObjectMetadataId: fullObjectMetadataAfterUpdate.id,
});
if (fullObjectMetadataAfterUpdate.shouldSyncLabelAndName) {
validateNameAndLabelAreSyncOrThrow(
fullObjectMetadataAfterUpdate.labelSingular,
fullObjectMetadataAfterUpdate.nameSingular,
);
validateNameAndLabelAreSyncOrThrow(
fullObjectMetadataAfterUpdate.labelPlural,
fullObjectMetadataAfterUpdate.namePlural,
);
}
const updatedObject = await super.updateOne(input.id, input.update);
await this.handleObjectNameAndLabelUpdates(
existingObjectMetadata,
fullObjectMetadataAfterUpdate,
input,
);
if (input.update.isActive !== undefined) {
await this.updateObjectRelationships(input.id, input.update.isActive);
}
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
if (input.update.labelIdentifierFieldMetadataId) {
const labelIdentifierFieldMetadata =
await this.fieldMetadataRepository.findOneByOrFail({
@ -1375,4 +1410,235 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
}),
);
}
private async handleObjectNameAndLabelUpdates(
existingObjectMetadata: ObjectMetadataEntity,
objectMetadataForUpdate: ObjectMetadataEntity,
input: UpdateOneObjectInput,
) {
if (
isDefined(input.update.nameSingular) ||
isDefined(input.update.namePlural)
) {
if (
objectMetadataForUpdate.nameSingular ===
objectMetadataForUpdate.namePlural
) {
throw new ObjectMetadataException(
'The singular and plural name cannot be the same for an object',
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
}
const newTargetTableName = computeObjectTargetTable(
objectMetadataForUpdate,
);
const existingTargetTableName = computeObjectTargetTable(
existingObjectMetadata,
);
if (!(newTargetTableName === existingTargetTableName)) {
await this.createRenameTableMigration(
existingObjectMetadata,
objectMetadataForUpdate,
);
await this.createRelationsUpdatesMigrations(
existingObjectMetadata,
objectMetadataForUpdate,
);
}
if (input.update.labelPlural || input.update.icon) {
if (
!(input.update.labelPlural === existingObjectMetadata.labelPlural) ||
!(input.update.icon === existingObjectMetadata.icon)
) {
await this.updateObjectView(
objectMetadataForUpdate,
objectMetadataForUpdate.workspaceId,
);
}
}
}
private async createRenameTableMigration(
existingObjectMetadata: ObjectMetadataEntity,
objectMetadataForUpdate: ObjectMetadataEntity,
) {
const newTargetTableName = computeObjectTargetTable(
objectMetadataForUpdate,
);
const existingTargetTableName = computeObjectTargetTable(
existingObjectMetadata,
);
this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`rename-${existingObjectMetadata.nameSingular}`),
objectMetadataForUpdate.workspaceId,
[
{
name: existingTargetTableName,
newName: newTargetTableName,
action: WorkspaceMigrationTableActionType.ALTER,
},
],
);
}
private async createRelationsUpdatesMigrations(
existingObjectMetadata: ObjectMetadataEntity,
updatedObjectMetadata: ObjectMetadataEntity,
) {
const existingTableName = computeObjectTargetTable(existingObjectMetadata);
const newTableName = computeObjectTargetTable(updatedObjectMetadata);
if (existingTableName !== newTableName) {
const searchCriteria = {
isCustom: false,
settings: {
isForeignKey: true,
},
name: `${existingObjectMetadata.nameSingular}Id`,
};
const fieldsWihStandardRelation = await this.fieldMetadataRepository.find(
{
where: {
isCustom: false,
settings: {
isForeignKey: true,
},
name: `${existingObjectMetadata.nameSingular}Id`,
},
},
);
await this.fieldMetadataRepository.update(searchCriteria, {
name: `${updatedObjectMetadata.nameSingular}Id`,
});
await Promise.all(
fieldsWihStandardRelation.map(async (fieldWihStandardRelation) => {
const relatedObject = await this.objectMetadataRepository.findOneBy({
id: fieldWihStandardRelation.objectMetadataId,
workspaceId: updatedObjectMetadata.workspaceId,
});
if (relatedObject) {
await this.fieldMetadataRepository.update(
{
name: existingObjectMetadata.nameSingular,
label: existingObjectMetadata.labelSingular,
},
{
name: updatedObjectMetadata.nameSingular,
label: updatedObjectMetadata.labelSingular,
},
);
const relationTableName = computeObjectTargetTable(relatedObject);
const columnName = `${existingObjectMetadata.nameSingular}Id`;
const columnType = fieldMetadataTypeToColumnType(
fieldWihStandardRelation.type,
);
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`rename-${existingObjectMetadata.nameSingular}-to-${updatedObjectMetadata.nameSingular}-in-${relatedObject.nameSingular}`,
),
updatedObjectMetadata.workspaceId,
[
{
name: relationTableName,
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.ALTER,
currentColumnDefinition: {
columnName,
columnType,
isNullable: true,
defaultValue: null,
},
alteredColumnDefinition: {
columnName: `${updatedObjectMetadata.nameSingular}Id`,
columnType,
isNullable: true,
defaultValue: null,
},
},
],
},
],
);
}
}),
);
}
}
private async updateObjectView(
updatedObjectMetadata: ObjectMetadataEntity,
workspaceId: string,
) {
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
await workspaceDataSource?.query(
`UPDATE ${dataSourceMetadata.schema}."view"
SET "name"=$1, "icon"=$2 WHERE "objectMetadataId"=$3 AND "key"=$4`,
[
`All ${updatedObjectMetadata.labelPlural}`,
updatedObjectMetadata.icon,
updatedObjectMetadata.id,
'INDEX',
],
);
}
private validatesNoOtherObjectWithSameNameExistsOrThrows = async ({
objectMetadataNameSingular,
objectMetadataNamePlural,
workspaceId,
existingObjectMetadataId,
}: {
objectMetadataNameSingular: string;
objectMetadataNamePlural: string;
workspaceId: string;
existingObjectMetadataId?: string;
}): Promise<void> => {
const baseWhereConditions = [
{ nameSingular: objectMetadataNameSingular, workspaceId },
{ nameSingular: objectMetadataNamePlural, workspaceId },
{ namePlural: objectMetadataNameSingular, workspaceId },
{ namePlural: objectMetadataNamePlural, workspaceId },
];
const whereConditions = baseWhereConditions.map((condition) => {
return {
...condition,
...(isDefined(existingObjectMetadataId)
? { id: Not(In([existingObjectMetadataId])) }
: {}),
};
});
const objectAlreadyExists = await this.objectMetadataRepository.findOne({
where: whereConditions,
});
if (objectAlreadyExists) {
throw new ObjectMetadataException(
'Object already exists',
ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS,
);
}
};
}

View File

@ -1,3 +1,6 @@
import toCamelCase from 'lodash.camelcase';
import { slugify, transliterate } from 'transliteration';
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import {
@ -40,6 +43,8 @@ const reservedKeywords = [
'addresses',
];
const METADATA_NAME_VALID_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*$/;
export const validateObjectMetadataInputOrThrow = <
T extends UpdateObjectPayload | CreateObjectInput,
>(
@ -58,6 +63,30 @@ export const validateObjectMetadataInputOrThrow = <
validateNameIsNotTooLongThrow(objectMetadataInput.namePlural);
};
export const transliterateAndFormatOrThrow = (string?: string): string => {
if (!string) {
throw new ObjectMetadataException(
'Name is required',
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
let formattedString = string;
if (formattedString.match(METADATA_NAME_VALID_PATTERN) !== null) {
return toCamelCase(formattedString);
}
formattedString = toCamelCase(
slugify(transliterate(formattedString, { trim: true })),
);
if (!formattedString.match(METADATA_NAME_VALID_PATTERN)) {
throw new Error(`"${string}" is not a valid name`);
}
return formattedString;
};
const validateNameIsNotReservedKeywordOrThrow = (name?: string) => {
if (name) {
if (reservedKeywords.includes(name)) {
@ -107,3 +136,9 @@ const validateNameCharactersOrThrow = (name?: string) => {
}
}
};
export const computeMetadataNameFromLabelOrThrow = (label: string): string => {
const formattedString = transliterateAndFormatOrThrow(label);
return formattedString;
};

View File

@ -0,0 +1,19 @@
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { computeMetadataNameFromLabelOrThrow } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
export const validateNameAndLabelAreSyncOrThrow = (
label: string,
name: string,
) => {
const computedName = computeMetadataNameFromLabelOrThrow(label);
if (name !== computedName) {
throw new ObjectMetadataException(
`Name is not synced with label. Expected name: "${computedName}", got ${name}`,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
};