diff --git a/packages/twenty-sdk/src/cli/commands/dev/add.ts b/packages/twenty-sdk/src/cli/commands/dev/add.ts index 2842477e71..f4da9982a0 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/add.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/add.ts @@ -3,7 +3,12 @@ import inquirer from 'inquirer'; import { writeFile } from 'node:fs/promises'; import { join, relative } from 'path'; import { SyncableEntity } from 'twenty-shared/application'; -import { FieldMetadataType, ViewType } from 'twenty-shared/types'; +import { + FieldMetadataType, + RelationOnDeleteAction, + RelationType, + ViewType, +} from 'twenty-shared/types'; import { assertUnreachable } from 'twenty-shared/utils'; import { v4 } from 'uuid'; @@ -559,12 +564,20 @@ export class EntityAddCommand { } private async getFieldData() { + const isRelationFieldType = (type: FieldMetadataType | undefined) => + type === FieldMetadataType.RELATION || + type === FieldMetadataType.MORPH_RELATION; + return inquirer.prompt<{ name: string; label: string; type: FieldMetadataType; objectUniversalIdentifier: string; description: string; + relationTargetObjectMetadataUniversalIdentifier?: string; + relationTargetFieldMetadataUniversalIdentifier?: string; + relationType?: RelationType; + onDelete?: RelationOnDeleteAction; }>([ { type: 'input', @@ -618,6 +631,50 @@ export class EntityAddCommand { message: 'Enter a description for your field (optional):', default: '', }, + { + type: 'input', + name: 'relationTargetObjectMetadataUniversalIdentifier', + message: + 'Enter the universalIdentifier of the target object of the relation:', + default: 'fill-later', + when: (answers) => isRelationFieldType(answers.type), + validate: (input: string) => { + if (!input || input.trim().length === 0) { + return 'Please enter a non empty string'; + } + return true; + }, + }, + { + type: 'input', + name: 'relationTargetFieldMetadataUniversalIdentifier', + message: + 'Enter the universalIdentifier of the target field on the related object:', + default: 'fill-later', + when: (answers) => isRelationFieldType(answers.type), + validate: (input: string) => { + if (!input || input.trim().length === 0) { + return 'Please enter a non empty string'; + } + return true; + }, + }, + { + type: 'select', + name: 'relationType', + message: 'Select the relation type:', + choices: Object.values(RelationType), + default: RelationType.ONE_TO_MANY, + when: (answers) => isRelationFieldType(answers.type), + }, + { + type: 'select', + name: 'onDelete', + message: 'Select the onDelete behavior:', + choices: Object.values(RelationOnDeleteAction), + default: RelationOnDeleteAction.CASCADE, + when: (answers) => isRelationFieldType(answers.type), + }, ]); } diff --git a/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-field-base-file.spec.ts b/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-field-base-file.spec.ts index 76d34d7c00..02e27a2b93 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-field-base-file.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-field-base-file.spec.ts @@ -97,4 +97,99 @@ describe('getFieldBaseFile', () => { expect(uuid2).toBeDefined(); expect(uuid1).not.toBe(uuid2); }); + + it('should render relation properties for a RELATION field', () => { + const result = getFieldBaseFile({ + data: { + name: 'company', + label: 'Company', + type: 'RELATION' as any, + objectUniversalIdentifier: 'obj-1', + relationTargetObjectMetadataUniversalIdentifier: 'target-obj', + relationTargetFieldMetadataUniversalIdentifier: 'target-field', + relationType: 'ONE_TO_MANY' as any, + }, + name: 'company', + }); + + expect(result).toContain( + "import { defineField, FieldType, RelationType } from 'twenty-sdk/define';", + ); + expect(result).toContain('type: FieldType.RELATION'); + expect(result).toContain( + "relationTargetObjectMetadataUniversalIdentifier: 'target-obj'", + ); + expect(result).toContain( + "relationTargetFieldMetadataUniversalIdentifier: 'target-field'", + ); + expect(result).toContain( + 'universalSettings: { relationType: RelationType.ONE_TO_MANY }', + ); + expect(result).not.toContain('morphId'); + }); + + it('should include onDelete when provided for a RELATION field', () => { + const result = getFieldBaseFile({ + data: { + name: 'company', + label: 'Company', + type: 'RELATION' as any, + objectUniversalIdentifier: 'obj-1', + relationTargetObjectMetadataUniversalIdentifier: 'target-obj', + relationTargetFieldMetadataUniversalIdentifier: 'target-field', + relationType: 'ONE_TO_MANY' as any, + onDelete: 'CASCADE' as any, + }, + name: 'company', + }); + + expect(result).toContain( + "import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';", + ); + expect(result).toContain( + 'universalSettings: { relationType: RelationType.ONE_TO_MANY, onDelete: OnDeleteAction.CASCADE }', + ); + }); + + it('should omit onDelete when set to None for a RELATION field', () => { + const result = getFieldBaseFile({ + data: { + name: 'company', + label: 'Company', + type: 'RELATION' as any, + objectUniversalIdentifier: 'obj-1', + relationTargetObjectMetadataUniversalIdentifier: 'target-obj', + relationTargetFieldMetadataUniversalIdentifier: 'target-field', + relationType: 'ONE_TO_MANY' as any, + onDelete: 'None', + }, + name: 'company', + }); + + expect(result).not.toContain('onDelete'); + expect(result).not.toContain('OnDeleteAction'); + }); + + it('should render a generated morphId for a MORPH_RELATION field', () => { + const result = getFieldBaseFile({ + data: { + name: 'target', + label: 'Target', + type: 'MORPH_RELATION' as any, + objectUniversalIdentifier: 'obj-1', + relationTargetObjectMetadataUniversalIdentifier: 'target-obj', + relationTargetFieldMetadataUniversalIdentifier: 'target-field', + relationType: 'MANY_TO_ONE' as any, + }, + name: 'target', + }); + + expect(result).toContain('type: FieldType.MORPH_RELATION'); + expect(result).toMatch( + /morphId: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/, + ); + expect(result).toContain( + 'universalSettings: { relationType: RelationType.MANY_TO_ONE }', + ); + }); }); diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-field-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-field-template.ts index b0b15cfa75..dd4cf8a62c 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/entity-field-template.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-field-template.ts @@ -1,4 +1,8 @@ -import { type FieldMetadataType } from 'twenty-shared/types'; +import { + FieldMetadataType, + type RelationOnDeleteAction, + type RelationType, +} from 'twenty-shared/types'; import { v4 } from 'uuid'; export const getFieldBaseFile = ({ @@ -10,6 +14,10 @@ export const getFieldBaseFile = ({ type: FieldMetadataType; objectUniversalIdentifier: string; description?: string; + relationTargetObjectMetadataUniversalIdentifier?: string; + relationTargetFieldMetadataUniversalIdentifier?: string; + relationType?: RelationType; + onDelete?: RelationOnDeleteAction | 'None'; }; name: string; }) => { @@ -18,6 +26,38 @@ export const getFieldBaseFile = ({ ? `\n description: '${data.description}',` : ''; + const isRelation = + data.type === FieldMetadataType.RELATION || + data.type === FieldMetadataType.MORPH_RELATION; + + if (isRelation) { + const hasOnDelete = data.onDelete && data.onDelete !== 'None'; + const importLine = `import { defineField, FieldType, RelationType${ + hasOnDelete ? ', OnDeleteAction' : '' + } } from 'twenty-sdk/define';`; + const onDeleteSetting = hasOnDelete + ? `, onDelete: OnDeleteAction.${data.onDelete}` + : ''; + const morphIdLine = + data.type === FieldMetadataType.MORPH_RELATION + ? `\n morphId: '${v4()}',` + : ''; + + return `${importLine} + +export default defineField({ + universalIdentifier: '${universalIdentifier}', + name: '${data.name}', + label: '${data.label}', + type: FieldType.${data.type}, + objectUniversalIdentifier: '${data.objectUniversalIdentifier}', + relationTargetObjectMetadataUniversalIdentifier: '${data.relationTargetObjectMetadataUniversalIdentifier}', + relationTargetFieldMetadataUniversalIdentifier: '${data.relationTargetFieldMetadataUniversalIdentifier}', + universalSettings: { relationType: RelationType.${data.relationType}${onDeleteSetting} },${morphIdLine}${descriptionLine} +}); +`; + } + return `import { defineField, FieldType } from 'twenty-sdk/define'; export default defineField({