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:
+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