feat: merge records (#13436)

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2025-07-31 15:36:36 +05:30
committed by GitHub
parent 00d12e854a
commit 06e0814ce8
45 changed files with 2047 additions and 29 deletions
@@ -0,0 +1,103 @@
import { useCallback, useState } from 'react';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useMergeManyRecordsMutation } from '@/object-record/hooks/useMergeManyRecordsMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField';
export type MergeManySettings = {
conflictPriorityIndex: number;
};
export type UseMergeManyRecordsProps = {
objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
};
export const useMergeManyRecords = <
MergedObjectRecord extends ObjectRecord = ObjectRecord,
>({
objectNameSingular,
recordGqlFields,
}: UseMergeManyRecordsProps) => {
const apolloCoreClient = useApolloCoreClient();
const [loading, setLoading] = useState(false);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const computedRecordGqlFields =
recordGqlFields ?? generateDepthOneRecordGqlFields({ objectMetadataItem });
const { mergeManyRecordsMutation } = useMergeManyRecordsMutation({
objectNameSingular,
recordGqlFields: computedRecordGqlFields,
});
const { refetchAggregateQueries } = useRefetchAggregateQueries({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
type MergeManyRecordsProps = {
recordIds: string[];
mergeSettings: MergeManySettings;
preview?: boolean;
};
const mergeManyRecords = useCallback(
async ({
recordIds,
mergeSettings,
preview = false,
}: MergeManyRecordsProps): Promise<MergedObjectRecord | null> => {
setLoading(true);
const mutationResponseField = getMergeManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
try {
const mergedObject = await apolloCoreClient.mutate({
mutation: mergeManyRecordsMutation,
variables: {
ids: recordIds,
conflictPriorityIndex: mergeSettings.conflictPriorityIndex,
dryRun: preview,
},
// Prevent cache updates during dry run to avoid overwriting original record data
...(preview && {
fetchPolicy: 'no-cache',
errorPolicy: 'ignore',
}),
});
setLoading(false);
if (!preview) {
await refetchAggregateQueries();
}
return mergedObject.data?.[mutationResponseField] ?? null;
} catch (error) {
setLoading(false);
throw error;
}
},
[
apolloCoreClient,
mergeManyRecordsMutation,
objectMetadataItem.namePlural,
refetchAggregateQueries,
],
);
return {
mergeManyRecords,
loading,
};
};
@@ -0,0 +1,62 @@
import gql from 'graphql-tag';
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField';
import { capitalize } from 'twenty-shared/utils';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useMergeManyRecordsMutation = ({
objectNameSingular,
recordGqlFields,
}: {
objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const appliedRecordGqlFields =
recordGqlFields ??
generateDepthOneRecordGqlFields({
objectMetadataItem,
});
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
if (isUndefinedOrNull(objectMetadataItem)) {
return { mergeManyRecordsMutation: EMPTY_MUTATION };
}
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const mutationResponseField = getMergeManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const mergeManyRecordsMutation = gql`
mutation merge${capitalizedObjectName}s($ids: [UUID!]!, $conflictPriorityIndex: Int!, $dryRun: Boolean) {
${mutationResponseField}(ids: $ids, conflictPriorityIndex: $conflictPriorityIndex, dryRun: $dryRun) ${mapObjectMetadataToGraphQLQuery(
{
objectMetadataItems,
objectMetadataItem,
recordGqlFields: appliedRecordGqlFields,
objectPermissionsByObjectMetadataId,
},
)}
}
`;
return {
mergeManyRecordsMutation,
};
};
@@ -0,0 +1,55 @@
import { useMergePreview } from '@/object-record/record-merge/hooks/useMergePreview';
import { CardComponents } from '@/object-record/record-show/components/CardComponents';
import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
import { Section } from 'twenty-ui/layout';
const StyledLoadingContainer = styled.div`
align-items: center;
display: flex;
height: 100px;
justify-content: center;
`;
type MergePreviewTabProps = {
objectNameSingular: string;
};
export const MergePreviewTab = ({
objectNameSingular,
}: MergePreviewTabProps) => {
const { mergePreviewRecord, isGeneratingPreview } = useMergePreview({
objectNameSingular,
});
if (isGeneratingPreview) {
return (
<StyledLoadingContainer>
Generating merge preview...
</StyledLoadingContainer>
);
}
if (!isDefined(mergePreviewRecord)) {
return null;
}
return (
<Section>
<SummaryCard
objectNameSingular={objectNameSingular}
objectRecordId={mergePreviewRecord.id}
isInRightDrawer={true}
/>
<CardComponents.FieldCard
targetableObject={{
targetObjectNameSingular: objectNameSingular,
id: mergePreviewRecord.id,
}}
isInRightDrawer={true}
/>
</Section>
);
};
@@ -0,0 +1,32 @@
import { CardComponents } from '@/object-record/record-show/components/CardComponents';
import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
import { Section } from 'twenty-ui/layout';
type MergeRecordTabProps = {
isInRightDrawer?: boolean;
objectNameSingular: string;
recordId: string;
};
export const MergeRecordTab = ({
objectNameSingular,
recordId,
}: MergeRecordTabProps) => {
return (
<Section>
<SummaryCard
objectNameSingular={objectNameSingular}
objectRecordId={recordId}
isInRightDrawer={true}
/>
<CardComponents.FieldCard
targetableObject={{
targetObjectNameSingular: objectNameSingular,
id: recordId,
}}
isInRightDrawer={true}
/>
</Section>
);
};
@@ -0,0 +1,87 @@
import styled from '@emotion/styled';
import { ShowPageContainer } from '@/ui/layout/page/components/ShowPageContainer';
import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useMergeRecordsSettings } from '@/object-record/record-merge/hooks/useMergeRecordsSettings';
import { MergeRecordsTabId } from '@/object-record/record-merge/types/MergeRecordsTabId';
import { useMergeRecordsContainerTabs } from '../hooks/useMergeRecordsContainerTabs';
import { MergePreviewTab } from './MergePreviewTab';
import { MergeRecordsFooter } from './MergeRecordsFooter';
import { MergeRecordTab } from './MergeRecordTab';
import { MergeSettingsTab } from './MergeSettingsTab';
const StyledShowPageRightContainer = styled.div`
display: flex;
flex-direction: column;
justify-content: start;
width: 100%;
height: 100%;
overflow: auto;
`;
const StyledTabList = styled(TabList)`
background-color: ${({ theme }) => theme.background.secondary};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledContentContainer = styled.div`
flex: 1;
overflow-y: auto;
background: ${({ theme }) => theme.background.primary};
padding-bottom: ${({ theme }) => theme.spacing(16)};
`;
type MergeRecordsContainerProps = {
componentInstanceId: string;
objectNameSingular: string;
};
export const MergeRecordsContainer = ({
componentInstanceId,
objectNameSingular,
}: MergeRecordsContainerProps) => {
const { selectedRecords } = useMergeRecordsSettings();
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
componentInstanceId,
);
const { tabs } = useMergeRecordsContainerTabs(selectedRecords);
return (
<RightDrawerProvider value={{ isInRightDrawer: true }}>
<ShowPageContainer>
<StyledShowPageRightContainer>
<TabListComponentInstanceContext.Provider
value={{ instanceId: componentInstanceId }}
>
<StyledTabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={componentInstanceId}
/>
</TabListComponentInstanceContext.Provider>
<StyledContentContainer>
{activeTabId === MergeRecordsTabId.MERGE_PREVIEW && (
<MergePreviewTab objectNameSingular={objectNameSingular} />
)}
{activeTabId === MergeRecordsTabId.SETTINGS && <MergeSettingsTab />}
{selectedRecords.some((record) => record.id === activeTabId) && (
<MergeRecordTab
objectNameSingular={objectNameSingular}
recordId={activeTabId || ''}
/>
)}
</StyledContentContainer>
<MergeRecordsFooter objectNameSingular={objectNameSingular} />
</StyledShowPageRightContainer>
</ShowPageContainer>
</RightDrawerProvider>
);
};
@@ -0,0 +1,72 @@
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { useMergeRecordsActions } from '@/object-record/record-merge/hooks/useMergeRecordsActions';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { IconArrowMerge, IconDotsVertical } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
const StyledFooterContainer = styled.div`
align-items: flex-end;
background: ${({ theme }) => theme.background.primary};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: flex-end;
padding: ${({ theme }) => theme.spacing(3)};
`;
const StyledFooterActions = styled.div`
display: flex;
align-items: flex-end;
gap: ${({ theme }) => theme.spacing(2)};
`;
type MergeRecordsFooterProps = {
objectNameSingular: string;
};
export const MergeRecordsFooter = ({
objectNameSingular,
}: MergeRecordsFooterProps) => {
const { handleMergeRecords, isMerging } = useMergeRecordsActions({
objectNameSingular,
});
useHotkeysOnFocusedElement({
keys: [`${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`],
callback: () => {
if (!isMerging) {
handleMergeRecords();
}
},
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [handleMergeRecords, isMerging],
});
return (
<StyledFooterContainer>
<StyledFooterActions>
<Button
title="Options"
variant="secondary"
accent="default"
size="medium"
Icon={IconDotsVertical}
disabled={isMerging}
/>
<Button
title={isMerging ? 'Merging...' : 'Merge'}
variant="primary"
accent="blue"
size="medium"
Icon={IconArrowMerge}
hotkeys={isMerging ? undefined : ['⌘', '⏎']}
onClick={handleMergeRecords}
disabled={isMerging}
/>
</StyledFooterActions>
</StyledFooterContainer>
);
};
@@ -0,0 +1,38 @@
import { useMergeRecordsSettings } from '@/object-record/record-merge/hooks/useMergeRecordsSettings';
import { Select } from '@/ui/input/components/Select';
import styled from '@emotion/styled';
import { Section } from 'twenty-ui/layout';
import { getPositionNumberIcon } from '../utils/getPositionNumberIcon';
import { getPositionWordLabel } from '../utils/getPositionWordLabel';
const StyledSection = styled(Section)`
margin: ${({ theme }) => theme.spacing(4)};
`;
export const MergeSettingsTab = () => {
const { mergeSettings, updatePriorityRecordIndex, selectedRecords } =
useMergeRecordsSettings();
const priorityOptions = selectedRecords.map((_, index) => ({
value: index,
label: `${getPositionWordLabel(index)} record holds priority`,
Icon: getPositionNumberIcon(index),
recordIndex: index,
}));
const handleSelectionChange = (index: number) => {
updatePriorityRecordIndex(index);
};
return (
<StyledSection>
<Select
dropdownId="merge-settings-priority-select"
options={priorityOptions}
value={mergeSettings.conflictPriorityIndex}
onChange={handleSelectionChange}
label="Fields conflicts"
/>
</StyledSection>
);
};
@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { useMergeManyRecords } from '@/object-record/hooks/useMergeManyRecords';
import { useMergeRecordRelationships } from '@/object-record/record-merge/hooks/useMergeRecordRelationships';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { mergeSettingsState } from '../states/mergeSettingsState';
type UseMergePreviewProps = {
objectNameSingular: string;
};
export const useMergePreview = ({
objectNameSingular,
}: UseMergePreviewProps) => {
const [mergePreviewRecord, setMergePreviewRecord] =
useState<ObjectRecord | null>(null);
const [isGeneratingPreview, setIsGeneratingPreview] = useState(false);
const mergeSettings = useRecoilValue(mergeSettingsState);
const { records: selectedRecords } = useFindManyRecordsSelectedInContextStore(
{
limit: 10,
},
);
const { mergeManyRecords } = useMergeManyRecords({
objectNameSingular,
});
const { upsertRecords } = useUpsertRecordsInStore();
const { isLoading: isLoadingRelationships } = useMergeRecordRelationships({
objectNameSingular,
previewRecordId: mergePreviewRecord?.id || '',
selectedRecords: selectedRecords,
});
useEffect(() => {
const fetchPreview = async () => {
if (selectedRecords.length < 2) return;
setIsGeneratingPreview(true);
try {
const previewRecord = await mergeManyRecords({
recordIds: selectedRecords.map((record) => record.id),
mergeSettings,
preview: true,
});
if (!previewRecord) {
setMergePreviewRecord(null);
return;
}
setMergePreviewRecord(previewRecord);
upsertRecords([previewRecord]);
} catch (error) {
setMergePreviewRecord(null);
} finally {
setIsGeneratingPreview(false);
}
};
if (selectedRecords.length > 0) {
fetchPreview();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedRecords, mergeSettings]);
return {
mergePreviewRecord,
isGeneratingPreview: isGeneratingPreview || isLoadingRelationships,
};
};
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState } from 'react';
import { useRecoilCallback } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords';
import { mergeRecordRelationshipData } from '@/object-record/record-merge/utils/mergeRelationshipData';
import { buildFindOneRecordForShowPageOperationSignature } from '@/object-record/record-show/graphql/operations/factories/findOneRecordForShowPageOperationSignatureFactory';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
type UseMergeRecordRelationshipsProps = {
objectNameSingular: string;
previewRecordId: string;
selectedRecords: ObjectRecord[];
};
type MergedRelationshipData = Record<
string,
ObjectRecord[] | ObjectRecord | null
>;
type UseMergeRecordRelationshipsResult = {
isLoading: boolean;
mergedRelationshipData: MergedRelationshipData;
error?: Error;
};
export const useMergeRecordRelationships = ({
objectNameSingular,
previewRecordId,
selectedRecords,
}: UseMergeRecordRelationshipsProps): UseMergeRecordRelationshipsResult => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const [completeRecords, setCompleteRecords] = useState<ObjectRecord[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | undefined>();
const recordGqlFields = useMemo(() => {
return buildFindOneRecordForShowPageOperationSignature({
objectMetadataItem,
objectMetadataItems,
}).fields;
}, [objectMetadataItem, objectMetadataItems]);
const { findManyRecordsLazy } = useLazyFindManyRecords({
objectNameSingular,
recordGqlFields,
filter: {
id: {
in: selectedRecords.map((record) => record.id),
},
},
});
useEffect(() => {
if (selectedRecords.length === 0 || !previewRecordId) {
setCompleteRecords([]);
setError(undefined);
return;
}
const fetchCompleteRecords = async () => {
setIsLoading(true);
setError(undefined);
try {
const { records } = await findManyRecordsLazy();
setCompleteRecords(records || []);
} catch (fetchError) {
const errorMessage =
fetchError instanceof Error
? fetchError.message
: 'Failed to fetch complete records';
setError(
new Error(`Failed to merge record relationships: ${errorMessage}`),
);
setCompleteRecords([]);
} finally {
setIsLoading(false);
}
};
fetchCompleteRecords();
}, [selectedRecords, findManyRecordsLazy, previewRecordId]);
const mergedRelationshipData = useMemo((): MergedRelationshipData => {
return mergeRecordRelationshipData(
completeRecords,
objectMetadataItem.fields,
isLoading,
);
}, [completeRecords, objectMetadataItem.fields, isLoading]);
const updatePreviewRecordStore = useRecoilCallback(
({ set }) =>
async (relationshipData: MergedRelationshipData) => {
if (Object.keys(relationshipData).length === 0) return;
set(recordStoreFamilyState(previewRecordId), (prevRecord) => {
if (!prevRecord) return prevRecord;
return {
...prevRecord,
...relationshipData,
};
});
},
[previewRecordId],
);
useEffect(() => {
if (!isLoading && Object.keys(mergedRelationshipData).length > 0) {
updatePreviewRecordStore(mergedRelationshipData);
}
}, [isLoading, mergedRelationshipData, updatePreviewRecordStore]);
return {
isLoading,
mergedRelationshipData,
error,
};
};
@@ -0,0 +1,92 @@
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
import { useMergeManyRecords } from '@/object-record/hooks/useMergeManyRecords';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { mergeSettingsState } from '../states/mergeSettingsState';
type UseMergeRecordsActionsProps = {
objectNameSingular: string;
};
export const useMergeRecordsActions = ({
objectNameSingular,
}: UseMergeRecordsActionsProps) => {
const mergeSettings = useRecoilValue(mergeSettingsState);
const { records: selectedRecords } = useFindManyRecordsSelectedInContextStore(
{
limit: 10,
},
);
const { mergeManyRecords, loading: isMerging } = useMergeManyRecords({
objectNameSingular,
});
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
const { objectNamePlural } = useObjectNamePluralFromSingular({
objectNameSingular,
});
const contextStoreCurrentViewId = useRecoilComponentValueV2(
contextStoreCurrentViewIdComponentState,
);
if (!contextStoreCurrentViewId) {
throw new Error('Current view ID is not defined');
}
const { resetTableRowSelection } = useRecordTable({
recordTableId: getRecordIndexIdFromObjectNamePluralAndViewId(
objectNamePlural,
contextStoreCurrentViewId,
),
});
const handleMergeRecords = async () => {
try {
const mergedRecord = await mergeManyRecords({
recordIds: selectedRecords.map((record) => record.id),
mergeSettings,
preview: false,
});
if (!mergedRecord) {
throw new Error('Failed to merge records');
}
const recordCount = selectedRecords.length;
enqueueSuccessSnackBar({
message: t`Successfully merged ${recordCount} records`,
});
resetTableRowSelection();
openRecordInCommandMenu({
objectNameSingular,
recordId: mergedRecord.id,
});
} catch (error) {
enqueueErrorSnackBar({
message:
error instanceof Error
? error.message
: 'Failed to merge records. Please try again.',
});
}
};
return {
handleMergeRecords,
isMerging,
selectedRecords,
};
};
@@ -0,0 +1,43 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { IconArrowMerge, IconSettings } from 'twenty-ui/display';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { MergeRecordsTabId } from '../types/MergeRecordsTabId';
import { getPositionNumberIcon } from '../utils/getPositionNumberIcon';
import { getPositionWordLabel } from '../utils/getPositionWordLabel';
export const useMergeRecordsContainerTabs = (
selectedRecords: ObjectRecord[],
): { tabs: SingleTabProps[] } => {
const { t } = useLingui();
const tabs = useMemo(() => {
const mergePreviewTab: SingleTabProps = {
id: MergeRecordsTabId.MERGE_PREVIEW,
title: t`Merge preview`,
Icon: IconArrowMerge,
};
const recordTabs: SingleTabProps[] = selectedRecords.map(
(record, index) => ({
id: record.id,
title: getPositionWordLabel(index),
Icon: getPositionNumberIcon(index),
}),
);
const settingsTab: SingleTabProps = {
id: MergeRecordsTabId.SETTINGS,
title: t`Settings`,
Icon: IconSettings,
};
return [mergePreviewTab, ...recordTabs, settingsTab];
}, [t, selectedRecords]);
return { tabs };
};
@@ -0,0 +1,32 @@
import { useRecoilState } from 'recoil';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { MergeManySettings } from '@/object-record/hooks/useMergeManyRecords';
import { mergeSettingsState } from '../states/mergeSettingsState';
export const useMergeRecordsSettings = () => {
const [mergeSettings, setMergeSettings] = useRecoilState(mergeSettingsState);
const { records: selectedRecords } = useFindManyRecordsSelectedInContextStore(
{
limit: 10,
},
);
const updateMergeSettings = (settings: MergeManySettings) => {
setMergeSettings(settings);
};
const updatePriorityRecordIndex = (conflictPriorityIndex: number) => {
setMergeSettings({
...mergeSettings,
conflictPriorityIndex,
});
};
return {
selectedRecords,
mergeSettings,
updateMergeSettings,
updatePriorityRecordIndex,
};
};
@@ -0,0 +1,9 @@
import { MergeManySettings } from '@/object-record/hooks/useMergeManyRecords';
import { createState } from 'twenty-ui/utilities';
export const mergeSettingsState = createState<MergeManySettings>({
key: 'mergeSettingsState',
defaultValue: {
conflictPriorityIndex: 0,
},
});
@@ -0,0 +1 @@
export type FixedLengthArray<T, L extends number> = T[] & { length: L };
@@ -0,0 +1,6 @@
export type MergeRecordsTabIdType = 'merge-preview' | 'settings';
export enum MergeRecordsTabId {
MERGE_PREVIEW = 'merge-preview',
SETTINGS = 'settings',
}
@@ -0,0 +1,356 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { mergeManyToOneRelationship } from '@/object-record/record-merge/utils/mergeManyToOneRelationship';
import { mergeOneToManyRelationships } from '@/object-record/record-merge/utils/mergeOneToManyRelationships';
import { mergeRecordRelationshipData } from '@/object-record/record-merge/utils/mergeRelationshipData';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
describe('mergeOneToManyRelationships', () => {
it('should merge one-to-many relationships and remove duplicates', () => {
const records: ObjectRecord[] = [
{
__typename: 'Company',
id: 'record1',
opportunities: [
{ __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
{ __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' },
],
},
{
__typename: 'Company',
id: 'record2',
opportunities: [
{ __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' }, // Duplicate
{ __typename: 'Opportunity', id: 'opp3', name: 'Opportunity 3' },
],
},
];
const result = mergeOneToManyRelationships(records, 'opportunities');
expect(result).toHaveLength(3);
expect(result).toEqual([
{ __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
{ __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' },
{ __typename: 'Opportunity', id: 'opp3', name: 'Opportunity 3' },
]);
});
it('should handle empty arrays', () => {
const records: ObjectRecord[] = [
{
__typename: 'Company',
id: 'record1',
opportunities: [],
},
{
__typename: 'Company',
id: 'record2',
opportunities: [],
},
];
const result = mergeOneToManyRelationships(records, 'opportunities');
expect(result).toEqual([]);
});
it('should handle records with null or undefined relationship values', () => {
const records: ObjectRecord[] = [
{
__typename: 'Company',
id: 'record1',
opportunities: [
{ __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
],
},
{
__typename: 'Company',
id: 'record2',
opportunities: null,
},
{
__typename: 'Company',
id: 'record3',
opportunities: undefined,
},
];
const result = mergeOneToManyRelationships(records, 'opportunities');
expect(result).toEqual([
{ __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
]);
});
it('should handle empty records array', () => {
const result = mergeOneToManyRelationships([], 'opportunities');
expect(result).toEqual([]);
});
});
describe('mergeManyToOneRelationship', () => {
it('should return the first non-null value', () => {
const records: ObjectRecord[] = [
{
__typename: 'Person',
id: 'record1',
company: null,
},
{
__typename: 'Person',
id: 'record2',
company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
},
{
__typename: 'Person',
id: 'record3',
company: { __typename: 'Company', id: 'company2', name: 'Company 2' },
},
];
const result = mergeManyToOneRelationship(records, 'company');
expect(result).toEqual({
__typename: 'Company',
id: 'company1',
name: 'Company 1',
});
});
it('should return the first non-undefined value', () => {
const records: ObjectRecord[] = [
{
__typename: 'Person',
id: 'record1',
company: undefined,
},
{
__typename: 'Person',
id: 'record2',
company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
},
];
const result = mergeManyToOneRelationship(records, 'company');
expect(result).toEqual({
__typename: 'Company',
id: 'company1',
name: 'Company 1',
});
});
it('should return null if all values are null or undefined', () => {
const records: ObjectRecord[] = [
{
__typename: 'Person',
id: 'record1',
company: null,
},
{
__typename: 'Person',
id: 'record2',
company: undefined,
},
];
const result = mergeManyToOneRelationship(records, 'company');
expect(result).toBeNull();
});
it('should handle empty records array', () => {
const result = mergeManyToOneRelationship([], 'company');
expect(result).toBeNull();
});
});
describe('mergeRecordRelationshipData', () => {
const mockFieldMetadataItems: FieldMetadataItem[] = [
{
id: 'field1',
name: 'opportunities',
type: FieldMetadataType.RELATION,
relation: {
type: RelationType.ONE_TO_MANY,
},
} as FieldMetadataItem,
{
id: 'field2',
name: 'company',
type: FieldMetadataType.RELATION,
relation: {
type: RelationType.MANY_TO_ONE,
},
} as FieldMetadataItem,
{
id: 'field3',
name: 'name',
type: FieldMetadataType.TEXT,
} as FieldMetadataItem,
];
it('should merge all relationship fields correctly', () => {
const records: ObjectRecord[] = [
{
__typename: 'Company',
id: 'record1',
opportunities: [
{ __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
{ __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' },
],
company: null,
name: 'Record 1',
},
{
__typename: 'Company',
id: 'record2',
opportunities: [
{ __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' },
{ __typename: 'Opportunity', id: 'opp3', name: 'Opportunity 3' },
],
company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
name: 'Record 2',
},
];
const result = mergeRecordRelationshipData(
records,
mockFieldMetadataItems,
false,
);
expect(result).toEqual({
opportunities: [
{ __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
{ __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' },
{ __typename: 'Opportunity', id: 'opp3', name: 'Opportunity 3' },
],
company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
});
});
it('should return empty object when isLoading is true', () => {
const records: ObjectRecord[] = [
{
__typename: 'Company',
id: 'record1',
opportunities: [
{ __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
],
company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
},
];
const result = mergeRecordRelationshipData(
records,
mockFieldMetadataItems,
true,
);
expect(result).toEqual({});
});
it('should return empty object when records array is empty', () => {
const result = mergeRecordRelationshipData(
[],
mockFieldMetadataItems,
false,
);
expect(result).toEqual({});
});
it('should only process relation fields', () => {
const records: ObjectRecord[] = [
{
__typename: 'Company',
id: 'record1',
opportunities: [
{ __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
],
company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
name: 'Record 1',
email: 'test@example.com',
},
];
const result = mergeRecordRelationshipData(
records,
mockFieldMetadataItems,
false,
);
expect(result).toEqual({
opportunities: [
{ __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
],
company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
});
expect(result).not.toHaveProperty('name');
expect(result).not.toHaveProperty('email');
});
it('should handle fields without relation metadata', () => {
const fieldsWithoutRelation: FieldMetadataItem[] = [
{
id: 'field1',
name: 'opportunities',
type: FieldMetadataType.RELATION,
relation: null,
} as FieldMetadataItem,
];
const records: ObjectRecord[] = [
{
__typename: 'Company',
id: 'record1',
opportunities: [
{ __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
],
},
];
const result = mergeRecordRelationshipData(
records,
fieldsWithoutRelation,
false,
);
expect(result).toEqual({});
});
it('should handle unknown relation types gracefully', () => {
const fieldsWithUnknownRelation: FieldMetadataItem[] = [
{
id: 'field1',
name: 'unknownRelation',
type: FieldMetadataType.RELATION,
relation: {
type: 'UNKNOWN_TYPE' as RelationType,
},
} as FieldMetadataItem,
];
const records: ObjectRecord[] = [
{
__typename: 'Person',
id: 'record1',
unknownRelation: [
{ __typename: 'Unknown', id: 'unknown1', name: 'Unknown 1' },
],
},
];
const result = mergeRecordRelationshipData(
records,
fieldsWithUnknownRelation,
false,
);
expect(result).toEqual({});
});
});
@@ -0,0 +1,32 @@
import { MUTATION_MAX_MERGE_RECORDS } from 'twenty-shared/constants';
import {
IconSquareNumber1,
IconSquareNumber2,
IconSquareNumber3,
IconSquareNumber4,
IconSquareNumber5,
IconSquareNumber6,
IconSquareNumber7,
IconSquareNumber8,
IconSquareNumber9,
} from 'twenty-ui/display';
import { FixedLengthArray } from '../types/FixedLengthArray';
export const getPositionNumberIcon = (index: number) => {
const iconMapping: FixedLengthArray<
typeof IconSquareNumber1,
typeof MUTATION_MAX_MERGE_RECORDS
> = [
IconSquareNumber1,
IconSquareNumber2,
IconSquareNumber3,
IconSquareNumber4,
IconSquareNumber5,
IconSquareNumber6,
IconSquareNumber7,
IconSquareNumber8,
IconSquareNumber9,
];
return iconMapping[index] || IconSquareNumber1;
};
@@ -0,0 +1,21 @@
import { msg } from '@lingui/core/macro';
import { MUTATION_MAX_MERGE_RECORDS } from 'twenty-shared/constants';
import { FixedLengthArray } from '../types/FixedLengthArray';
export const getPositionWordLabel = (index: number): string => {
const labels: FixedLengthArray<
ReturnType<typeof msg>,
typeof MUTATION_MAX_MERGE_RECORDS
> = [
msg`First`,
msg`Second`,
msg`Third`,
msg`Fourth`,
msg`Fifth`,
msg`Sixth`,
msg`Seventh`,
msg`Eighth`,
msg`Ninth`,
];
return labels[index] ? (labels[index].message as string) : '';
};
@@ -0,0 +1,13 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from 'twenty-shared/utils';
export const mergeManyToOneRelationship = (
records: ObjectRecord[],
fieldName: string,
): ObjectRecord | null => {
const firstNonNullValue = records
.map((record) => record[fieldName])
.find((value) => isDefined(value) && value !== null);
return firstNonNullValue || null;
};
@@ -0,0 +1,20 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const mergeOneToManyRelationships = (
records: ObjectRecord[],
fieldName: string,
): ObjectRecord[] => {
const allRelatedRecords: ObjectRecord[] = [];
records.forEach((record) => {
const relationValue = record[fieldName];
if (Array.isArray(relationValue)) {
allRelatedRecords.push(...relationValue);
}
});
return allRelatedRecords.filter(
(record, index, array) =>
array.findIndex((r) => r.id === record.id) === index,
);
};
@@ -0,0 +1,34 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { mergeManyToOneRelationship } from '@/object-record/record-merge/utils/mergeManyToOneRelationship';
import { mergeOneToManyRelationships } from '@/object-record/record-merge/utils/mergeOneToManyRelationships';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
export const mergeRecordRelationshipData = (
records: ObjectRecord[],
fieldMetadataItems: FieldMetadataItem[],
isLoading: boolean,
): Record<string, ObjectRecord[] | ObjectRecord | null> => {
if (isLoading || records.length === 0) {
return {};
}
const mergedData: Record<string, ObjectRecord[] | ObjectRecord | null> = {};
const relationFieldMetadataItems = fieldMetadataItems.filter(
(field) => field.type === FieldMetadataType.RELATION,
);
relationFieldMetadataItems.forEach((fieldMetadataItem) => {
const fieldName = fieldMetadataItem.name;
const relationType = fieldMetadataItem.relation?.type;
if (relationType === RelationType.ONE_TO_MANY) {
mergedData[fieldName] = mergeOneToManyRelationships(records, fieldName);
} else if (relationType === RelationType.MANY_TO_ONE) {
mergedData[fieldName] = mergeManyToOneRelationship(records, fieldName);
}
});
return mergedData;
};
@@ -10,8 +10,8 @@ import { useRecordShowContainerData } from '@/object-record/record-show/hooks/us
import { useRecordShowContainerTabs } from '@/object-record/record-show/hooks/useRecordShowContainerTabs';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { ShowPageSubContainer } from '@/ui/layout/show-page/components/ShowPageSubContainer';
import { useRecoilValue } from 'recoil';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
const StyledShowPageBannerContainer = styled.div`
z-index: 1;
@@ -0,0 +1,7 @@
import { capitalize } from 'twenty-shared/utils';
export const getMergeManyRecordsMutationResponseField = (
objectNamePlural: string,
): string => {
return `merge${capitalize(objectNamePlural)}`;
};