feat: merge records button in RecordDetailDuplicatesSection (#13537)
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
+43
@@ -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,
|
||||
};
|
||||
};
|
||||
+68
@@ -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,
|
||||
|
||||
+1
@@ -48,6 +48,7 @@ export const MergePreviewTab = ({
|
||||
targetObjectNameSingular: objectNameSingular,
|
||||
id: mergePreviewRecord.id,
|
||||
}}
|
||||
showDuplicatesSection={false}
|
||||
isInRightDrawer={true}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ export const MergeRecordTab = ({
|
||||
id: recordId,
|
||||
}}
|
||||
isInRightDrawer={true}
|
||||
showDuplicatesSection={false}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
|
||||
+45
-12
@@ -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(
|
||||
|
||||
+8
-27
@@ -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({
|
||||
|
||||
+13
-2
@@ -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>
|
||||
),
|
||||
|
||||
+8
-4
@@ -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}
|
||||
|
||||
+27
-1
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user