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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user