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
+33
-5
@@ -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>
|
||||
|
||||
+8
-7
@@ -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}
|
||||
|
||||
+191
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+2
-11
@@ -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>
|
||||
|
||||
+208
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
-13
@@ -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) {
|
||||
|
||||
+23
@@ -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;
|
||||
});
|
||||
};
|
||||
+16
@@ -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
|
||||
);
|
||||
};
|
||||
+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