feat: find duplicate objects init (#4038)
* feat: find duplicate objects backend init * refactor: move duplicate criteria to constants * fix: correct constant usage after type change * feat: skip query generation in case its not necessary * feat: filter out existing duplicate * feat: FE queries and hooks * feat: show duplicates on FE * refactor: should-skip-query moved to workspace utils * refactor: naming improvements * refactor: current record typings/parsing improvements * refactor: throw error if existing record not found * fix: domain -> domainName duplicate criteria * refactor: fieldNames -> columnNames * docs: add explanation to duplicate criteria collection * feat: add person linkedinLinkUrl as duplicate criteria * feat: throw early when bot id and data are empty * refactor: trying to improve readability of filter criteria query * refactor: naming improvements * refactor: remove shouldSkipQuery * feat: resolve empty array in case of empty filter * feat: hide whole section in case of no duplicates * feat: FE display list the same way as relations * test: basic unit test coverage * Refactor Record detail section front * Use Create as input argument of findDuplicates * Improve coverage * Fix --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -16,6 +16,7 @@ import { useGenerateCreateManyRecordMutation } from '@/object-record/hooks/useGe
|
||||
import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
|
||||
import { useGenerateDeleteManyRecordMutation } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation';
|
||||
import { useGenerateExecuteQuickActionOnOneRecordMutation } from '@/object-record/hooks/useGenerateExecuteQuickActionOnOneRecordMutation';
|
||||
import { useGenerateFindDuplicateRecordsQuery } from '@/object-record/hooks/useGenerateFindDuplicateRecordsQuery';
|
||||
import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery';
|
||||
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
|
||||
import { useGenerateUpdateOneRecordMutation } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation';
|
||||
@ -91,6 +92,13 @@ export const useObjectMetadataItem = (
|
||||
depth,
|
||||
});
|
||||
|
||||
const generateFindDuplicateRecordsQuery =
|
||||
useGenerateFindDuplicateRecordsQuery();
|
||||
const findDuplicateRecordsQuery = generateFindDuplicateRecordsQuery({
|
||||
objectMetadataItem,
|
||||
depth,
|
||||
});
|
||||
|
||||
const generateFindOneRecordQuery = useGenerateFindOneRecordQuery();
|
||||
const findOneRecordQuery = generateFindOneRecordQuery({
|
||||
objectMetadataItem,
|
||||
@ -136,6 +144,7 @@ export const useObjectMetadataItem = (
|
||||
getRecordFromCache,
|
||||
modifyRecordFromCache,
|
||||
findManyRecordsQuery,
|
||||
findDuplicateRecordsQuery,
|
||||
findOneRecordQuery,
|
||||
createOneRecordMutation,
|
||||
updateOneRecordMutation,
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@apollo/client';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
|
||||
import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords';
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
import { ObjectRecordQueryResult } from '../types/ObjectRecordQueryResult';
|
||||
|
||||
export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
|
||||
objectRecordId = '',
|
||||
objectNameSingular,
|
||||
onCompleted,
|
||||
depth,
|
||||
}: ObjectMetadataItemIdentifier & {
|
||||
objectRecordId: string | undefined;
|
||||
onCompleted?: (data: ObjectRecordConnection<T>) => void;
|
||||
skip?: boolean;
|
||||
depth?: number;
|
||||
}) => {
|
||||
const findDuplicateQueryStateIdentifier = objectNameSingular;
|
||||
|
||||
const { objectMetadataItem, findDuplicateRecordsQuery } =
|
||||
useObjectMetadataItem({ objectNameSingular }, depth);
|
||||
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const { data, loading, error } = useQuery<ObjectRecordQueryResult<T>>(
|
||||
findDuplicateRecordsQuery,
|
||||
{
|
||||
variables: {
|
||||
id: objectRecordId,
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
onCompleted?.(data[objectMetadataItem.nameSingular]);
|
||||
},
|
||||
onError: (error) => {
|
||||
logError(
|
||||
`useFindDuplicateRecords for "${objectMetadataItem.nameSingular}" error : ` +
|
||||
error,
|
||||
);
|
||||
enqueueSnackBar(
|
||||
`Error during useFindDuplicateRecords for "${objectMetadataItem.nameSingular}", ${error.message}`,
|
||||
{
|
||||
variant: 'error',
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const objectRecordConnection =
|
||||
data?.[`${objectMetadataItem.nameSingular}Duplicates`];
|
||||
|
||||
const mapConnectionToRecords = useMapConnectionToRecords();
|
||||
|
||||
const records = useMemo(
|
||||
() =>
|
||||
mapConnectionToRecords({
|
||||
objectRecordConnection,
|
||||
objectNameSingular,
|
||||
depth: 5,
|
||||
}) as T[],
|
||||
[mapConnectionToRecords, objectRecordConnection, objectNameSingular],
|
||||
);
|
||||
|
||||
return {
|
||||
objectMetadataItem,
|
||||
records,
|
||||
totalCount: objectRecordConnection?.totalCount || 0,
|
||||
loading,
|
||||
error,
|
||||
queryStateIdentifier: findDuplicateQueryStateIdentifier,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { capitalize } from '~/utils/string/capitalize';
|
||||
|
||||
export const useGenerateFindDuplicateRecordsQuery = () => {
|
||||
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
|
||||
|
||||
return ({
|
||||
objectMetadataItem,
|
||||
depth,
|
||||
}: {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
depth?: number;
|
||||
}) => gql`
|
||||
query FindDuplicate${capitalize(objectMetadataItem.nameSingular)}($id: ID) {
|
||||
${objectMetadataItem.nameSingular}Duplicates(id: $id){
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
${objectMetadataItem.fields
|
||||
.map((field) =>
|
||||
mapFieldMetadataToGraphQLQuery({
|
||||
field,
|
||||
maxDepthForRelations: depth,
|
||||
}),
|
||||
)
|
||||
.join('\n')}
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
@ -13,7 +13,8 @@ import {
|
||||
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
|
||||
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
|
||||
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
|
||||
import { RecordRelationFieldCardSection } from '@/object-record/record-relation-card/components/RecordRelationFieldCardSection';
|
||||
import { RecordDuplicatesFieldCardSection } from '@/object-record/record-show/record-detail-section/components/RecordDuplicatesFieldCardSection';
|
||||
import { RecordRelationFieldCardSection } from '@/object-record/record-show/record-detail-section/components/RecordRelationFieldCardSection';
|
||||
import { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { recordStoreIdentifierFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreIdentifierSelector';
|
||||
@ -193,6 +194,10 @@ export const RecordShowContainer = ({
|
||||
</FieldContext.Provider>
|
||||
))}
|
||||
</PropertyBox>
|
||||
<RecordDuplicatesFieldCardSection
|
||||
objectRecordId={objectRecordId}
|
||||
objectNameSingular={objectNameSingular}
|
||||
/>
|
||||
{relationFieldMetadataItems
|
||||
.filter((item) => {
|
||||
const relationObjectMetadataItem = item.toRelationMetadata
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
|
||||
const StyledHeader = styled.header<{ isDropdownOpen?: boolean }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 24px;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${() => (useIsMobile() ? '0 12px' : 'unset')};
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledTitleLabel = styled.div`
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledLink = styled(Link)`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
text-decoration: none;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
|
||||
:hover {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
type RecordDetailSectionHeaderProps = {
|
||||
title: string;
|
||||
link?: { to: string; label: string };
|
||||
rightAdornment?: React.ReactNode;
|
||||
hideRightAdornmentOnMouseLeave?: boolean;
|
||||
};
|
||||
|
||||
export const RecordDetailSectionHeader = ({
|
||||
title,
|
||||
link,
|
||||
rightAdornment,
|
||||
hideRightAdornmentOnMouseLeave = true,
|
||||
}: RecordDetailSectionHeaderProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<StyledHeader
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<StyledTitle>
|
||||
<StyledTitleLabel>{title}</StyledTitleLabel>
|
||||
{link && <StyledLink to={link.to}>{link.label}</StyledLink>}
|
||||
</StyledTitle>
|
||||
{hideRightAdornmentOnMouseLeave && !isHovered! ? null : rightAdornment}
|
||||
</StyledHeader>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,51 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { RecordChip } from '@/object-record/components/RecordChip';
|
||||
import { useFindDuplicateRecords } from '@/object-record/hooks/useFindDuplicateRecords';
|
||||
import { RecordDetailSectionHeader } from '@/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader';
|
||||
import { Card } from '@/ui/layout/card/components/Card';
|
||||
import { CardContent } from '@/ui/layout/card/components/CardContent';
|
||||
import { Section } from '@/ui/layout/section/components/Section';
|
||||
|
||||
const StyledCardContent = styled(CardContent)`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
export const RecordDuplicatesFieldCardSection = ({
|
||||
objectRecordId,
|
||||
objectNameSingular,
|
||||
}: {
|
||||
objectRecordId: string;
|
||||
objectNameSingular: string;
|
||||
}) => {
|
||||
const { records: duplicateRecords } = useFindDuplicateRecords({
|
||||
objectRecordId,
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
if (duplicateRecords.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<RecordDetailSectionHeader title="Duplicates" />
|
||||
<Card>
|
||||
{duplicateRecords.slice(0, 5).map((duplicateRecord, index) => (
|
||||
<StyledCardContent
|
||||
key={`${objectNameSingular}${duplicateRecord.id}`}
|
||||
divider={index < duplicateRecords.length - 1}
|
||||
>
|
||||
<RecordChip
|
||||
record={duplicateRecord}
|
||||
objectNameSingular={objectNameSingular}
|
||||
/>
|
||||
</StyledCardContent>
|
||||
))}
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,5 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { css, useTheme } from '@emotion/react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import qs from 'qs';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
@ -10,7 +9,8 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
|
||||
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
|
||||
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { RecordRelationFieldCardContent } from '@/object-record/record-relation-card/components/RecordRelationFieldCardContent';
|
||||
import { RecordDetailSectionHeader } from '@/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader';
|
||||
import { RecordRelationFieldCardContent } from '@/object-record/record-show/record-detail-section/components/RecordRelationFieldCardContent';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
||||
import { SingleEntitySelectMenuItemsWithSearch } from '@/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch';
|
||||
@ -26,7 +26,6 @@ import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
|
||||
import { Section } from '@/ui/layout/section/components/Section';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { FilterQueryParams } from '@/views/hooks/internal/useFiltersFromQueryParams';
|
||||
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
|
||||
|
||||
@ -34,51 +33,6 @@ const StyledAddDropdown = styled(Dropdown)`
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
const StyledHeader = styled.header<{ isDropdownOpen?: boolean }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${() => (useIsMobile() ? '0 12px' : 'unset')};
|
||||
|
||||
${({ isDropdownOpen, theme }) =>
|
||||
isDropdownOpen
|
||||
? ''
|
||||
: css`
|
||||
.displayOnHover {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity ${theme.animation.duration.instant}s ease;
|
||||
}
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
.displayOnHover {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledTitleLabel = styled.div`
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledLink = styled(Link)`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
text-decoration: none;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
|
||||
:hover {
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCardNoContent = styled.div`
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
@ -181,46 +135,50 @@ export const RecordRelationFieldCardSection = () => {
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<StyledHeader isDropdownOpen={isDropdownOpen}>
|
||||
<StyledTitle>
|
||||
<StyledTitleLabel>{fieldDefinition.label}</StyledTitleLabel>
|
||||
{isFromManyObjects && (
|
||||
<StyledLink to={filterLinkHref}>
|
||||
All ({relationRecords.length})
|
||||
</StyledLink>
|
||||
)}
|
||||
</StyledTitle>
|
||||
<DropdownScope dropdownScopeId={dropdownId}>
|
||||
<StyledAddDropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="right-start"
|
||||
onClose={handleCloseRelationPickerDropdown}
|
||||
clickableComponent={
|
||||
<LightIconButton
|
||||
className="displayOnHover"
|
||||
Icon={isToOneObject ? IconPencil : IconPlus}
|
||||
accent="tertiary"
|
||||
/>
|
||||
}
|
||||
dropdownComponents={
|
||||
<RelationPickerScope relationPickerScopeId={dropdownId}>
|
||||
<SingleEntitySelectMenuItemsWithSearch
|
||||
EmptyIcon={IconForbid}
|
||||
onEntitySelected={handleRelationPickerEntitySelected}
|
||||
selectedRelationRecordIds={relationRecordIds}
|
||||
relationObjectNameSingular={
|
||||
relationObjectMetadataNameSingular
|
||||
}
|
||||
relationPickerScopeId={dropdownId}
|
||||
<RecordDetailSectionHeader
|
||||
title={fieldDefinition.label}
|
||||
link={
|
||||
isFromManyObjects
|
||||
? {
|
||||
to: filterLinkHref,
|
||||
label: `All (${relationRecords.length})`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
hideRightAdornmentOnMouseLeave={!isDropdownOpen}
|
||||
rightAdornment={
|
||||
<DropdownScope dropdownScopeId={dropdownId}>
|
||||
<StyledAddDropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="right-start"
|
||||
onClose={handleCloseRelationPickerDropdown}
|
||||
clickableComponent={
|
||||
<LightIconButton
|
||||
className="displayOnHover"
|
||||
Icon={isToOneObject ? IconPencil : IconPlus}
|
||||
accent="tertiary"
|
||||
/>
|
||||
</RelationPickerScope>
|
||||
}
|
||||
dropdownHotkeyScope={{
|
||||
scope: dropdownId,
|
||||
}}
|
||||
/>
|
||||
</DropdownScope>
|
||||
</StyledHeader>
|
||||
}
|
||||
dropdownComponents={
|
||||
<RelationPickerScope relationPickerScopeId={dropdownId}>
|
||||
<SingleEntitySelectMenuItemsWithSearch
|
||||
EmptyIcon={IconForbid}
|
||||
onEntitySelected={handleRelationPickerEntitySelected}
|
||||
selectedRelationRecordIds={relationRecordIds}
|
||||
relationObjectNameSingular={
|
||||
relationObjectMetadataNameSingular
|
||||
}
|
||||
relationPickerScopeId={dropdownId}
|
||||
/>
|
||||
</RelationPickerScope>
|
||||
}
|
||||
dropdownHotkeyScope={{
|
||||
scope: dropdownId,
|
||||
}}
|
||||
/>
|
||||
</DropdownScope>
|
||||
}
|
||||
/>
|
||||
{relationRecords.length === 0 && (
|
||||
<StyledCardNoContent>
|
||||
<Icon size={theme.icon.size.sm} />
|
||||
@ -14,7 +14,7 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { arrayToChunks } from '~/utils/array/array-to-chunks';
|
||||
import { arrayToChunks } from '~/utils/array/arrayToChunks';
|
||||
|
||||
import { IconButton, IconButtonVariant } from '../button/components/IconButton';
|
||||
import { LightIconButton } from '../button/components/LightIconButton';
|
||||
|
||||
@ -3,7 +3,7 @@ import { ReactNode, useEffect } from 'react';
|
||||
import { useSelectableListHotKeys } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { SelectableListScope } from '@/ui/layout/selectable-list/scopes/SelectableListScope';
|
||||
import { arrayToChunks } from '~/utils/array/array-to-chunks';
|
||||
import { arrayToChunks } from '~/utils/array/arrayToChunks';
|
||||
|
||||
type SelectableListProps = {
|
||||
children: ReactNode;
|
||||
|
||||
Reference in New Issue
Block a user