fix: duplicate merge button bug (#15284)

Fixes - https://github.com/twentyhq/twenty/issues/15263

- Replaced `useLoadSelectedRecordsInContextStore` with
`useLoadMergeRecords` in `useOpenMergeRecordsPageInCommandMenu` for
improved functionality.
- Updated `useMergePreview`, `useMergeRecordsActions`, and
`useMergeRecordsSettings` to utilize `mergeRecordsState` instead of the
deprecated context store hook.
- Cleaned up imports and ensured consistency across merge-related hooks.


https://github.com/user-attachments/assets/453539c9-7f2b-4e8c-bfa1-3ceebca07081

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Ranjeet Baraik
2025-10-24 22:33:11 +05:30
committed by GitHub
parent 9b2a73d50a
commit e613b15c5a
34 changed files with 366 additions and 350 deletions
@@ -1,74 +0,0 @@
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 { upsertRecordsInStore } = 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();
upsertRecordsInStore(records ?? []);
};
},
[
objectRecordIds,
objectMetadataItemId,
findManyRecordsLazy,
upsertRecordsInStore,
],
);
return {
loadSelectedRecordsInContextStore,
};
};
@@ -103,12 +103,11 @@ export const useMergeManyRecords = <
if (!preview) {
await refetchAggregateQueries();
registerObjectOperation(objectNameSingular, {
type: 'merge-records',
});
}
registerObjectOperation(objectNameSingular, {
type: 'merge-records',
});
return mergedObject.data?.[mutationResponseField] ?? null;
} catch (error) {
setLoading(false);
@@ -6,6 +6,8 @@ const StyledListItem = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
display: flex;
height: ${({ theme }) => theme.spacing(10)};
padding-left: ${({ theme }) => theme.spacing(3)};
padding-right: ${({ theme }) => theme.spacing(2)};
`;
export { StyledListItem as RecordDetailRecordsListItemContainer };
@@ -60,9 +60,6 @@ const StyledListItem = styled(RecordDetailRecordsListItemContainer)<{
}
`}
padding-left: ${({ theme }) => theme.spacing(3)};
padding-right: ${({ theme }) => theme.spacing(2)};
&:hover {
.displayOnHover {
opacity: 1;
@@ -7,8 +7,10 @@ import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTab
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useMergeRecordsSettings } from '@/object-record/record-merge/hooks/useMergeRecordsSettings';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { useMergePreview } from '@/object-record/record-merge/hooks/useMergePreview';
import { MergeRecordsTabId } from '@/object-record/record-merge/types/MergeRecordsTabId';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useMergeRecordsContainerTabs } from '../hooks/useMergeRecordsContainerTabs';
import { MergePreviewTab } from './MergePreviewTab';
import { MergeRecordTab } from './MergeRecordTab';
@@ -37,41 +39,46 @@ const StyledContentContainer = styled.div`
`;
type MergeRecordsContainerProps = {
componentInstanceId: string;
objectNameSingular: string;
};
export const MergeRecordsContainer = ({
componentInstanceId,
objectNameSingular,
}: MergeRecordsContainerProps) => {
const { selectedRecords } = useMergeRecordsSettings();
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
componentInstanceId,
);
const { selectedRecords } = useMergePreview({
objectNameSingular,
});
const { tabs } = useMergeRecordsContainerTabs(selectedRecords);
const instanceId = useAvailableComponentInstanceIdOrThrow(
CommandMenuPageComponentInstanceContext,
);
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
instanceId,
);
return (
<RightDrawerProvider value={{ isInRightDrawer: true }}>
<ShowPageContainer>
<StyledShowPageRightContainer>
<TabListComponentInstanceContext.Provider
value={{ instanceId: componentInstanceId }}
value={{ instanceId: instanceId }}
>
<StyledTabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={componentInstanceId}
componentInstanceId={instanceId}
/>
</TabListComponentInstanceContext.Provider>
<StyledContentContainer>
{activeTabId === MergeRecordsTabId.MERGE_PREVIEW && (
<MergePreviewTab objectNameSingular={objectNameSingular} />
)}
{activeTabId === MergeRecordsTabId.SETTINGS && <MergeSettingsTab />}
{activeTabId === MergeRecordsTabId.SETTINGS && (
<MergeSettingsTab objectNameSingular={objectNameSingular} />
)}
{selectedRecords.some((record) => record.id === activeTabId) && (
<MergeRecordTab
objectNameSingular={objectNameSingular}
@@ -1,3 +1,4 @@
import { useMergePreview } from '@/object-record/record-merge/hooks/useMergePreview';
import { useMergeRecordsSettings } from '@/object-record/record-merge/hooks/useMergeRecordsSettings';
import { Select } from '@/ui/input/components/Select';
import styled from '@emotion/styled';
@@ -10,9 +11,16 @@ const StyledSection = styled(Section)`
width: auto;
`;
export const MergeSettingsTab = () => {
const { mergeSettings, updatePriorityRecordIndex, selectedRecords } =
export const MergeSettingsTab = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const { mergeSettings, updatePriorityRecordIndex } =
useMergeRecordsSettings();
const { selectedRecords } = useMergePreview({
objectNameSingular,
});
const priorityOptions = selectedRecords.map((_, index) => ({
value: index,
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { commandMenuNavigationMorphItemsByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsByPageState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
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 { recordStoreRecordsSelector } from '@/object-record/record-store/states/selectors/recordStoreRecordsSelector';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isMergeInProgressState } from '../states/mergeInProgressState';
import { mergeSettingsState } from '../states/mergeSettingsState';
@@ -19,18 +20,29 @@ export const useMergePreview = ({
const [mergePreviewRecord, setMergePreviewRecord] =
useState<ObjectRecord | null>(null);
const [isGeneratingPreview, setIsGeneratingPreview] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const mergeSettings = useRecoilValue(mergeSettingsState);
const isMergeInProgress = useRecoilValue(isMergeInProgressState);
const { records: selectedRecords } = useFindManyRecordsSelectedInContextStore(
{
limit: 10,
},
);
const { mergeManyRecords } = useMergeManyRecords({
objectNameSingular,
});
const commandMenuNavigationMorphItemsByPage = useRecoilValue(
commandMenuNavigationMorphItemsByPageState,
);
const selectedRecordIds =
commandMenuNavigationMorphItemsByPage
.get(CommandMenuPages.MergeRecords)
?.map((morphItem) => morphItem.recordId) ?? [];
const selectedRecords = useRecoilValue(
recordStoreRecordsSelector({
recordIds: selectedRecordIds,
}),
);
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const { isLoading: isLoadingRelationships } = useMergeRecordRelationships({
@@ -41,7 +53,9 @@ export const useMergePreview = ({
useEffect(() => {
const fetchPreview = async () => {
if (selectedRecords.length < 2 || isMergeInProgress) return;
if (selectedRecords.length < 2 || isMergeInProgress || isInitialized)
return;
setIsGeneratingPreview(true);
try {
const previewRecord = await mergeManyRecords({
@@ -49,28 +63,34 @@ export const useMergePreview = ({
mergeSettings,
preview: true,
});
if (!previewRecord) {
setMergePreviewRecord(null);
return;
}
setMergePreviewRecord(previewRecord);
upsertRecordsInStore([previewRecord]);
} catch {
setMergePreviewRecord(null);
} finally {
setIsGeneratingPreview(false);
setIsInitialized(true);
}
};
if (selectedRecords.length > 0 && !isMergeInProgress) {
fetchPreview();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedRecords, mergeSettings]);
}, [
selectedRecords,
mergeSettings,
isMergeInProgress,
mergeManyRecords,
upsertRecordsInStore,
isInitialized,
]);
return {
selectedRecords,
mergePreviewRecord,
isGeneratingPreview: isGeneratingPreview || isLoadingRelationships,
};
@@ -2,8 +2,8 @@ import { useLingui } from '@lingui/react/macro';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { useMergeManyRecords } from '@/object-record/hooks/useMergeManyRecords';
import { useMergePreview } from '@/object-record/record-merge/hooks/useMergePreview';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { AppPath } from 'twenty-shared/types';
import { useNavigateApp } from '~/hooks/useNavigateApp';
@@ -18,11 +18,10 @@ export const useMergeRecordsActions = ({
objectNameSingular,
}: UseMergeRecordsActionsProps) => {
const mergeSettings = useRecoilValue(mergeSettingsState);
const { records: selectedRecords } = useFindManyRecordsSelectedInContextStore(
{
limit: 10,
},
);
const { selectedRecords } = useMergePreview({
objectNameSingular,
});
const { mergeManyRecords, loading: isMerging } = useMergeManyRecords({
objectNameSingular,
@@ -1,16 +1,10 @@
import { useRecoilState } from 'recoil';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { type 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);
@@ -24,7 +18,6 @@ export const useMergeRecordsSettings = () => {
};
return {
selectedRecords,
mergeSettings,
updateMergeSettings,
updatePriorityRecordIndex,
@@ -50,7 +50,6 @@ export const SummaryCard = ({
const recordIdentifier = useRecoilValue(
recordStoreIdentifierFamilySelector({
objectNameSingular,
recordId: objectRecordId,
}),
);
@@ -3,19 +3,17 @@ import { selectorFamily } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { uncapitalize } from 'twenty-shared/utils';
export const recordStoreIdentifierFamilySelector = selectorFamily({
key: 'recordStoreIdentifierFamilySelector',
get:
({
recordId,
objectNameSingular,
}: {
recordId: string;
objectNameSingular: string;
}) =>
({ recordId }: { recordId: string }) =>
({ get }) => {
const recordFromStore = get(recordStoreFamilyState(recordId));
const objectNameSingular = uncapitalize(
recordFromStore?.__typename ?? '',
);
const objectMetadataItems = get(objectMetadataItemsState);
@@ -0,0 +1,37 @@
import { selectorFamily } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isDefined, uncapitalize } from 'twenty-shared/utils';
export const recordStoreIdentifiersFamilySelector = selectorFamily({
key: 'recordStoreIdentifiersFamilySelector',
get:
({ recordIds }: { recordIds: string[] }) =>
({ get }) => {
const objectMetadataItems = get(objectMetadataItemsState);
return recordIds
.map((recordId) => {
const recordFromStore = get(recordStoreFamilyState(recordId));
const objectNameSingular = uncapitalize(
recordFromStore?.__typename ?? '',
);
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectNameSingular,
);
if (!objectMetadataItem || !recordFromStore) {
return null;
}
return getObjectRecordIdentifier({
objectMetadataItem: objectMetadataItem,
record: recordFromStore,
});
})
.filter(isDefined);
},
});
@@ -0,0 +1,16 @@
import { selectorFamily } from 'recoil';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isDefined } from 'twenty-shared/utils';
export const recordStoreRecordsSelector = selectorFamily({
key: 'recordStoreRecordsSelector',
get:
({ recordIds }: { recordIds: string[] }) =>
({ get }) => {
const records = recordIds
.map((recordId) => get(recordStoreFamilyState(recordId)))
.filter(isDefined);
return records;
},
});