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
@@ -3,13 +3,16 @@ import { styled } from '@linaria/react';
import { EventFieldDiffLabel } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffLabel';
import { EventFieldDiffValue } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffValue';
import { EventFieldDiffValueEffect } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffValueEffect';
import { EventRelationFieldDiffValues } from '@/activities/timeline-activities/rows/main-object/components/EventRelationFieldDiffValues';
import { isRelationFieldChangeValue } from '@/activities/timeline-activities/utils/relationFieldChangeValue';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { Trans } from '@lingui/react/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type EventFieldDiffProps = {
diffRecord: Record<string, any>;
fieldDiff: { before: unknown; after: unknown };
mainObjectMetadataItem: EnrichedObjectMetadataItem;
fieldMetadataItem: FieldMetadataItem | undefined;
diffArtificialRecordStoreId: string;
@@ -31,8 +34,12 @@ const StyledEmptyValue = styled.div`
color: ${themeCssVariables.font.color.tertiary};
`;
const StyledArrowContainer = styled.span`
color: ${themeCssVariables.font.color.secondary};
`;
export const EventFieldDiff = ({
diffRecord,
fieldDiff,
mainObjectMetadataItem,
fieldMetadataItem,
diffArtificialRecordStoreId,
@@ -41,11 +48,31 @@ export const EventFieldDiff = ({
throw new Error('fieldMetadataItem is required');
}
const isRelationFieldDiff =
fieldMetadataItem.type === FieldMetadataType.RELATION &&
(isRelationFieldChangeValue(fieldDiff.before) ||
isRelationFieldChangeValue(fieldDiff.after));
if (isRelationFieldDiff) {
return (
<StyledEventFieldDiffContainer>
<EventFieldDiffLabel fieldMetadataItem={fieldMetadataItem} />
<StyledArrowContainer></StyledArrowContainer>
<EventRelationFieldDiffValues
fieldDiff={fieldDiff}
fieldMetadataItem={fieldMetadataItem}
/>
</StyledEventFieldDiffContainer>
);
}
const diffRecord = fieldDiff.after as Record<string, unknown> | undefined;
const isValueEmpty = (value: unknown): boolean =>
value === null || value === undefined || value === '';
const isObjectEmpty = (obj: Record<string, unknown>): boolean =>
Object.values(obj).every(isValueEmpty);
const isObjectEmpty = (objectValue: Record<string, unknown>): boolean =>
Object.values(objectValue).every(isValueEmpty);
const isUpdatedToEmpty =
isValueEmpty(diffRecord) ||
@@ -55,7 +82,8 @@ export const EventFieldDiff = ({
return (
<StyledEventFieldDiffContainer>
<EventFieldDiffLabel fieldMetadataItem={fieldMetadataItem} />
<EventFieldDiffLabel fieldMetadataItem={fieldMetadataItem} />
<StyledArrowContainer></StyledArrowContainer>
{isUpdatedToEmpty ? (
<StyledEmptyValue>
<Trans>Empty</Trans>
@@ -1,24 +1,25 @@
import { EventFieldDiff } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiff';
import { findFieldMetadataItemByDiffKey } from '@/activities/timeline-activities/utils/findFieldMetadataItemByDiffKey';
import { isDefined } from 'twenty-shared/utils';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
type EventFieldDiffContainerProps = {
mainObjectMetadataItem: EnrichedObjectMetadataItem;
diffKey: string;
diffValue: any;
fieldDiff: { before: unknown; after: unknown };
eventId: string;
fieldMetadataItemMap: Record<string, FieldMetadataItem>;
};
export const EventFieldDiffContainer = ({
mainObjectMetadataItem,
diffKey,
diffValue,
fieldDiff,
eventId,
fieldMetadataItemMap,
}: EventFieldDiffContainerProps) => {
const fieldMetadataItem = fieldMetadataItemMap[diffKey];
const fieldMetadataItem = findFieldMetadataItemByDiffKey(
mainObjectMetadataItem.fields,
diffKey,
);
if (!isDefined(fieldMetadataItem)) {
throw new Error(
@@ -31,7 +32,7 @@ export const EventFieldDiffContainer = ({
return (
<EventFieldDiff
key={diffArtificialRecordStoreId}
diffRecord={diffValue}
fieldDiff={fieldDiff}
fieldMetadataItem={fieldMetadataItem}
mainObjectMetadataItem={mainObjectMetadataItem}
diffArtificialRecordStoreId={diffArtificialRecordStoreId}
@@ -0,0 +1,191 @@
import { styled } from '@linaria/react';
import { useId, useMemo } from 'react';
import { isRelationFieldChangeValue } from '@/activities/timeline-activities/utils/relationFieldChangeValue';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { Trans, useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type EventRelationFieldDiffValuesProps = {
fieldDiff: { before: unknown; after: unknown };
fieldMetadataItem: FieldMetadataItem;
};
const StyledRelationValue = styled.div`
color: ${themeCssVariables.font.color.primary};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledEmptyValue = styled.div`
color: ${themeCssVariables.font.color.tertiary};
`;
const getRelationRecordId = (value: unknown): string | null => {
if (!isRelationFieldChangeValue(value)) {
return null;
}
if (!isDefined(value.id) || value.id === '') {
return null;
}
return value.id;
};
const EventRelationFieldDiffValuesWithFetch = ({
fieldDiff,
relationTargetObjectMetadataNameSingular,
}: {
fieldDiff: { before: unknown; after: unknown };
relationTargetObjectMetadataNameSingular: string;
}) => {
const relationRecordIds = useMemo(() => {
const recordIds = new Set<string>();
for (const relationFieldChangeValue of [
fieldDiff.before,
fieldDiff.after,
]) {
const relationRecordId = getRelationRecordId(relationFieldChangeValue);
if (isDefined(relationRecordId)) {
recordIds.add(relationRecordId);
}
}
return Array.from(recordIds);
}, [fieldDiff.after, fieldDiff.before]);
const { objectMetadataItem: relationTargetObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: relationTargetObjectMetadataNameSingular,
});
const { records, loading } = useFindManyRecords({
objectNameSingular: relationTargetObjectMetadataNameSingular,
filter: {
id: {
in: relationRecordIds,
},
},
skip: relationRecordIds.length === 0,
});
const recordsById = useMemo(
() => new Map(records.map((record) => [record.id, record])),
[records],
);
const resolveDisplayName = (value: unknown): string | null => {
const relationRecordId = getRelationRecordId(value);
if (!isDefined(relationRecordId)) {
return null;
}
const relatedRecord = recordsById.get(relationRecordId);
if (isDefined(relatedRecord)) {
return getObjectRecordIdentifier({
objectMetadataItem: relationTargetObjectMetadataItem,
record: relatedRecord,
allowRequestsToTwentyIcons: false,
}).name;
}
if (loading) {
return null;
}
return relationRecordId;
};
const beforeDisplayName = resolveDisplayName(fieldDiff.before);
const afterDisplayName = resolveDisplayName(fieldDiff.after);
return (
<RelationFieldDiffValue
beforeDisplayName={beforeDisplayName}
afterDisplayName={afterDisplayName}
/>
);
};
const RelationFieldDiffValue = ({
beforeDisplayName,
afterDisplayName,
}: {
beforeDisplayName: string | null;
afterDisplayName: string | null;
}) => {
const { t } = useLingui();
const instanceId = useId();
// react-tooltip anchors via a CSS selector, so the id must be selector-safe
const tooltipAnchorId = `relation-field-diff-${instanceId.replace(
/[^a-zA-Z0-9-_]/g,
'-',
)}`;
const emptyLabel = t`Empty`;
const tooltipContent = `${beforeDisplayName ?? emptyLabel}${
afterDisplayName ?? emptyLabel
}`;
return (
<>
{afterDisplayName !== null ? (
<StyledRelationValue id={tooltipAnchorId}>
{afterDisplayName}
</StyledRelationValue>
) : (
<StyledEmptyValue id={tooltipAnchorId}>
<Trans>Empty</Trans>
</StyledEmptyValue>
)}
<AppTooltip
anchorSelect={`#${tooltipAnchorId}`}
content={tooltipContent}
delay={TooltipDelay.shortDelay}
place={TooltipPosition.Bottom}
positionStrategy="fixed"
/>
</>
);
};
export const EventRelationFieldDiffValues = ({
fieldDiff,
fieldMetadataItem,
}: EventRelationFieldDiffValuesProps) => {
const relationTargetObjectMetadataNameSingular =
fieldMetadataItem.relation?.targetObjectMetadata.nameSingular;
if (!isDefined(relationTargetObjectMetadataNameSingular)) {
const beforeDisplayName = getRelationRecordId(fieldDiff.before);
const afterDisplayName = getRelationRecordId(fieldDiff.after);
return (
<RelationFieldDiffValue
beforeDisplayName={beforeDisplayName}
afterDisplayName={afterDisplayName}
/>
);
}
return (
<EventRelationFieldDiffValuesWithFetch
fieldDiff={fieldDiff}
relationTargetObjectMetadataNameSingular={
relationTargetObjectMetadataNameSingular
}
/>
);
};
@@ -7,7 +7,6 @@ import { EventCardToggleButton } from '@/activities/timeline-activities/rows/com
import { EventRowItem } from '@/activities/timeline-activities/rows/components/EventRowItem';
import { EventFieldDiffContainer } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffContainer';
import { type TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
@@ -61,12 +60,6 @@ export const EventRowMainObjectUpdated = ({
const [isOpen, setIsOpen] = useState(true);
const fieldMetadataItemMap: Record<string, FieldMetadataItem> =
mainObjectMetadataItem.fields.reduce(
(acc, field) => ({ ...acc, [field.name]: field }),
{},
);
const diffEntries = Object.entries(diff);
if (diffEntries.length === 0) {
throw new Error('Cannot render update description without changes');
@@ -85,9 +78,8 @@ export const EventRowMainObjectUpdated = ({
<EventFieldDiffContainer
mainObjectMetadataItem={mainObjectMetadataItem}
diffKey={diffEntries[0][0]}
diffValue={diffEntries[0][1].after}
fieldDiff={diffEntries[0][1]}
eventId={event.id}
fieldMetadataItemMap={fieldMetadataItemMap}
/>
)}
{diffEntries.length > 1 && (
@@ -106,9 +98,8 @@ export const EventRowMainObjectUpdated = ({
key={diffKey}
mainObjectMetadataItem={mainObjectMetadataItem}
diffKey={diffKey}
diffValue={diffValue.after}
fieldDiff={diffValue}
eventId={event.id}
fieldMetadataItemMap={fieldMetadataItemMap}
/>
))}
</EventCard>
@@ -0,0 +1,208 @@
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { type ReactNode } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { EventRelationFieldDiffValues } from '@/activities/timeline-activities/rows/main-object/components/EventRelationFieldDiffValues';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: jest.fn(),
}));
jest.mock('@/object-metadata/hooks/useObjectMetadataItem', () => ({
useObjectMetadataItem: jest.fn(),
}));
// getObjectRecordIdentifier depends on label-identifier metadata; mocking it to
// echo the record name keeps these tests focused on the diff rendering logic.
jest.mock('@/object-metadata/utils/getObjectRecordIdentifier', () => ({
getObjectRecordIdentifier: jest.fn(),
}));
const { useFindManyRecords } = jest.requireMock(
'@/object-record/hooks/useFindManyRecords',
);
const { useObjectMetadataItem } = jest.requireMock(
'@/object-metadata/hooks/useObjectMetadataItem',
);
const { getObjectRecordIdentifier } = jest.requireMock(
'@/object-metadata/utils/getObjectRecordIdentifier',
);
const WORKSPACE_MEMBER_RECORDS = [
{ id: 'before-id', name: 'Tim A' },
{ id: 'after-id', name: 'Tim Apple' },
];
const relationFieldMetadataItem = {
id: 'field-account-owner',
name: 'accountOwner',
type: FieldMetadataType.RELATION,
relation: {
targetObjectMetadata: { nameSingular: 'workspaceMember' },
},
} as unknown as FieldMetadataItem;
const renderWithI18n = (node: ReactNode) =>
render(<I18nProvider i18n={i18n}>{node}</I18nProvider>);
// react-tooltip relies on floating-ui, which needs ResizeObserver; jsdom does
// not provide one, so we stub it for the hover-tooltip assertions.
class ResizeObserverMock {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
describe('EventRelationFieldDiffValues', () => {
beforeAll(() => {
global.ResizeObserver =
ResizeObserverMock as unknown as typeof ResizeObserver;
});
beforeEach(() => {
useObjectMetadataItem.mockReturnValue({
objectMetadataItem: { nameSingular: 'workspaceMember' },
});
useFindManyRecords.mockReturnValue({
records: WORKSPACE_MEMBER_RECORDS,
loading: false,
});
getObjectRecordIdentifier.mockImplementation(
({ record }: { record: { name: string } }) => ({ name: record.name }),
);
});
afterEach(() => {
jest.clearAllMocks();
});
// Regression test: clearing a relation must show "Empty", not the stale
// previous value. value -> null is the only transition where `after` is null
// while `before` is set, so it is the one that previously rendered the old
// value as if nothing had changed.
it('renders "Empty" when a relation is changed from a value to null', () => {
renderWithI18n(
<EventRelationFieldDiffValues
fieldDiff={{ before: { id: 'before-id' }, after: { id: null } }}
fieldMetadataItem={relationFieldMetadataItem}
/>,
);
expect(screen.getByText('Empty')).toBeInTheDocument();
expect(screen.queryByText('Tim A')).not.toBeInTheDocument();
});
it('renders the new record name when a relation is set from null to a value', () => {
renderWithI18n(
<EventRelationFieldDiffValues
fieldDiff={{ before: { id: null }, after: { id: 'after-id' } }}
fieldMetadataItem={relationFieldMetadataItem}
/>,
);
expect(screen.getByText('Tim Apple')).toBeInTheDocument();
expect(screen.queryByText('Empty')).not.toBeInTheDocument();
});
it('renders only the after record name when a relation changes from one value to another', () => {
renderWithI18n(
<EventRelationFieldDiffValues
fieldDiff={{ before: { id: 'before-id' }, after: { id: 'after-id' } }}
fieldMetadataItem={relationFieldMetadataItem}
/>,
);
expect(screen.getByText('Tim Apple')).toBeInTheDocument();
expect(screen.queryByText('Tim A')).not.toBeInTheDocument();
expect(screen.queryByText('Empty')).not.toBeInTheDocument();
});
// Hovering the value reveals a tooltip with the full before -> after change,
// even though only the after value is shown inline.
describe('before -> after tooltip on hover', () => {
it('does not render the tooltip until the value is hovered', () => {
renderWithI18n(
<EventRelationFieldDiffValues
fieldDiff={{ before: { id: 'before-id' }, after: { id: null } }}
fieldMetadataItem={relationFieldMetadataItem}
/>,
);
expect(screen.queryByText('Tim A → Empty')).not.toBeInTheDocument();
});
it('shows "before → Empty" when a relation is cleared', async () => {
renderWithI18n(
<EventRelationFieldDiffValues
fieldDiff={{ before: { id: 'before-id' }, after: { id: null } }}
fieldMetadataItem={relationFieldMetadataItem}
/>,
);
fireEvent.mouseEnter(screen.getByText('Empty'));
expect(await screen.findByText('Tim A → Empty')).toBeInTheDocument();
});
it('shows "Empty → after" when a relation is set from null', async () => {
renderWithI18n(
<EventRelationFieldDiffValues
fieldDiff={{ before: { id: null }, after: { id: 'after-id' } }}
fieldMetadataItem={relationFieldMetadataItem}
/>,
);
fireEvent.mouseEnter(screen.getByText('Tim Apple'));
expect(await screen.findByText('Empty → Tim Apple')).toBeInTheDocument();
});
it('shows "before → after" when a relation changes between two values', async () => {
renderWithI18n(
<EventRelationFieldDiffValues
fieldDiff={{ before: { id: 'before-id' }, after: { id: 'after-id' } }}
fieldMetadataItem={relationFieldMetadataItem}
/>,
);
fireEvent.mouseEnter(screen.getByText('Tim Apple'));
expect(await screen.findByText('Tim A → Tim Apple')).toBeInTheDocument();
});
});
// Fallback branch: when the relation has no target object metadata, the
// component skips the fetch and renders directly from the raw id.
describe('without a relation target object metadata', () => {
const fieldMetadataItemWithoutTarget = {
id: 'field-account-owner',
name: 'accountOwner',
type: FieldMetadataType.RELATION,
} as unknown as FieldMetadataItem;
it('renders "Empty" when changed from a value to null', () => {
renderWithI18n(
<EventRelationFieldDiffValues
fieldDiff={{ before: { id: 'before-id' }, after: { id: null } }}
fieldMetadataItem={fieldMetadataItemWithoutTarget}
/>,
);
expect(screen.getByText('Empty')).toBeInTheDocument();
});
it('renders the raw record id when set to a value', () => {
renderWithI18n(
<EventRelationFieldDiffValues
fieldDiff={{ before: { id: null }, after: { id: 'after-id' } }}
fieldMetadataItem={fieldMetadataItemWithoutTarget}
/>,
);
expect(screen.getByText('after-id')).toBeInTheDocument();
});
});
});
@@ -1,6 +1,8 @@
import { type TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { findFieldMetadataItemByDiffKey } from '@/activities/timeline-activities/utils/findFieldMetadataItemByDiffKey';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const filterOutInvalidTimelineActivities = (
timelineActivities: TimelineActivity[],
@@ -21,14 +23,6 @@ export const filterOutInvalidTimelineActivities = (
throw new Error('Object metadata items not found');
}
const fieldMetadataItemMap = new Map(
mainObjectMetadataItem.readableFields.map((field) => [field.name, field]),
);
const noteFieldMetadataItemMap = new Map(
noteObjectMetadataItem.readableFields.map((field) => [field.name, field]),
);
return timelineActivities.filter((timelineActivity) => {
const diff = timelineActivity.properties?.diff;
const canSkipValidation = !diff;
@@ -41,11 +35,14 @@ export const filterOutInvalidTimelineActivities = (
timelineActivity.name.startsWith('linked-note') ||
timelineActivity.name.startsWith('linked-task');
const fieldsToValidateAgainst = isNoteOrTask
? noteObjectMetadataItem.readableFields
: mainObjectMetadataItem.readableFields;
const validDiffEntries = Object.entries(diff).filter(([diffKey]) =>
isNoteOrTask
? // Note and Task objects have the same field metadata
noteFieldMetadataItemMap.has(diffKey)
: fieldMetadataItemMap.has(diffKey),
isDefined(
findFieldMetadataItemByDiffKey(fieldsToValidateAgainst, diffKey),
),
);
if (validDiffEntries.length === 0) {
@@ -0,0 +1,23 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { computeRelationGqlFieldJoinColumnName } from 'twenty-shared/utils';
export const findFieldMetadataItemByDiffKey = (
fieldMetadataItems: FieldMetadataItem[],
diffKey: string,
): FieldMetadataItem | undefined => {
const fieldMetadataItemByName = fieldMetadataItems.find(
(fieldMetadataItem) => fieldMetadataItem.name === diffKey,
);
if (fieldMetadataItemByName) {
return fieldMetadataItemByName;
}
return fieldMetadataItems.find((fieldMetadataItem) => {
const joinColumnName =
fieldMetadataItem.settings?.joinColumnName ??
computeRelationGqlFieldJoinColumnName({ name: fieldMetadataItem.name });
return joinColumnName === diffKey;
});
};
@@ -0,0 +1,16 @@
import { isDefined } from 'twenty-shared/utils';
export type RelationFieldChangeValue = {
id: string | null;
};
export const isRelationFieldChangeValue = (
value: unknown,
): value is RelationFieldChangeValue => {
return (
isDefined(value) &&
typeof value === 'object' &&
value !== null &&
'id' in value
);
};
@@ -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;
};