fix: show relation field changes in the timeline (#21052)

## Summary
- Fixes #20970 
- When we changed only a relation field (e.g. company on a person):
- The diff builder skipped all RELATION fields -> empty diff -> no
timeline row.
- If we change company and something else, only the scalar field
appeared; the company change was missing.
- Even with a diff, the UI validated keys by field name (`company`)
while join columns (`companyId`) could be filtered out.

## Solution
- For `MANY_TO_ONE`, compare join column values (`companyId`) and store
the diff under the relation field name (`company`):
```
"company": {
  "before": { "id": "<old-id>" },
  "after": { "id": "<new-id>" }
}
```
- Frontend
- `filterOutInvalidTimelineActivities`: resolve diff keys by field name
or join column via `findFieldMetadataItemByDiffKey`, so relation diffs
are not stripped.
- `EventRelationFieldDiffValues`: resolve related record labels by id;
show only the new value in the row (same pattern as other fields:
Company -> airSlate).
- Tooltip (relations only): on hover, show before -> after with readable
names (e.g. Microsoft -> Apple). Scalar and composite fields (e.g.
Updated by) are unchanged and do not get this tooltip.
- `EventFieldDiff`: route RELATION diffs to the relation renderer; all
other field types keep the existing FieldDisplay behavior.

### What you’ll see
On a person (or opportunity) timeline after changing company:
```
You updated Company → airSlate
(tooltip: Microsoft → Apple)
```

## Test plan
- Change only company on a person -> timeline shows a company update
with names.
- Change company and name in one save -> both appear in the diff.
- Clear company -> row shows Empty; tooltip reflects previous -> empty
if applicable.
- Same we can do for the Opportunities also
- Scalar / ACTOR fields (e.g. Updated by) - no new tooltip; display
unchanged.

## Screenshots

### Before
<img width="527" height="124" alt="Screenshot 2026-05-29 155123"
src="https://github.com/user-attachments/assets/e067f19a-8184-4e50-9cd0-9135e06188b8"
/>
<br><br>
<img width="535" height="181" alt="Screenshot 2026-05-29 155149"
src="https://github.com/user-attachments/assets/3c513a1e-c5c5-4cff-ae1c-5fed62837798"
/>
<br><br>

### After
<img width="564" height="200" alt="Screenshot 2026-06-01 154539"
src="https://github.com/user-attachments/assets/459ad6b4-af4c-4f7a-b749-30762c979627"
/>
<img width="567" height="187" alt="Screenshot 2026-06-01 154555"
src="https://github.com/user-attachments/assets/6bc4711c-afca-43d8-b874-f51fc0f374df"
/>
<img width="563" height="188" alt="Screenshot 2026-06-01 154639"
src="https://github.com/user-attachments/assets/f275b9fd-a1c3-4c4b-9538-8042657eb593"
/>
<img width="556" height="180" alt="Screenshot 2026-06-01 154654"
src="https://github.com/user-attachments/assets/2e614368-8fd7-4c73-8876-2223f2f98e67"
/>
<img width="560" height="237" alt="Screenshot 2026-06-01 154802"
src="https://github.com/user-attachments/assets/0437aef4-7b2c-4d35-a2e9-f617b90a1beb"
/>

---------

Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com>
Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
Parship Chowdhury
2026-06-01 20:04:54 +05:30
committed by GitHub
parent 627b488556
commit ba493e5a23
9 changed files with 591 additions and 56 deletions
@@ -1,32 +1,77 @@
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { fastDeepEqual } from 'twenty-shared/utils';
import {
FieldMetadataType,
RelationType,
type ObjectRecord,
} from 'twenty-shared/types';
import { fastDeepEqual, isDefined } from 'twenty-shared/utils';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export type RelationFieldChangeValue = {
id: string | null;
};
const buildRelationFieldChangeValue = (
relationId: string | null | undefined,
): RelationFieldChangeValue => ({
id: isDefined(relationId) ? relationId : null,
});
const isManyToOneRelationField = (
field: FlatFieldMetadata,
): field is FlatFieldMetadata<FieldMetadataType.RELATION> => {
return (
isFlatFieldMetadataOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE
);
};
const getJoinColumnNameForRelationField = (
field: FlatFieldMetadata<FieldMetadataType.RELATION>,
) => {
return (
field.settings?.joinColumnName ??
computeMorphOrRelationFieldJoinColumnName({
name: field.name,
})
);
};
export const objectRecordChangedValues = (
oldRecord: Partial<ObjectRecord>,
newRecord: Partial<ObjectRecord>,
objectMetadataItem: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) => {
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
objectMetadataItem,
);
const { fieldIdByName, fieldIdByJoinColumnName } =
buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
objectMetadataItem,
);
return Object.keys(newRecord).reduce(
(acc, key) => {
const fieldId = fieldIdByName[key];
const field = fieldId
? findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldId,
flatEntityMaps: flatFieldMetadataMaps,
})
: undefined;
const findFieldForKey = (key: string) => {
const fieldId = fieldIdByName[key] ?? fieldIdByJoinColumnName[key];
if (!isDefined(fieldId)) {
return undefined;
}
return findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldId,
flatEntityMaps: flatFieldMetadataMaps,
});
};
const accumulator = Object.keys(newRecord).reduce(
(diffAccumulator, key) => {
const field = findFieldForKey(key);
const oldRecordValue = oldRecord[key];
const newRecordValue = newRecord[key];
@@ -34,22 +79,57 @@ export const objectRecordChangedValues = (
if (
key === 'updatedAt' ||
key === 'searchVector' ||
field?.type === FieldMetadataType.POSITION ||
(isDefined(field) && isManyToOneRelationField(field)) ||
field?.type === FieldMetadataType.RELATION ||
field?.type === FieldMetadataType.POSITION
field?.type === FieldMetadataType.MORPH_RELATION
) {
return acc;
return diffAccumulator;
}
if (fastDeepEqual(oldRecordValue, newRecordValue)) {
return acc;
return diffAccumulator;
}
acc[key] = { before: oldRecordValue, after: newRecordValue };
diffAccumulator[key] = { before: oldRecordValue, after: newRecordValue };
return acc;
return diffAccumulator;
},
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
{} as Record<string, { before: any; after: any }>,
);
const objectFields = getFlatFieldsFromFlatObjectMetadata(
objectMetadataItem,
flatFieldMetadataMaps,
);
for (const field of objectFields) {
if (
!isManyToOneRelationField(field) ||
isDefined(accumulator[field.name])
) {
continue;
}
const joinColumnName = getJoinColumnNameForRelationField(field);
const oldJoinColumnValue = oldRecord[joinColumnName];
const newJoinColumnValue = newRecord[joinColumnName];
if (fastDeepEqual(oldJoinColumnValue, newJoinColumnValue)) {
continue;
}
accumulator[field.name] = {
before: buildRelationFieldChangeValue(
oldJoinColumnValue as string | null | undefined,
),
after: buildRelationFieldChangeValue(
newJoinColumnValue as string | null | undefined,
),
};
}
return accumulator;
};