feat: merge records (#13436)
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
+55
-21
@@ -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
@@ -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 />],
|
||||
|
||||
+65
@@ -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,
|
||||
};
|
||||
};
|
||||
+55
@@ -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>
|
||||
);
|
||||
};
|
||||
+32
@@ -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>
|
||||
);
|
||||
};
|
||||
+87
@@ -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>
|
||||
);
|
||||
};
|
||||
+72
@@ -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>
|
||||
);
|
||||
};
|
||||
+38
@@ -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,
|
||||
};
|
||||
};
|
||||
+126
@@ -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,
|
||||
};
|
||||
};
|
||||
+92
@@ -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,
|
||||
};
|
||||
};
|
||||
+43
@@ -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 };
|
||||
};
|
||||
+32
@@ -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,
|
||||
};
|
||||
};
|
||||
+9
@@ -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 };
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export type MergeRecordsTabIdType = 'merge-preview' | 'settings';
|
||||
|
||||
export enum MergeRecordsTabId {
|
||||
MERGE_PREVIEW = 'merge-preview',
|
||||
SETTINGS = 'settings',
|
||||
}
|
||||
+356
@@ -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({});
|
||||
});
|
||||
});
|
||||
+32
@@ -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;
|
||||
};
|
||||
+21
@@ -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) : '';
|
||||
};
|
||||
+13
@@ -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;
|
||||
};
|
||||
+20
@@ -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,
|
||||
);
|
||||
};
|
||||
+34
@@ -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;
|
||||
};
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
|
||||
export const getMergeManyRecordsMutationResponseField = (
|
||||
objectNamePlural: string,
|
||||
): string => {
|
||||
return `merge${capitalize(objectNamePlural)}`;
|
||||
};
|
||||
+2
@@ -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,
|
||||
|
||||
+437
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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;
|
||||
};
|
||||
+17
@@ -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;
|
||||
}
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
+1
@@ -12,4 +12,5 @@ export const RESOLVER_METHOD_NAMES = {
|
||||
RESTORE_ONE: 'restoreOne',
|
||||
DESTROY_MANY: 'destroyMany',
|
||||
DESTROY_ONE: 'destroyOne',
|
||||
MERGE_MANY: 'mergeMany',
|
||||
} as const;
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+45
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
+8
@@ -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
|
||||
|
||||
+3
@@ -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;
|
||||
}
|
||||
|
||||
+16
-6
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user