diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx index a30a8496224..4a70e811c5f 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx @@ -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: , 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: ( + + ), + }, [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: [ diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys.ts index bc9c148d11f..eff98147ab3 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys.ts @@ -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', } diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx index 05ddfad993c..833a7cbeed4 100644 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx @@ -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, ], [CommandMenuPages.ViewRecord, ], + [CommandMenuPages.MergeRecords, ], [CommandMenuPages.ViewEmailThread, ], [CommandMenuPages.ViewCalendarEvent, ], [CommandMenuPages.EditRichText, ], diff --git a/packages/twenty-front/src/modules/command-menu/pages/record-page/components/CommandMenuMergeRecordPage.tsx b/packages/twenty-front/src/modules/command-menu/pages/record-page/components/CommandMenuMergeRecordPage.tsx new file mode 100644 index 00000000000..1edad710e67 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/record-page/components/CommandMenuMergeRecordPage.tsx @@ -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 ( + + + + + + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts index a860cb0034f..52c0459b8ff 100644 --- a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts +++ b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts @@ -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', diff --git a/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts new file mode 100644 index 00000000000..f5680ba773b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts @@ -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 => { + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecordsMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecordsMutation.ts new file mode 100644 index 00000000000..11630febf3e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecordsMutation.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-merge/components/MergePreviewTab.tsx b/packages/twenty-front/src/modules/object-record/record-merge/components/MergePreviewTab.tsx new file mode 100644 index 00000000000..94bea90da8c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-merge/components/MergePreviewTab.tsx @@ -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 ( + + Generating merge preview... + + ); + } + + if (!isDefined(mergePreviewRecord)) { + return null; + } + + return ( +
+ + + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordTab.tsx b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordTab.tsx new file mode 100644 index 00000000000..4d0919e59a9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordTab.tsx @@ -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 ( +
+ + + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsContainer.tsx b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsContainer.tsx new file mode 100644 index 00000000000..844cee1bcbb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsContainer.tsx @@ -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 ( + + + + + + + + {activeTabId === MergeRecordsTabId.MERGE_PREVIEW && ( + + )} + {activeTabId === MergeRecordsTabId.SETTINGS && } + {selectedRecords.some((record) => record.id === activeTabId) && ( + + )} + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsFooter.tsx b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsFooter.tsx new file mode 100644 index 00000000000..3fd47162024 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsFooter.tsx @@ -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 ( + + +