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:
committed by
GitHub
parent
627b488556
commit
ba493e5a23
+100
-20
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user