diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiff.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiff.tsx index 054f3cdf74..a29056108d 100644 --- a/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiff.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiff.tsx @@ -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; + 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 ( + + + + + + ); + } + + const diffRecord = fieldDiff.after as Record | undefined; + const isValueEmpty = (value: unknown): boolean => value === null || value === undefined || value === ''; - const isObjectEmpty = (obj: Record): boolean => - Object.values(obj).every(isValueEmpty); + const isObjectEmpty = (objectValue: Record): boolean => + Object.values(objectValue).every(isValueEmpty); const isUpdatedToEmpty = isValueEmpty(diffRecord) || @@ -55,7 +82,8 @@ export const EventFieldDiff = ({ return ( - → + + {isUpdatedToEmpty ? ( Empty diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffContainer.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffContainer.tsx index 8121b75f40..1fcb760412 100644 --- a/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffContainer.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffContainer.tsx @@ -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; }; 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 ( { + 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(); + + 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 ( + + ); +}; + +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 ? ( + + {afterDisplayName} + + ) : ( + + Empty + + )} + + + ); +}; + +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 ( + + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventRowMainObjectUpdated.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventRowMainObjectUpdated.tsx index 49a66687a8..ba097dd18b 100644 --- a/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventRowMainObjectUpdated.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventRowMainObjectUpdated.tsx @@ -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 = - 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 = ({ )} {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} /> ))} diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/__tests__/EventRelationFieldDiffValues.test.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/__tests__/EventRelationFieldDiffValues.test.tsx new file mode 100644 index 0000000000..512b619dc1 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/__tests__/EventRelationFieldDiffValues.test.tsx @@ -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({node}); + +// 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.queryByText('Tim A → Empty')).not.toBeInTheDocument(); + }); + + it('shows "before → Empty" when a relation is cleared', async () => { + renderWithI18n( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.getByText('Empty')).toBeInTheDocument(); + }); + + it('renders the raw record id when set to a value', () => { + renderWithI18n( + , + ); + + expect(screen.getByText('after-id')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/utils/filterOutInvalidTimelineActivities.ts b/packages/twenty-front/src/modules/activities/timeline-activities/utils/filterOutInvalidTimelineActivities.ts index 8d237d694c..11dcd54a91 100644 --- a/packages/twenty-front/src/modules/activities/timeline-activities/utils/filterOutInvalidTimelineActivities.ts +++ b/packages/twenty-front/src/modules/activities/timeline-activities/utils/filterOutInvalidTimelineActivities.ts @@ -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) { diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/utils/findFieldMetadataItemByDiffKey.ts b/packages/twenty-front/src/modules/activities/timeline-activities/utils/findFieldMetadataItemByDiffKey.ts new file mode 100644 index 0000000000..50c68e3839 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timeline-activities/utils/findFieldMetadataItemByDiffKey.ts @@ -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; + }); +}; diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/utils/relationFieldChangeValue.ts b/packages/twenty-front/src/modules/activities/timeline-activities/utils/relationFieldChangeValue.ts new file mode 100644 index 0000000000..d81b52e538 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timeline-activities/utils/relationFieldChangeValue.ts @@ -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 + ); +}; diff --git a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts index dd95936f04..871d43b26e 100644 --- a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts +++ b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts @@ -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 => { + return ( + isFlatFieldMetadataOfType(field, FieldMetadataType.RELATION) && + field.settings?.relationType === RelationType.MANY_TO_ONE + ); +}; + +const getJoinColumnNameForRelationField = ( + field: FlatFieldMetadata, +) => { + return ( + field.settings?.joinColumnName ?? + computeMorphOrRelationFieldJoinColumnName({ + name: field.name, + }) + ); +}; + export const objectRecordChangedValues = ( oldRecord: Partial, newRecord: Partial, objectMetadataItem: FlatObjectMetadata, flatFieldMetadataMaps: FlatEntityMaps, ) => { - 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, ); + + 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; };