fix(front): dedupe morph relation fields in view field pickers (#21580)
## Issue Reported in quality-feedbacks: **"Issues with morph relation view field"** — a morph relation column added to a view **disappears after refresh** (and can be added several times). ## Root cause — the SSE metadata sync A morph relation is stored as **one `fieldMetadata` row per target object**, all sharing a `morphId`. Collapsing those rows into the single field that represents the relation is a **read-time projection** in the server's `objects.fieldsList` resolver — it is *not* a storage invariant, and the rows are never merged. The frontend metadata store is kept in sync with the raw rows **one row at a time over SSE** (`MetadataStoreSSEEffect`): every metadata change broadcasts a single created/updated record that's pushed straight into the store. Creating a morph relation creates N rows (one per target), so **N `create` events arrive and N raw sub-fields land in the store — bypassing the `fieldsList` projection entirely.** The view-field pickers read straight from that store, so they saw the morph relation **once per target**. Each could be added as a column referencing a different sub-field id; after a refresh the view reloads from the projected (deduped) data, the non-survivor columns no longer resolve, and they disappear. ## Fix & architecture note Because the store deliberately mirrors raw rows (that's what the SSE sync maintains), the fix applies the **same read-time projection on the client** — deduping morph rows by `morphId` in `useActiveFieldMetadataItems` — rather than filtering rows at each insert path (SSE, optimistic create, …). This matches how the backend already models morph fields and is robust regardless of which path delivered the rows. The survivor-selection rule (which sub-field id represents the relation) now lives in `twenty-shared` (`pickMorphGroupSurvivor`) so client and server can't drift.
This commit is contained in:
+11
-8
@@ -1,5 +1,6 @@
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { dedupeMorphRelationFieldMetadataItems } from '@/object-metadata/utils/dedupeMorphRelationFieldMetadataItems';
|
||||
import { isActiveFieldMetadataItem } from '@/object-metadata/utils/isActiveFieldMetadataItem';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@@ -11,14 +12,16 @@ export const useActiveFieldMetadataItems = ({
|
||||
const activeFieldMetadataItems = useMemo(
|
||||
() =>
|
||||
isDefined(objectMetadataItem)
|
||||
? objectMetadataItem.readableFields.filter(
|
||||
({ id, isActive, isSystem, name }) =>
|
||||
isActiveFieldMetadataItem({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
fieldMetadata: { isActive, isSystem, name },
|
||||
}) ||
|
||||
// Allow label identifier field even if it's a system field
|
||||
id === objectMetadataItem.labelIdentifierFieldMetadataId,
|
||||
? dedupeMorphRelationFieldMetadataItems(
|
||||
objectMetadataItem.readableFields.filter(
|
||||
({ id, isActive, isSystem, name }) =>
|
||||
isActiveFieldMetadataItem({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
fieldMetadata: { isActive, isSystem, name },
|
||||
}) ||
|
||||
// Allow label identifier field even if it's a system field
|
||||
id === objectMetadataItem.labelIdentifierFieldMetadataId,
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[objectMetadataItem],
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { dedupeMorphRelationFieldMetadataItems } from '@/object-metadata/utils/dedupeMorphRelationFieldMetadataItems';
|
||||
|
||||
const buildField = (
|
||||
field: Partial<FieldMetadataItem> & Pick<FieldMetadataItem, 'id' | 'type'>,
|
||||
): FieldMetadataItem =>
|
||||
({
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
morphId: null,
|
||||
...field,
|
||||
}) as FieldMetadataItem;
|
||||
|
||||
describe('dedupeMorphRelationFieldMetadataItems', () => {
|
||||
it('should keep non-morph fields untouched', () => {
|
||||
const fields = [
|
||||
buildField({ id: '1', type: FieldMetadataType.TEXT }),
|
||||
buildField({ id: '2', type: FieldMetadataType.NUMBER }),
|
||||
];
|
||||
|
||||
expect(dedupeMorphRelationFieldMetadataItems(fields)).toEqual(fields);
|
||||
});
|
||||
|
||||
it('should keep a single field per morphId', () => {
|
||||
const fields = [
|
||||
buildField({
|
||||
id: 'b',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: 'morph-1',
|
||||
}),
|
||||
buildField({
|
||||
id: 'a',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: 'morph-1',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = dedupeMorphRelationFieldMetadataItems(fields);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('a');
|
||||
});
|
||||
|
||||
it('should preserve the position of the surviving morph field', () => {
|
||||
const fields = [
|
||||
buildField({ id: 'name', type: FieldMetadataType.TEXT }),
|
||||
buildField({
|
||||
id: 'a',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: 'morph-1',
|
||||
}),
|
||||
buildField({
|
||||
id: 'z',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: 'morph-1',
|
||||
}),
|
||||
buildField({ id: 'tag', type: FieldMetadataType.TEXT }),
|
||||
];
|
||||
|
||||
const result = dedupeMorphRelationFieldMetadataItems(fields);
|
||||
|
||||
expect(result.map((field) => field.id)).toEqual(['name', 'a', 'tag']);
|
||||
});
|
||||
|
||||
it('should prefer active non-system fields over system ones', () => {
|
||||
const fields = [
|
||||
buildField({
|
||||
id: 'a',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: 'morph-1',
|
||||
isSystem: true,
|
||||
}),
|
||||
buildField({
|
||||
id: 'z',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: 'morph-1',
|
||||
isSystem: false,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = dedupeMorphRelationFieldMetadataItems(fields);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('z');
|
||||
});
|
||||
|
||||
it('should keep the active morph field over an inactive one', () => {
|
||||
const fields = [
|
||||
buildField({
|
||||
id: 'a',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: 'morph-1',
|
||||
isActive: false,
|
||||
}),
|
||||
buildField({
|
||||
id: 'z',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: 'morph-1',
|
||||
isActive: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = dedupeMorphRelationFieldMetadataItems(fields);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('z');
|
||||
});
|
||||
|
||||
it('should dedupe each morphId independently', () => {
|
||||
const fields = [
|
||||
buildField({
|
||||
id: 'a1',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: 'morph-1',
|
||||
}),
|
||||
buildField({
|
||||
id: 'a2',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: 'morph-1',
|
||||
}),
|
||||
buildField({
|
||||
id: 'b1',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: 'morph-2',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = dedupeMorphRelationFieldMetadataItems(fields);
|
||||
|
||||
expect(result.map((field) => field.id).sort()).toEqual(['a1', 'b1']);
|
||||
});
|
||||
|
||||
it('should keep morph fields without a morphId', () => {
|
||||
const fields = [
|
||||
buildField({
|
||||
id: 'a',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
morphId: null,
|
||||
}),
|
||||
];
|
||||
|
||||
expect(dedupeMorphRelationFieldMetadataItems(fields)).toEqual(fields);
|
||||
});
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined, pickMorphGroupSurvivorOrThrow } from 'twenty-shared/utils';
|
||||
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
|
||||
export const dedupeMorphRelationFieldMetadataItems = (
|
||||
fieldMetadataItems: FieldMetadataItem[],
|
||||
): FieldMetadataItem[] => {
|
||||
const morphGroupsByMorphId = new Map<string, FieldMetadataItem[]>();
|
||||
|
||||
for (const fieldMetadataItem of fieldMetadataItems) {
|
||||
if (
|
||||
fieldMetadataItem.type !== FieldMetadataType.MORPH_RELATION ||
|
||||
!isDefined(fieldMetadataItem.morphId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = morphGroupsByMorphId.get(fieldMetadataItem.morphId) ?? [];
|
||||
|
||||
group.push(fieldMetadataItem);
|
||||
morphGroupsByMorphId.set(fieldMetadataItem.morphId, group);
|
||||
}
|
||||
|
||||
const survivorIdByMorphId = new Map<string, string>();
|
||||
|
||||
for (const [morphId, group] of morphGroupsByMorphId) {
|
||||
survivorIdByMorphId.set(morphId, pickMorphGroupSurvivorOrThrow(group).id);
|
||||
}
|
||||
|
||||
return fieldMetadataItems.filter(
|
||||
(fieldMetadataItem) =>
|
||||
fieldMetadataItem.type !== FieldMetadataType.MORPH_RELATION ||
|
||||
!isDefined(fieldMetadataItem.morphId) ||
|
||||
survivorIdByMorphId.get(fieldMetadataItem.morphId) ===
|
||||
fieldMetadataItem.id,
|
||||
);
|
||||
};
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { pickMorphGroupSurvivor } from 'src/engine/dataloaders/utils/pick-morph-group-survivor.util';
|
||||
|
||||
const makeMorphField = (
|
||||
overrides: Partial<FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>> & {
|
||||
id: string;
|
||||
},
|
||||
): FlatFieldMetadata<FieldMetadataType.MORPH_RELATION> =>
|
||||
({
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
morphId: 'morph-1',
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>;
|
||||
|
||||
describe('pickMorphGroupSurvivor', () => {
|
||||
it('should return the only field when group has one element', () => {
|
||||
const field = makeMorphField({ id: 'a' });
|
||||
|
||||
expect(pickMorphGroupSurvivor([field])).toBe(field);
|
||||
});
|
||||
|
||||
it('should prefer active non-system over active system', () => {
|
||||
const standard = makeMorphField({
|
||||
id: 'b',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
});
|
||||
const system = makeMorphField({
|
||||
id: 'a',
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
});
|
||||
|
||||
expect(pickMorphGroupSurvivor([system, standard])).toBe(standard);
|
||||
});
|
||||
|
||||
it('should prefer active over inactive', () => {
|
||||
const active = makeMorphField({
|
||||
id: 'b',
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
});
|
||||
const inactive = makeMorphField({
|
||||
id: 'a',
|
||||
isActive: false,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
expect(pickMorphGroupSurvivor([inactive, active])).toBe(active);
|
||||
});
|
||||
|
||||
it('should break ties by smallest id', () => {
|
||||
const fieldA = makeMorphField({
|
||||
id: 'aaa',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
});
|
||||
const fieldB = makeMorphField({
|
||||
id: 'bbb',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
expect(pickMorphGroupSurvivor([fieldB, fieldA])).toBe(fieldA);
|
||||
});
|
||||
|
||||
it('should prefer active+non-system (score 3) over inactive+non-system (score 1)', () => {
|
||||
const best = makeMorphField({
|
||||
id: 'z',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
});
|
||||
const worse = makeMorphField({
|
||||
id: 'a',
|
||||
isActive: false,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
expect(pickMorphGroupSurvivor([worse, best])).toBe(best);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { pickMorphGroupSurvivorOrThrow } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { pickMorphGroupSurvivor } from 'src/engine/dataloaders/utils/pick-morph-group-survivor.util';
|
||||
|
||||
export const filterMorphRelationDuplicateFields = (
|
||||
flatFieldMetadatas: FlatFieldMetadata[],
|
||||
@@ -36,7 +36,7 @@ export const filterMorphRelationDuplicateFields = (
|
||||
[];
|
||||
|
||||
for (const group of morphGroupsByMorphId.values()) {
|
||||
filteredMorphFlatFieldMetadatas.push(pickMorphGroupSurvivor(group));
|
||||
filteredMorphFlatFieldMetadatas.push(pickMorphGroupSurvivorOrThrow(group));
|
||||
}
|
||||
|
||||
return [...otherFlatFieldMetadatas, ...filteredMorphFlatFieldMetadatas];
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { type FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
// Prefers active non-system fields (standard targets) over system ones
|
||||
// (auto-created for custom objects). Smallest id breaks ties.
|
||||
const scoreMorphField = (
|
||||
field: FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>,
|
||||
): number => (field.isActive ? 2 : 0) + (field.isSystem ? 0 : 1);
|
||||
|
||||
export const pickMorphGroupSurvivor = (
|
||||
group: FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>[],
|
||||
): FlatFieldMetadata<FieldMetadataType.MORPH_RELATION> => {
|
||||
return group.reduce((best, current) => {
|
||||
const diff = scoreMorphField(current) - scoreMorphField(best);
|
||||
|
||||
return diff > 0 || (diff === 0 && current.id < best.id) ? current : best;
|
||||
});
|
||||
};
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { pickMorphGroupSurvivorOrThrow } from 'twenty-shared/utils';
|
||||
|
||||
import { pickMorphGroupSurvivor } from 'src/engine/dataloaders/utils/pick-morph-group-survivor.util';
|
||||
import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
@@ -64,7 +64,7 @@ export const resolveRelationFromFlatFieldMetadata = ({
|
||||
}),
|
||||
];
|
||||
|
||||
const survivorMorphField = pickMorphGroupSurvivor(
|
||||
const survivorMorphField = pickMorphGroupSurvivorOrThrow(
|
||||
allMorphFlatFieldMetadatas,
|
||||
);
|
||||
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { pickMorphGroupSurvivorOrThrow } from '@/utils/fieldMetadata/pick-morph-group-survivor-or-throw';
|
||||
|
||||
const makeMorphField = (overrides: {
|
||||
id: string;
|
||||
isActive?: boolean;
|
||||
isSystem?: boolean;
|
||||
}) => ({
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('pickMorphGroupSurvivorOrThrow', () => {
|
||||
it('should return the only field when group has one element', () => {
|
||||
const field = makeMorphField({ id: 'a' });
|
||||
|
||||
expect(pickMorphGroupSurvivorOrThrow([field])).toBe(field);
|
||||
});
|
||||
|
||||
it('should prefer active non-system over active system', () => {
|
||||
const standard = makeMorphField({
|
||||
id: 'b',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
});
|
||||
const system = makeMorphField({ id: 'a', isActive: true, isSystem: true });
|
||||
|
||||
expect(pickMorphGroupSurvivorOrThrow([system, standard])).toBe(standard);
|
||||
});
|
||||
|
||||
it('should prefer active over inactive', () => {
|
||||
const active = makeMorphField({ id: 'b', isActive: true, isSystem: true });
|
||||
const inactive = makeMorphField({
|
||||
id: 'a',
|
||||
isActive: false,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
expect(pickMorphGroupSurvivorOrThrow([inactive, active])).toBe(active);
|
||||
});
|
||||
|
||||
it('should break ties by smallest id', () => {
|
||||
const fieldA = makeMorphField({ id: 'aaa' });
|
||||
const fieldB = makeMorphField({ id: 'bbb' });
|
||||
|
||||
expect(pickMorphGroupSurvivorOrThrow([fieldB, fieldA])).toBe(fieldA);
|
||||
});
|
||||
|
||||
it('should treat nullish isActive/isSystem as falsy', () => {
|
||||
const nullishField = { id: 'a', isActive: null, isSystem: null };
|
||||
const activeField = makeMorphField({ id: 'b', isActive: true });
|
||||
|
||||
expect(pickMorphGroupSurvivorOrThrow([nullishField, activeField])).toBe(
|
||||
activeField,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on an empty group', () => {
|
||||
expect(() => pickMorphGroupSurvivorOrThrow([])).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CustomError } from '@/utils/errors';
|
||||
|
||||
type MorphGroupSurvivorCandidate = {
|
||||
id: string;
|
||||
isActive?: boolean | null;
|
||||
isSystem?: boolean | null;
|
||||
};
|
||||
|
||||
const scoreMorphField = (field: MorphGroupSurvivorCandidate): number =>
|
||||
(field.isActive ? 2 : 0) + (field.isSystem ? 0 : 1);
|
||||
|
||||
export const pickMorphGroupSurvivorOrThrow = <
|
||||
T extends MorphGroupSurvivorCandidate,
|
||||
>(
|
||||
group: T[],
|
||||
): T => {
|
||||
if (group.length === 0) {
|
||||
throw new CustomError(
|
||||
'pickMorphGroupSurvivorOrThrow requires a non-empty morph group',
|
||||
'EMPTY_MORPH_GROUP',
|
||||
);
|
||||
}
|
||||
|
||||
return group.reduce((best, current) => {
|
||||
const scoreDifference = scoreMorphField(current) - scoreMorphField(best);
|
||||
|
||||
return scoreDifference > 0 ||
|
||||
(scoreDifference === 0 && current.id < best.id)
|
||||
? current
|
||||
: best;
|
||||
});
|
||||
};
|
||||
@@ -58,6 +58,7 @@ export { isFieldMetadataNumericKind } from './fieldMetadata/isFieldMetadataNumer
|
||||
export { isFieldMetadataSelectKind } from './fieldMetadata/isFieldMetadataSelectKind';
|
||||
export { isFieldMetadataSupportedInGroupBy } from './fieldMetadata/isFieldMetadataSupportedInGroupBy';
|
||||
export { isFieldMetadataTextKind } from './fieldMetadata/isFieldMetadataTextKind';
|
||||
export { pickMorphGroupSurvivorOrThrow } from './fieldMetadata/pick-morph-group-survivor-or-throw';
|
||||
export { shouldExcludeFieldFromAgentToolSchema } from './fieldMetadata/shouldExcludeFieldFromAgentToolSchema';
|
||||
export { extractFolderPathFilenameAndTypeOrThrow } from './files/extractFolderPathFilenameAndTypeOrThrow.util';
|
||||
export { checkIfShouldComputeEmptinessFilter } from './filter/checkIfShouldComputeEmptinessFilter';
|
||||
|
||||
Reference in New Issue
Block a user