Enrich app:add field relation and morph relations (#21368)

# Before

<img width="833" height="358" alt="image"
src="https://github.com/user-attachments/assets/e006bc5b-79e8-449f-beba-eb75393a9c60"
/>


# After

<img width="1015" height="383" alt="image"
src="https://github.com/user-attachments/assets/586f19df-e43b-45bc-9b26-16573108e750"
/>

<img width="1061" height="413" alt="image"
src="https://github.com/user-attachments/assets/962635fa-6cdf-4aef-a1ea-cd129c5ff656"
/>
This commit is contained in:
martmull
2026-06-09 16:20:41 +02:00
committed by GitHub
parent 9c66975520
commit c5e95c6649
3 changed files with 194 additions and 2 deletions
@@ -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),
},
]);
}
@@ -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 }',
);
});
});
@@ -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({