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:
+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,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user