feat: merge records (#13436)

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2025-07-31 10:06:36 +00:00
committed by GitHub
co-authored by Félix Malfait
parent 00d12e854a
commit 06e0814ce8
45 changed files with 2047 additions and 29 deletions
@@ -1,4 +1,5 @@
import { ActionLink } from '@/action-menu/actions/components/ActionLink';
import { ActionOpenSidePanelPage } from '@/action-menu/actions/components/ActionOpenSidePanelPage';
import { DeleteMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/components/DeleteMultipleRecordsAction';
import { DestroyMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/components/DestroyMultipleRecordsAction';
import { ExportMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/components/ExportMultipleRecordsAction';
@@ -24,6 +25,7 @@ import { ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { BACKEND_BATCH_REQUEST_MAX_COUNT } from '@/object-record/constants/BackendBatchRequestMaxCount';
@@ -31,8 +33,11 @@ import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { MUTATION_MAX_MERGE_RECORDS } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import {
IconArrowMerge,
IconBuildingSkyscraper,
IconCheckbox,
IconChevronDown,
@@ -173,13 +178,42 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
component: <ExportSingleRecordAction />,
requiredPermissionFlag: PermissionFlagType.EXPORT_CSV,
},
[MultipleRecordsActionKeys.MERGE]: {
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
key: MultipleRecordsActionKeys.MERGE,
label: msg`Merge records`,
shortLabel: msg`Merge`,
position: 5,
Icon: IconArrowMerge,
accent: 'default',
isPinned: false,
shouldBeRegistered: ({
objectMetadataItem,
numberOfSelectedRecords,
objectPermissions,
}) =>
isDefined(objectMetadataItem?.duplicateCriteria) &&
isDefined(numberOfSelectedRecords) &&
Boolean(objectPermissions.canUpdateObjectRecords) &&
Boolean(objectPermissions.canDestroyObjectRecords) &&
numberOfSelectedRecords <= MUTATION_MAX_MERGE_RECORDS,
availableOn: [ActionViewType.INDEX_PAGE_BULK_SELECTION],
component: (
<ActionOpenSidePanelPage
page={CommandMenuPages.MergeRecords}
pageTitle={msg`Merge records`}
pageIcon={IconArrowMerge}
/>
),
},
[MultipleRecordsActionKeys.EXPORT]: {
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
key: MultipleRecordsActionKeys.EXPORT,
label: msg`Export records`,
shortLabel: msg`Export`,
position: 5,
position: 6,
Icon: IconFileExport,
accent: 'default',
isPinned: false,
@@ -194,7 +228,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.IMPORT_RECORDS,
label: msg`Import records`,
shortLabel: msg`Import`,
position: 6,
position: 7,
Icon: IconFileImport,
accent: 'default',
isPinned: false,
@@ -210,7 +244,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.EXPORT_VIEW,
label: msg`Export view`,
shortLabel: msg`Export`,
position: 7,
position: 8,
Icon: IconFileExport,
accent: 'default',
isPinned: false,
@@ -225,7 +259,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: SingleRecordActionKeys.DELETE,
label: msg`Delete`,
shortLabel: msg`Delete`,
position: 8,
position: 9,
Icon: IconTrash,
accent: 'default',
isPinned: true,
@@ -252,7 +286,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: MultipleRecordsActionKeys.DELETE,
label: msg`Delete records`,
shortLabel: msg`Delete`,
position: 9,
position: 10,
Icon: IconTrash,
accent: 'default',
isPinned: true,
@@ -277,7 +311,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.SEE_DELETED_RECORDS,
label: msg`See deleted records`,
shortLabel: msg`Deleted records`,
position: 10,
position: 11,
Icon: IconRotate2,
accent: 'default',
isPinned: false,
@@ -292,7 +326,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.CREATE_NEW_VIEW,
label: msg`Create View`,
shortLabel: msg`Create View`,
position: 11,
position: 12,
Icon: IconLayout,
accent: 'default',
isPinned: false,
@@ -307,7 +341,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.HIDE_DELETED_RECORDS,
label: msg`Hide deleted records`,
shortLabel: msg`Hide deleted`,
position: 12,
position: 13,
Icon: IconEyeOff,
accent: 'default',
isPinned: false,
@@ -322,7 +356,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: SingleRecordActionKeys.DESTROY,
label: msg`Permanently destroy record`,
shortLabel: msg`Destroy`,
position: 13,
position: 14,
Icon: IconTrashX,
accent: 'danger',
isPinned: true,
@@ -342,7 +376,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
scope: ActionScope.RecordSelection,
key: SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD,
label: msg`Navigate to previous record`,
position: 14,
position: 15,
isPinned: true,
Icon: IconChevronUp,
shouldBeRegistered: ({ isInRightDrawer }) => !isInRightDrawer,
@@ -354,7 +388,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
scope: ActionScope.RecordSelection,
key: SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD,
label: msg`Navigate to next record`,
position: 15,
position: 16,
isPinned: true,
Icon: IconChevronDown,
shouldBeRegistered: ({ isInRightDrawer }) => !isInRightDrawer,
@@ -367,7 +401,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: MultipleRecordsActionKeys.DESTROY,
label: msg`Permanently destroy records`,
shortLabel: msg`Destroy`,
position: 16,
position: 17,
Icon: IconTrashX,
accent: 'danger',
isPinned: true,
@@ -393,7 +427,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: SingleRecordActionKeys.RESTORE,
label: msg`Restore record`,
shortLabel: msg`Restore`,
position: 17,
position: 18,
Icon: IconRefresh,
accent: 'default',
isPinned: true,
@@ -422,7 +456,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: MultipleRecordsActionKeys.RESTORE,
label: msg`Restore records`,
shortLabel: msg`Restore`,
position: 18,
position: 19,
Icon: IconRefresh,
accent: 'default',
isPinned: true,
@@ -448,7 +482,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_WORKFLOWS,
label: msg`Go to workflows`,
shortLabel: msg`See workflows`,
position: 19,
position: 20,
Icon: IconSettingsAutomation,
accent: 'default',
isPinned: false,
@@ -480,7 +514,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_PEOPLE,
label: msg`Go to People`,
shortLabel: msg`People`,
position: 20,
position: 21,
Icon: IconUser,
isPinned: false,
availableOn: [
@@ -511,7 +545,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_COMPANIES,
label: msg`Go to Companies`,
shortLabel: msg`Companies`,
position: 21,
position: 22,
Icon: IconBuildingSkyscraper,
isPinned: false,
availableOn: [
@@ -542,7 +576,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_OPPORTUNITIES,
label: msg`Go to Opportunities`,
shortLabel: msg`Opportunities`,
position: 22,
position: 23,
Icon: IconTargetArrow,
isPinned: false,
availableOn: [
@@ -575,7 +609,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_SETTINGS,
label: msg`Go to Settings`,
shortLabel: msg`Settings`,
position: 23,
position: 24,
Icon: IconSettings,
isPinned: false,
availableOn: [
@@ -601,7 +635,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_TASKS,
label: msg`Go to Tasks`,
shortLabel: msg`Tasks`,
position: 24,
position: 25,
Icon: IconCheckbox,
isPinned: false,
availableOn: [
@@ -632,7 +666,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_NOTES,
label: msg`Go to Notes`,
shortLabel: msg`Notes`,
position: 25,
position: 26,
Icon: IconCheckbox,
isPinned: false,
availableOn: [
@@ -1,6 +1,7 @@
export enum MultipleRecordsActionKeys {
DELETE = 'delete-multiple-records',
EXPORT = 'export-multiple-records',
MERGE = 'merge-multiple-records',
DESTROY = 'destroy-multiple-records',
RESTORE = 'restore-multiple-records',
}
@@ -3,6 +3,7 @@ import { CommandMenuAIChatThreadsPage } from '@/command-menu/pages/AIChatThreads
import { CommandMenuAskAIPage } from '@/command-menu/pages/ask-ai/components/CommandMenuAskAIPage';
import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage';
import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage';
import { CommandMenuMergeRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuMergeRecordPage';
import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage';
import { CommandMenuEditRichTextPage } from '@/command-menu/pages/rich-text-page/components/CommandMenuEditRichTextPage';
import { CommandMenuSearchRecordsPage } from '@/command-menu/pages/search/components/CommandMenuSearchRecordsPage';
@@ -19,6 +20,7 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map<
>([
[CommandMenuPages.Root, <CommandMenu />],
[CommandMenuPages.ViewRecord, <CommandMenuRecordPage />],
[CommandMenuPages.MergeRecords, <CommandMenuMergeRecordPage />],
[CommandMenuPages.ViewEmailThread, <CommandMenuMessageThreadPage />],
[CommandMenuPages.ViewCalendarEvent, <CommandMenuCalendarEventPage />],
[CommandMenuPages.EditRichText, <CommandMenuEditRichTextPage />],
@@ -0,0 +1,65 @@
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/record-filter-group/states/context/RecordFilterGroupsComponentInstanceContext';
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { MergeRecordsContainer } from '@/object-record/record-merge/components/MergeRecordsContainer';
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import styled from '@emotion/styled';
const StyledRightDrawerRecord = styled.div<{
isMobile: boolean;
}>`
height: ${({ theme, isMobile }) => {
const mobileOffset = isMobile ? theme.spacing(16) : '0px';
return `calc(100% - ${mobileOffset})`;
}};
`;
export const CommandMenuMergeRecordPage = () => {
const isMobile = useIsMobile();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const commandMenuPageInstanceId = useComponentInstanceStateContext(
CommandMenuPageComponentInstanceContext,
)?.instanceId;
if (!commandMenuPageInstanceId) {
throw new Error('Command menu page instance id is not defined');
}
return (
<RecordFilterGroupsComponentInstanceContext.Provider
value={{ instanceId: `record-merge-${commandMenuPageInstanceId}` }}
>
<RecordFiltersComponentInstanceContext.Provider
value={{ instanceId: `record-merge-${commandMenuPageInstanceId}` }}
>
<RecordSortsComponentInstanceContext.Provider
value={{ instanceId: `record-merge-${commandMenuPageInstanceId}` }}
>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID }}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: commandMenuPageInstanceId }}
>
<StyledRightDrawerRecord isMobile={isMobile}>
<MergeRecordsContainer
objectNameSingular={objectMetadataItem.nameSingular}
componentInstanceId={commandMenuPageInstanceId}
/>
</StyledRightDrawerRecord>
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</RecordSortsComponentInstanceContext.Provider>
</RecordFiltersComponentInstanceContext.Provider>
</RecordFilterGroupsComponentInstanceContext.Provider>
);
};
@@ -1,6 +1,7 @@
export enum CommandMenuPages {
Root = 'root',
ViewRecord = 'view-record',
MergeRecords = 'merge-records',
ViewEmailThread = 'view-email-thread',
ViewCalendarEvent = 'view-calendar-event',
EditRichText = 'edit-rich-text',
@@ -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)}`;
};
@@ -13,6 +13,7 @@ import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/gr
import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service';
import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
import { GraphqlQueryMergeManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service';
import { GraphqlQueryRestoreManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service';
import { GraphqlQueryRestoreOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service';
import { GraphqlQueryUpdateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service';
@@ -33,6 +34,7 @@ const graphqlQueryResolvers = [
GraphqlQueryFindDuplicatesResolverService,
GraphqlQueryFindManyResolverService,
GraphqlQueryFindOneResolverService,
GraphqlQueryMergeManyResolverService,
GraphqlQueryRestoreManyResolverService,
GraphqlQueryRestoreOneResolverService,
GraphqlQueryUpdateManyResolverService,
@@ -0,0 +1,437 @@
import { Injectable, Logger } from '@nestjs/common';
import {
MUTATION_MAX_MERGE_RECORDS,
QUERY_MAX_RECORDS,
} from 'twenty-shared/constants';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import {
GraphqlQueryBaseResolverService,
GraphqlQueryResolverExecutionArgs,
} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { MergeManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { FieldMetadataRelationSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
@Injectable()
export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolverService<
MergeManyResolverArgs,
ObjectRecord
> {
private readonly logger = new Logger(
GraphqlQueryMergeManyResolverService.name,
);
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
): Promise<ObjectRecord> {
const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
const { ids, conflictPriorityIndex, dryRun } = executionArgs.args;
const { roleId } = executionArgs;
const recordsToMerge = await this.fetchRecordsToMerge(executionArgs, ids);
const priorityRecord = this.validateAndGetPriorityRecord(
recordsToMerge,
ids,
conflictPriorityIndex,
);
const mergedData = this.performDeepMerge(
recordsToMerge,
priorityRecord.id,
objectMetadataItemWithFieldMaps,
);
if (dryRun) {
return this.createDryRunResponse(
priorityRecord,
mergedData,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
}
const idsToDelete = ids.filter((id) => id !== priorityRecord.id);
await this.migrateRelatedRecords(
executionArgs,
idsToDelete,
priorityRecord.id,
);
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
await queryBuilder
.softDelete()
.whereInIds(idsToDelete)
.returning('*')
.execute();
const updatedRecord = await this.updatePriorityRecord(
executionArgs,
priorityRecord.id,
mergedData,
);
if (roleId) {
await this.processNestedRelations(
executionArgs,
[updatedRecord],
authContext,
roleId,
);
}
return this.formatResponse(
updatedRecord,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
}
private async fetchRecordsToMerge(
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
ids: string[],
): Promise<ObjectRecord[]> {
const recordsToMerge = await executionArgs.repository.find({
where: { id: In(ids) },
});
if (recordsToMerge.length !== ids.length) {
throw new GraphqlQueryRunnerException(
'One or more records not found',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
return recordsToMerge as ObjectRecord[];
}
private validateAndGetPriorityRecord(
recordsToMerge: ObjectRecord[],
ids: string[],
conflictPriorityIndex: number,
): ObjectRecord {
const priorityRecordId = ids[conflictPriorityIndex];
const priorityRecord = recordsToMerge.find(
(record) => record.id === priorityRecordId,
);
if (!priorityRecord) {
throw new GraphqlQueryRunnerException(
'Priority record not found',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
return priorityRecord;
}
private performDeepMerge(
recordsToMerge: ObjectRecord[],
priorityRecordId: string,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
): Partial<ObjectRecord> {
const mergedResult: Partial<ObjectRecord> = {};
const allFieldNames = new Set<string>();
recordsToMerge.forEach((record) => {
Object.keys(record).forEach((fieldName) => {
if (
!this.shouldExcludeFieldFromMerge(
fieldName,
objectMetadataItemWithFieldMaps,
)
) {
allFieldNames.add(fieldName);
}
});
});
allFieldNames.forEach((fieldName) => {
const recordsWithValues: { value: unknown; recordId: string }[] = [];
recordsToMerge.forEach((record) => {
const fieldValue = record[fieldName];
if (hasRecordFieldValue(fieldValue)) {
recordsWithValues.push({ value: fieldValue, recordId: record.id });
}
});
if (recordsWithValues.length === 0) {
return;
} else if (recordsWithValues.length === 1) {
mergedResult[fieldName] = recordsWithValues[0].value;
} else {
const priorityValue = recordsWithValues.find(
(item) => item.recordId === priorityRecordId,
);
if (priorityValue) {
mergedResult[fieldName] = priorityValue.value;
} else {
mergedResult[fieldName] = recordsWithValues[0].value;
}
}
});
return mergedResult;
}
private shouldExcludeFieldFromMerge(
fieldName: string,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
): boolean {
const fieldMetadata = Object.values(
objectMetadataItemWithFieldMaps.fieldsById,
).find((field) => field?.name === fieldName);
if (fieldMetadata?.isSystem) {
return true;
}
return false;
}
private createDryRunResponse(
priorityRecord: ObjectRecord,
mergedData: Partial<ObjectRecord>,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
objectMetadataMaps: ObjectMetadataMaps,
): ObjectRecord {
const dryRunRecord = {
...priorityRecord,
...mergedData,
id: uuidv4(),
deletedAt: new Date().toISOString(),
} as ObjectRecord;
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return typeORMObjectRecordsParser.processRecord({
objectRecord: dryRunRecord,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
}
private async updatePriorityRecord(
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
priorityRecordId: string,
mergedData: Partial<ObjectRecord>,
): Promise<ObjectRecord> {
const { objectMetadataItemWithFieldMaps } = executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,
);
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
});
const updatedObjectRecords = await queryBuilder
.update()
.set(mergedData)
.where({ id: priorityRecordId })
.returning(columnsToReturn)
.execute();
if (!updatedObjectRecords.generatedMaps.length) {
throw new GraphqlQueryRunnerException(
'Failed to update record',
GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
);
}
const updatedRecord = updatedObjectRecords.generatedMaps[0] as ObjectRecord;
return updatedRecord;
}
private async migrateRelatedRecords(
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
fromIds: string[],
toId: string,
): Promise<void> {
const { objectMetadataMaps, objectMetadataItemWithFieldMaps } =
executionArgs.options;
const relationFieldsPointingToCurrentObject = Object.values(
objectMetadataMaps.byId,
)
.filter(isDefined)
.flatMap((metadata) => {
const relationFields = Object.values(metadata.fieldsById)
.filter(isDefined)
.filter((field) =>
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION),
)
.filter(
(field) =>
field.relationTargetObjectMetadataId ===
objectMetadataItemWithFieldMaps.id && field.isActive,
);
return relationFields
.filter((field) => {
const relationSettings =
field.settings as FieldMetadataRelationSettings;
return (
relationSettings?.relationType === RelationType.MANY_TO_ONE &&
relationSettings?.joinColumnName
);
})
.map((field) => {
const relationSettings =
field.settings as FieldMetadataRelationSettings;
return {
objectMetadata: metadata,
fieldName: field.name,
fieldId: field.id,
joinColumnName: relationSettings.joinColumnName,
};
});
});
for (const relationField of relationFieldsPointingToCurrentObject) {
if (!relationField.joinColumnName) {
continue;
}
try {
const repository = executionArgs.workspaceDataSource.getRepository(
relationField.objectMetadata.nameSingular,
executionArgs.isExecutedByApiKey,
executionArgs.roleId,
);
const whereCondition = { [relationField.joinColumnName]: In(fromIds) };
const existingRecords = await repository.find({
where: whereCondition,
});
if (existingRecords.length > 0) {
await repository.update(whereCondition, {
[relationField.joinColumnName]: toId,
});
}
} catch (error) {
this.logger.warn(
`Failed to migrate relation field "${relationField.fieldName}" (${relationField.joinColumnName}) in object "${relationField.objectMetadata.nameSingular}":`,
error.message,
);
}
}
}
private async processNestedRelations(
executionArgs: GraphqlQueryResolverExecutionArgs<MergeManyResolverArgs>,
updatedRecords: ObjectRecord[],
authContext: AuthContext,
roleId: string,
): Promise<void> {
const { objectMetadataMaps, objectMetadataItemWithFieldMaps } =
executionArgs.options;
if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
await this.processNestedRelationsHelper.processNestedRelations({
objectMetadataMaps,
parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
parentObjectRecords: updatedRecords,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
limit: QUERY_MAX_RECORDS,
authContext,
workspaceDataSource: executionArgs.workspaceDataSource,
roleId,
shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
});
}
}
private formatResponse(
updatedRecord: ObjectRecord,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
objectMetadataMaps: ObjectMetadataMaps,
): ObjectRecord {
const typeORMObjectRecordsParser =
new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
return typeORMObjectRecordsParser.processRecord({
objectRecord: updatedRecord,
objectName: objectMetadataItemWithFieldMaps.nameSingular,
take: 1,
totalCount: 1,
});
}
async validate(
args: MergeManyResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
if (!isDefined(options.objectMetadataItemWithFieldMaps.duplicateCriteria)) {
throw new GraphqlQueryRunnerException(
`Merge is only available for objects with duplicate criteria. Object '${options.objectMetadataItemWithFieldMaps.nameSingular}' does not have duplicate criteria defined.`,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
const { ids, conflictPriorityIndex } = args;
if (!ids || ids.length < 2) {
throw new GraphqlQueryRunnerException(
'At least 2 record IDs are required for merge',
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
if (ids.length > MUTATION_MAX_MERGE_RECORDS) {
throw new GraphqlQueryRunnerException(
`Maximum ${MUTATION_MAX_MERGE_RECORDS} records can be merged at once`,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
if (conflictPriorityIndex < 0 || conflictPriorityIndex >= ids.length) {
throw new GraphqlQueryRunnerException(
`Invalid conflict priority '${conflictPriorityIndex}'. Valid options for ${ids.length} records: 0-${ids.length - 1}`,
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
}
}
@@ -0,0 +1,29 @@
export const hasRecordFieldValue = (value: unknown): boolean => {
if (value === null || value === undefined) {
return false;
}
if (typeof value === 'string') {
return value.trim() !== '';
}
if (typeof value === 'number') {
return !isNaN(value);
}
if (typeof value === 'boolean') {
return true;
}
if (Array.isArray(value)) {
return value.length > 0;
}
if (typeof value === 'object') {
const objectValue = value as Record<string, unknown>;
return Object.values(objectValue).some((val) => hasRecordFieldValue(val));
}
return true;
};
@@ -14,6 +14,7 @@ import {
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
MergeManyResolverArgs,
ResolverArgs,
ResolverArgsType,
UpdateManyResolverArgs,
@@ -145,6 +146,22 @@ export class QueryRunnerArgsFactory {
) ?? [],
),
} satisfies FindDuplicatesResolverArgs;
case ResolverArgsType.MergeMany:
return {
...args,
ids: (await Promise.all(
(args as MergeManyResolverArgs).ids?.map((id) =>
this.overrideValueByFieldMetadata(
'id',
id,
fieldMetadataMapByNameByName,
),
) ?? [],
)) as string[],
conflictPriorityIndex: (args as MergeManyResolverArgs)
.conflictPriorityIndex,
dryRun: (args as MergeManyResolverArgs).dryRun,
} satisfies MergeManyResolverArgs;
default:
return args;
}
@@ -8,6 +8,7 @@ import {
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
MergeManyResolverArgs,
RestoreManyResolverArgs,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
@@ -42,4 +43,6 @@ export type WorkspacePreQueryHookPayload<T> = T extends 'createMany'
? DestroyManyResolverArgs
: T extends 'destroyOne'
? DestroyOneResolverArgs
: never;
: T extends 'mergeMany'
? MergeManyResolverArgs
: never;
@@ -12,4 +12,5 @@ export const RESOLVER_METHOD_NAMES = {
RESTORE_ONE: 'restoreOne',
DESTROY_MANY: 'destroyMany',
DESTROY_ONE: 'destroyOne',
MERGE_MANY: 'mergeMany',
} as const;
@@ -11,6 +11,7 @@ import { DeleteOneResolverFactory } from './delete-one-resolver.factory';
import { FindDuplicatesResolverFactory } from './find-duplicates-resolver.factory';
import { FindManyResolverFactory } from './find-many-resolver.factory';
import { FindOneResolverFactory } from './find-one-resolver.factory';
import { MergeManyResolverFactory } from './merge-many-resolver.factory';
import { UpdateOneResolverFactory } from './update-one-resolver.factory';
export const workspaceResolverBuilderFactories = [
@@ -27,6 +28,7 @@ export const workspaceResolverBuilderFactories = [
DestroyManyResolverFactory,
RestoreOneResolverFactory,
RestoreManyResolverFactory,
MergeManyResolverFactory,
];
export const workspaceResolverBuilderMethodNames = {
@@ -46,5 +48,6 @@ export const workspaceResolverBuilderMethodNames = {
DestroyManyResolverFactory.methodName,
RestoreOneResolverFactory.methodName,
RestoreManyResolverFactory.methodName,
MergeManyResolverFactory.methodName,
],
} as const;
@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
MergeManyResolverArgs,
Resolver,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryMergeManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class MergeManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = RESOLVER_METHOD_NAMES.MERGE_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryMergeManyResolverService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<MergeManyResolverArgs> {
const internalContext = context;
return async (_source, args, _context, info) => {
const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
info,
objectMetadataMaps: internalContext.objectMetadataMaps,
objectMetadataItemWithFieldMaps:
internalContext.objectMetadataItemWithFieldMaps,
};
return await this.graphqlQueryRunnerService.execute(
args,
options,
MergeManyResolverFactory.methodName,
);
};
}
}
@@ -26,6 +26,7 @@ export enum ResolverArgsType {
RestoreMany = 'RestoreMany',
DestroyMany = 'DestroyMany',
DestroyOne = 'DestroyOne',
MergeMany = 'MergeMany',
}
export interface FindManyResolverArgs<
@@ -99,6 +100,12 @@ export interface RestoreManyResolverArgs<Filter = any> {
filter: Filter;
}
export interface MergeManyResolverArgs {
ids: string[];
conflictPriorityIndex: number;
dryRun?: boolean;
}
export interface DestroyOneResolverArgs {
id: string;
}
@@ -132,6 +139,7 @@ export type ResolverArgs =
| FindDuplicatesResolverArgs
| FindManyResolverArgs
| FindOneResolverArgs
| MergeManyResolverArgs
| RestoreManyResolverArgs
| RestoreOneResolverArgs
| UpdateManyResolverArgs
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { FindDuplicatesResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory';
import { MergeManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Injectable()
@@ -18,6 +19,8 @@ export class WorkspaceResolverBuilderService {
switch (methodName) {
case FindDuplicatesResolverFactory.methodName:
return isDefined(objectMetadata.duplicateCriteria);
case MergeManyResolverFactory.methodName:
return isDefined(objectMetadata.duplicateCriteria);
default:
return true;
}
@@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils';
import { DeleteManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory';
import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory';
import { DestroyOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory';
import { MergeManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory';
import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory';
import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory';
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
@@ -52,6 +53,7 @@ export class WorkspaceResolverFactory {
private readonly restoreOneResolverFactory: RestoreOneResolverFactory,
private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
private readonly mergeManyResolverFactory: MergeManyResolverFactory,
private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService,
private readonly featureFlagService: FeatureFlagService,
) {}
@@ -74,6 +76,7 @@ export class WorkspaceResolverFactory {
['findDuplicates', this.findDuplicatesResolverFactory],
['findMany', this.findManyResolverFactory],
['findOne', this.findOneResolverFactory],
['mergeMany', this.mergeManyResolverFactory],
['restoreMany', this.restoreManyResolverFactory],
['restoreOne', this.restoreOneResolverFactory],
['updateMany', this.updateManyResolverFactory],
@@ -154,12 +157,19 @@ export class WorkspaceResolverFactory {
throw new Error(`Unknown mutation resolver type: ${methodName}`);
}
// @ts-expect-error legacy noImplicitAny
resolvers.Mutation[resolverName] = resolverFactory.create({
authContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps: objectMetadata,
});
if (
this.workspaceResolverBuilderService.shouldBuildResolver(
objectMetadata,
methodName,
)
) {
// @ts-expect-error legacy noImplicitAny
resolvers.Mutation[resolverName] = resolverFactory.create({
authContext,
objectMetadataMaps,
objectMetadataItemWithFieldMaps: objectMetadata,
});
}
}
}
@@ -145,6 +145,22 @@ export const getResolverArgs = (
isNullable: false,
},
};
case 'mergeMany':
return {
ids: {
type: UUIDScalarType,
isNullable: false,
isArray: true,
},
conflictPriorityIndex: {
type: GraphQLInt,
isNullable: false,
},
dryRun: {
type: GraphQLBoolean,
isNullable: true,
},
};
default:
throw new Error(`Unknown resolver type: ${type}`);
}
@@ -41,6 +41,9 @@ export const getResolverName = (
case 'restoreMany':
return `restore${pascalCase(objectMetadata.namePlural)}`;
case 'mergeMany':
return `merge${pascalCase(objectMetadata.namePlural)}`;
default:
throw new Error(`Unknown resolver type: ${type}`);
}
@@ -0,0 +1 @@
export const MUTATION_MAX_MERGE_RECORDS = 9;
@@ -11,6 +11,7 @@ export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountA
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes';
export { MUTATION_MAX_MERGE_RECORDS } from './MutationMaxMergeRecords';
export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords';
export { QUERY_MAX_RECORDS } from './QueryMaxRecords';
export { RELATION_NESTED_QUERY_KEYWORDS } from './RelationNestedQueriesKeyword';
@@ -11,6 +11,7 @@ export {
IconArrowBackUp,
IconArrowDown,
IconArrowLeft,
IconArrowMerge,
IconArrowRight,
IconArrowsDiagonal,
IconArrowsVertical,
@@ -276,6 +277,15 @@ export {
IconSql,
IconSquare,
IconSquareKey,
IconSquareNumber1,
IconSquareNumber2,
IconSquareNumber3,
IconSquareNumber4,
IconSquareNumber5,
IconSquareNumber6,
IconSquareNumber7,
IconSquareNumber8,
IconSquareNumber9,
IconSquareRoundedCheck,
IconSquareRoundedX,
IconStatusChange,
+10
View File
@@ -73,6 +73,7 @@ export {
IconArrowBackUp,
IconArrowDown,
IconArrowLeft,
IconArrowMerge,
IconArrowRight,
IconArrowsDiagonal,
IconArrowsVertical,
@@ -338,6 +339,15 @@ export {
IconSql,
IconSquare,
IconSquareKey,
IconSquareNumber1,
IconSquareNumber2,
IconSquareNumber3,
IconSquareNumber4,
IconSquareNumber5,
IconSquareNumber6,
IconSquareNumber7,
IconSquareNumber8,
IconSquareNumber9,
IconSquareRoundedCheck,
IconSquareRoundedX,
IconStatusChange,