feat: merge records (#13436)
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@@ -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)}`;
|
||||
};
|
||||
Reference in New Issue
Block a user