Morph many to one picker (#14155)

This PR follows the multiSelect PR merged previously. It will enable
morph relation Many to One to be handled from the table, using a
singleSelect picker

Main point : I decided to change the singleSelect API to take an array
of **objectMetadataName** instead of only one to deal with both our
usecases.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Guillim
2025-10-07 17:25:11 +02:00
committed by GitHub
parent 9f9c2d85ca
commit f60817a1e1
138 changed files with 2116 additions and 1763 deletions
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import omit from 'lodash.omit';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { v4 } from 'uuid';
@@ -16,7 +16,6 @@ import {
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { computeMorphRelationFieldName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-relation-field-name.util';
import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@@ -75,7 +74,8 @@ export class FieldMetadataMorphRelationService {
const currentMorphRelationFieldName = computeMorphRelationFieldName({
fieldName: fieldMetadataForCreate.name,
relationType: relationCreationPayload.type,
targetObjectMetadata,
targetObjectMetadataNameSingular: targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural: targetObjectMetadata.namePlural,
});
const relationFieldMetadataForCreate =
this.fieldMetadataRelationService.computeCustomRelationFieldMetadataForCreation(
@@ -1,26 +0,0 @@
import { capitalize } from 'twenty-shared/utils';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
type ComputeMorphRelationFieldNameArgs = {
fieldName: string;
relationType: RelationType;
targetObjectMetadata: Pick<
ObjectMetadataEntity,
'nameSingular' | 'namePlural'
>;
};
export const computeMorphRelationFieldName = ({
fieldName,
relationType,
targetObjectMetadata,
}: ComputeMorphRelationFieldNameArgs): string => {
if (relationType === RelationType.MANY_TO_ONE) {
return `${fieldName}${capitalize(targetObjectMetadata.nameSingular)}`;
}
return `${fieldName}${capitalize(targetObjectMetadata.namePlural)}`;
};
@@ -1,13 +1,12 @@
import { t } from '@lingui/core/macro';
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatEntityMaps } from 'src/engine/core-modules/common/types/flat-entity-maps.type';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { computeMorphRelationFieldName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-relation-field-name.util';
import { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { generateMorphOrRelationFlatFieldMetadataPair } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-morph-or-relation-flat-field-metadata-pair.util';
@@ -71,7 +70,8 @@ export const fromMorphRelationCreateFieldInputToFlatFieldMetadatas = async ({
const currentMorphRelationFieldName = computeMorphRelationFieldName({
fieldName: createFieldInput.name,
relationType: relationCreationPayload.type,
targetObjectMetadata: targetFlatObjectMetadata,
targetObjectMetadataNameSingular: targetFlatObjectMetadata.nameSingular,
targetObjectMetadataNamePlural: targetFlatObjectMetadata.namePlural,
});
const sourceFlatObjectMetadataJoinColumnName =
computeMorphOrRelationFieldJoinColumnName({
@@ -0,0 +1,81 @@
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getMorphNameFromMorphFieldMetadataName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-morph-name-from-morph-field-metadata-name.util';
describe('getMorphNameFromMorphFieldMetadataName', () => {
const createMorphRelationFlatFieldMetadataMock = (
name: string,
relationType: RelationType,
) =>
({
name,
type: FieldMetadataType.MORPH_RELATION,
label: name,
settings: { relationType },
}) as unknown as FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>;
it('should extract base morph field name for MANY_TO_ONE (removes singular suffix)', () => {
const morphRelationFlatFieldMetadata =
createMorphRelationFlatFieldMetadataMock(
'contactDeal',
RelationType.MANY_TO_ONE,
);
const result = getMorphNameFromMorphFieldMetadataName({
morphRelationFlatFieldMetadata,
nameSingular: 'Deal',
namePlural: 'Deals',
});
expect(result).toBe('contact');
});
it('should also work extract base morph field name for MANY_TO_ONE if casing is not correct', () => {
const morphRelationFlatFieldMetadata =
createMorphRelationFlatFieldMetadataMock(
'contactDeal',
RelationType.MANY_TO_ONE,
);
const result = getMorphNameFromMorphFieldMetadataName({
morphRelationFlatFieldMetadata,
nameSingular: 'deal',
namePlural: 'deals',
});
expect(result).toBe('contact');
});
it('should extract base morph field name for ONE_TO_MANY (removes plural suffix)', () => {
const morphRelationFlatFieldMetadata =
createMorphRelationFlatFieldMetadataMock(
'contactDeals',
RelationType.ONE_TO_MANY,
);
const result = getMorphNameFromMorphFieldMetadataName({
morphRelationFlatFieldMetadata,
nameSingular: 'Deal',
namePlural: 'Deals',
});
expect(result).toBe('contact');
});
it('should return the original name when suffix does not match', () => {
const morphRelationFlatFieldMetadata =
createMorphRelationFlatFieldMetadataMock(
'randomName',
RelationType.MANY_TO_ONE,
);
const result = getMorphNameFromMorphFieldMetadataName({
morphRelationFlatFieldMetadata,
nameSingular: 'account',
namePlural: 'accounts',
});
expect(result).toBe('randomName');
});
});
@@ -0,0 +1,30 @@
import { type FieldMetadataType, RelationType } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { searchAndReplaceLast } from 'src/engine/metadata-modules/flat-object-metadata/utils/search-and-replace-last.util';
export const getMorphNameFromMorphFieldMetadataName = ({
morphRelationFlatFieldMetadata,
nameSingular,
namePlural,
}: {
morphRelationFlatFieldMetadata: Pick<
FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>,
'name' | 'settings'
>;
nameSingular: string;
namePlural: string;
}): string => {
const isManyToOneRelationType =
morphRelationFlatFieldMetadata.settings.relationType ===
RelationType.MANY_TO_ONE;
return searchAndReplaceLast({
source: morphRelationFlatFieldMetadata.name,
replace: '',
search: isManyToOneRelationType
? capitalize(nameSingular)
: capitalize(namePlural),
});
};
@@ -3,34 +3,15 @@ import {
type FieldMetadataType,
type FromTo,
} from 'twenty-shared/types';
import { computeMorphRelationFieldName } from 'twenty-shared/utils';
import { type AllFlatEntityMaps } from 'src/engine/core-modules/common/types/all-flat-entity-maps.type';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { computeMorphRelationFieldName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-relation-field-name.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { findObjectFlatFieldMetadatasOrThrow } from 'src/engine/metadata-modules/flat-field-metadata/utils/find-object-fields-in-flat-field-metadata-maps-or-throw.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { getFlatObjectMetadataTargetMorphRelationFlatFieldMetadatasOrThrow } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-flat-object-metadata-many-to-one-target-morph-relation-flat-field-metadatas-or-throw.util';
const searchAndReplaceLast = ({
replace,
search,
source,
}: {
source: string;
search: string;
replace: string;
}) => {
const lastIndex = source.lastIndexOf(search);
if (lastIndex === -1) return source;
return (
source.slice(0, lastIndex) +
replace +
source.slice(lastIndex + search.length)
);
};
import { getMorphNameFromMorphFieldMetadataName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-morph-name-from-morph-field-metadata-name.util';
type RenameRelatedMorphFieldOnObjectNamesUpdateArgs = FromTo<
FlatObjectMetadata,
@@ -59,17 +40,18 @@ export const renameRelatedMorphFieldOnObjectNamesUpdate = ({
const isManyToOneRelationType =
morphRelationFlatFieldMetadata.settings.relationType ===
RelationType.MANY_TO_ONE;
const initialMorphRelationFieldName = searchAndReplaceLast({
source: morphRelationFlatFieldMetadata.name,
replace: '',
search: isManyToOneRelationType
? fromFlatObjectMetadata.nameSingular
: fromFlatObjectMetadata.namePlural,
});
const initialMorphRelationFieldName =
getMorphNameFromMorphFieldMetadataName({
morphRelationFlatFieldMetadata,
nameSingular: fromFlatObjectMetadata.nameSingular,
namePlural: fromFlatObjectMetadata.namePlural,
});
const newMorphFieldName = computeMorphRelationFieldName({
fieldName: initialMorphRelationFieldName,
relationType: morphRelationFlatFieldMetadata.settings.relationType,
targetObjectMetadata: toFlatObjectMetadata,
targetObjectMetadataNameSingular: toFlatObjectMetadata.nameSingular,
targetObjectMetadataNamePlural: toFlatObjectMetadata.namePlural,
});
const newJoinColumnName = isManyToOneRelationType
? computeMorphOrRelationFieldJoinColumnName({
@@ -0,0 +1,19 @@
export const searchAndReplaceLast = ({
replace,
search,
source,
}: {
source: string;
search: string;
replace: string;
}) => {
const lastIndex = source.lastIndexOf(search);
if (lastIndex === -1) return source;
return (
source.slice(0, lastIndex) +
replace +
source.slice(lastIndex + search.length)
);
};