feat: merge records button in RecordDetailDuplicatesSection (#13537)

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2025-08-01 20:07:20 +05:30
committed by GitHub
parent c44f610872
commit a5cb3168c1
10 changed files with 232 additions and 46 deletions
@@ -0,0 +1,43 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useLoadSelectedRecordsInContextStore } from '@/object-record/hooks/useLoadSelectedRecordsInContextStore';
import { msg, t } from '@lingui/core/macro';
import { IconArrowMerge } from 'twenty-ui/display';
type UseOpenMergeRecordsPageInCommandMenuProps = {
objectNameSingular: string;
objectRecordIds: string[];
};
export const useOpenMergeRecordsPageInCommandMenu = ({
objectNameSingular,
objectRecordIds,
}: UseOpenMergeRecordsPageInCommandMenuProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { navigateCommandMenu } = useNavigateCommandMenu();
const { loadSelectedRecordsInContextStore } =
useLoadSelectedRecordsInContextStore({
objectNameSingular,
objectRecordIds,
objectMetadataItemId: objectMetadataItem.id,
});
const openMergeRecordsPageInCommandMenu = async () => {
await loadSelectedRecordsInContextStore();
navigateCommandMenu({
page: CommandMenuPages.MergeRecords,
pageTitle: t(msg`Merge records`),
pageIcon: IconArrowMerge,
});
};
return {
openMergeRecordsPageInCommandMenu,
};
};
@@ -0,0 +1,68 @@
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useRecoilCallback } from 'recoil';
type UseLoadSelectedRecordsInContextStoreProps = {
objectNameSingular: string;
objectRecordIds: string[];
objectMetadataItemId: string;
};
export const useLoadSelectedRecordsInContextStore = ({
objectNameSingular,
objectRecordIds,
objectMetadataItemId,
}: UseLoadSelectedRecordsInContextStoreProps) => {
const { upsertRecords } = useUpsertRecordsInStore();
const { findManyRecordsLazy } = useLazyFindManyRecords({
objectNameSingular,
filter: {
id: {
in: objectRecordIds,
},
},
});
const loadSelectedRecordsInContextStore = useRecoilCallback(
({ set }) => {
return async () => {
set(
contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({
instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID,
}),
objectMetadataItemId,
);
set(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID,
}),
{
mode: 'selection',
selectedRecordIds: objectRecordIds,
},
);
set(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID,
}),
objectRecordIds.length,
);
const records = await findManyRecordsLazy();
upsertRecords(records.records);
};
},
[objectRecordIds, objectMetadataItemId, findManyRecordsLazy, upsertRecords],
);
return {
loadSelectedRecordsInContextStore,
};
};
@@ -4,10 +4,13 @@ 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 { useFindDuplicateRecordsQuery } from '@/object-record/hooks/useFindDuplicatesRecordsQuery';
import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQuery';
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';
import { getOperationName } from '@apollo/client/utilities';
export type MergeManySettings = {
conflictPriorityIndex: number;
@@ -43,6 +46,15 @@ export const useMergeManyRecords = <
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
const { findOneRecordQuery } = useFindOneRecordQuery({
objectNameSingular,
recordGqlFields: computedRecordGqlFields,
});
const { findDuplicateRecordsQuery } = useFindDuplicateRecordsQuery({
objectNameSingular,
});
type MergeManyRecordsProps = {
recordIds: string[];
mergeSettings: MergeManySettings;
@@ -74,6 +86,10 @@ export const useMergeManyRecords = <
fetchPolicy: 'no-cache',
errorPolicy: 'ignore',
}),
refetchQueries: [
getOperationName(findOneRecordQuery) ?? '',
getOperationName(findDuplicateRecordsQuery) ?? '',
].filter(Boolean),
});
setLoading(false);
@@ -90,6 +106,8 @@ export const useMergeManyRecords = <
},
[
apolloCoreClient,
findDuplicateRecordsQuery,
findOneRecordQuery,
mergeManyRecordsMutation,
objectMetadataItem.namePlural,
refetchAggregateQueries,
@@ -48,6 +48,7 @@ export const MergePreviewTab = ({
targetObjectNameSingular: objectNameSingular,
id: mergePreviewRecord.id,
}}
showDuplicatesSection={false}
isInRightDrawer={true}
/>
</Section>
@@ -26,6 +26,7 @@ export const MergeRecordTab = ({
id: recordId,
}}
isInRightDrawer={true}
showDuplicatesSection={false}
/>
</Section>
);
@@ -1,9 +1,13 @@
import { useLazyQuery } from '@apollo/client';
import { useEffect, useMemo, useState } from 'react';
import { useRecoilCallback } from 'recoil';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { RecordGqlOperationFindManyResult } from '@/object-record/graphql/types/RecordGqlOperationFindManyResult';
import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery';
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';
@@ -46,20 +50,23 @@ export const useMergeRecordRelationships = ({
}).fields;
}, [objectMetadataItem, objectMetadataItems]);
const { findManyRecordsLazy } = useLazyFindManyRecords({
const apolloCoreClient = useApolloCoreClient();
const { findManyRecordsQuery } = useFindManyRecordsQuery({
objectNameSingular,
recordGqlFields,
filter: {
id: {
in: selectedRecords.map((record) => record.id),
},
},
});
const [findManyRecords] = useLazyQuery<RecordGqlOperationFindManyResult>(
findManyRecordsQuery,
{
fetchPolicy: 'cache-first',
client: apolloCoreClient,
},
);
useEffect(() => {
if (selectedRecords.length === 0 || !previewRecordId) {
setCompleteRecords([]);
setError(undefined);
return;
}
@@ -68,7 +75,29 @@ export const useMergeRecordRelationships = ({
setError(undefined);
try {
const { records } = await findManyRecordsLazy();
const result = await findManyRecords({
variables: {
filter: {
id: {
in: selectedRecords.map((record) => record.id),
},
},
},
});
const records = getRecordsFromRecordConnection({
recordConnection: {
edges: result?.data?.[objectMetadataItem.namePlural]?.edges ?? [],
pageInfo: result?.data?.[objectMetadataItem.namePlural]
?.pageInfo ?? {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
endCursor: '',
},
},
});
setCompleteRecords(records || []);
} catch (fetchError) {
const errorMessage =
@@ -78,14 +107,18 @@ export const useMergeRecordRelationships = ({
setError(
new Error(`Failed to merge record relationships: ${errorMessage}`),
);
setCompleteRecords([]);
} finally {
setIsLoading(false);
}
};
fetchCompleteRecords();
}, [selectedRecords, findManyRecordsLazy, previewRecordId]);
}, [
selectedRecords,
findManyRecords,
previewRecordId,
objectMetadataItem.namePlural,
]);
const mergedRelationshipData = useMemo((): MergedRelationshipData => {
return mergeRecordRelationshipData(
@@ -1,15 +1,12 @@
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
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 { AppPath } from '@/types/AppPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useNavigateApp } from '~/hooks/useNavigateApp';
import { mergeSettingsState } from '../states/mergeSettingsState';
type UseMergeRecordsActionsProps = {
@@ -32,25 +29,9 @@ export const useMergeRecordsActions = ({
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 { closeCommandMenu } = useCommandMenu();
const navigate = useNavigateApp();
const handleMergeRecords = async () => {
try {
const mergedRecord = await mergeManyRecords({
@@ -68,11 +49,11 @@ export const useMergeRecordsActions = ({
enqueueSuccessSnackBar({
message: t`Successfully merged ${recordCount} records`,
});
closeCommandMenu();
resetTableRowSelection();
openRecordInCommandMenu({
navigate(AppPath.RecordShowPage, {
objectNameSingular,
recordId: mergedRecord.id,
objectRecordId: mergedRecord.id,
});
} catch (error) {
enqueueErrorSnackBar({
@@ -51,7 +51,13 @@ type CardComponentProps = {
isInRightDrawer?: boolean;
};
type CardComponentType = (props: CardComponentProps) => JSX.Element | null;
type CardComponentType = (
props: CardComponentProps | FieldsCardComponentProps,
) => JSX.Element | null;
type FieldsCardComponentProps = CardComponentProps & {
showDuplicatesSection?: boolean;
};
const LoadingSkeleton = () => {
const theme = useTheme();
@@ -103,11 +109,16 @@ export const CardComponents: Record<CardType, CardComponentType> = {
/>
),
[CardType.FieldCard]: ({ targetableObject, isInRightDrawer }) => (
[CardType.FieldCard]: ({
targetableObject,
isInRightDrawer,
showDuplicatesSection,
}: FieldsCardComponentProps) => (
<StyledGreyBox isInRightDrawer={isInRightDrawer}>
<FieldsCard
objectNameSingular={targetableObject.targetObjectNameSingular}
objectRecordId={targetableObject.id}
showDuplicatesSection={showDuplicatesSection}
/>
</StyledGreyBox>
),
@@ -26,6 +26,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
type FieldsCardProps = {
objectNameSingular: string;
objectRecordId: string;
showDuplicatesSection?: boolean;
};
const INPUT_ID_PREFIX = 'fields-card';
@@ -33,6 +34,7 @@ const INPUT_ID_PREFIX = 'fields-card';
export const FieldsCard = ({
objectNameSingular,
objectRecordId,
showDuplicatesSection = true,
}: FieldsCardProps) => {
const { recordLoading, labelIdentifierFieldMetadataItem, isPrefetchLoading } =
useRecordShowContainerData({
@@ -204,10 +206,12 @@ export const FieldsCard = ({
</>
)}
</PropertyBox>
<RecordDetailDuplicatesSection
objectRecordId={objectRecordId}
objectNameSingular={objectNameSingular}
/>
{showDuplicatesSection && (
<RecordDetailDuplicatesSection
objectRecordId={objectRecordId}
objectNameSingular={objectNameSingular}
/>
)}
{boxedRelationFieldMetadataItems?.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
@@ -1,3 +1,4 @@
import { useOpenMergeRecordsPageInCommandMenu } from '@/command-menu/hooks/useOpenMergeRecordsPageInCommandMenu';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordChip } from '@/object-record/components/RecordChip';
import { useFindDuplicateRecords } from '@/object-record/hooks/useFindDuplicateRecords';
@@ -5,7 +6,10 @@ import { RecordDetailRecordsList } from '@/object-record/record-show/record-deta
import { RecordDetailRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsListItem';
import { RecordDetailSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailSection';
import { RecordDetailSectionHeader } from '@/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader';
import { isDefined } from 'twenty-shared/utils';
import { IconArrowMerge } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
export const RecordDetailDuplicatesSection = ({
objectRecordId,
@@ -24,12 +28,34 @@ export const RecordDetailDuplicatesSection = ({
skip: !isDefined(objectMetadataItem.duplicateCriteria),
});
const duplicateRecords = queryResults?.[0] ?? [];
const duplicateRecordIds = [
...duplicateRecords.map((record) => record.id),
objectRecordId,
];
const { openMergeRecordsPageInCommandMenu } =
useOpenMergeRecordsPageInCommandMenu({
objectNameSingular,
objectRecordIds: duplicateRecordIds,
});
if (!queryResults || !queryResults[0] || queryResults[0].length === 0)
return null;
return (
<RecordDetailSection>
<RecordDetailSectionHeader title="Duplicates" />
<RecordDetailSectionHeader
title="Duplicates"
rightAdornment={
<LightIconButton
className="displayOnHover"
Icon={IconArrowMerge}
accent="tertiary"
onClick={openMergeRecordsPageInCommandMenu}
/>
}
/>
<RecordDetailRecordsList>
{queryResults[0].slice(0, 5).map((duplicateRecord) => (
<RecordDetailRecordsListItem key={duplicateRecord.id}>