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:
@ -6,17 +6,18 @@ const globalCoverage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const modulesCoverage = {
|
const modulesCoverage = {
|
||||||
statements: 50,
|
statements: 75,
|
||||||
lines: 50,
|
lines: 75,
|
||||||
functions: 45,
|
functions: 70,
|
||||||
include: ['src/modules/**/*'],
|
include: ['src/modules/**/*'],
|
||||||
|
exclude: ['src/**/*.ts'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const pagesCoverage = {
|
const pagesCoverage = {
|
||||||
statements: 50,
|
statements: 60,
|
||||||
lines: 50,
|
lines: 60,
|
||||||
functions: 45,
|
functions: 45,
|
||||||
exclude: ['src/generated/**/*', 'src/modules/**/*', '*.ts'],
|
exclude: ['src/generated/**/*', 'src/modules/**/*', 'src/**/*.ts'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const storybookStoriesFolders = process.env.STORYBOOK_SCOPE;
|
const storybookStoriesFolders = process.env.STORYBOOK_SCOPE;
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { useGenerateCreateManyRecordMutation } from '@/object-record/hooks/useGe
|
|||||||
import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
|
import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
|
||||||
import { useGenerateDeleteManyRecordMutation } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation';
|
import { useGenerateDeleteManyRecordMutation } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation';
|
||||||
import { useGenerateExecuteQuickActionOnOneRecordMutation } from '@/object-record/hooks/useGenerateExecuteQuickActionOnOneRecordMutation';
|
import { useGenerateExecuteQuickActionOnOneRecordMutation } from '@/object-record/hooks/useGenerateExecuteQuickActionOnOneRecordMutation';
|
||||||
|
import { useGenerateFindDuplicateRecordsQuery } from '@/object-record/hooks/useGenerateFindDuplicateRecordsQuery';
|
||||||
import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery';
|
import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery';
|
||||||
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
|
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
|
||||||
import { useGenerateUpdateOneRecordMutation } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation';
|
import { useGenerateUpdateOneRecordMutation } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation';
|
||||||
@ -91,6 +92,13 @@ export const useObjectMetadataItem = (
|
|||||||
depth,
|
depth,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const generateFindDuplicateRecordsQuery =
|
||||||
|
useGenerateFindDuplicateRecordsQuery();
|
||||||
|
const findDuplicateRecordsQuery = generateFindDuplicateRecordsQuery({
|
||||||
|
objectMetadataItem,
|
||||||
|
depth,
|
||||||
|
});
|
||||||
|
|
||||||
const generateFindOneRecordQuery = useGenerateFindOneRecordQuery();
|
const generateFindOneRecordQuery = useGenerateFindOneRecordQuery();
|
||||||
const findOneRecordQuery = generateFindOneRecordQuery({
|
const findOneRecordQuery = generateFindOneRecordQuery({
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
@ -136,6 +144,7 @@ export const useObjectMetadataItem = (
|
|||||||
getRecordFromCache,
|
getRecordFromCache,
|
||||||
modifyRecordFromCache,
|
modifyRecordFromCache,
|
||||||
findManyRecordsQuery,
|
findManyRecordsQuery,
|
||||||
|
findDuplicateRecordsQuery,
|
||||||
findOneRecordQuery,
|
findOneRecordQuery,
|
||||||
createOneRecordMutation,
|
createOneRecordMutation,
|
||||||
updateOneRecordMutation,
|
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 { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
|
||||||
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
|
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
|
||||||
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
|
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 { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState';
|
||||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||||
import { recordStoreIdentifierFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreIdentifierSelector';
|
import { recordStoreIdentifierFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreIdentifierSelector';
|
||||||
@ -193,6 +194,10 @@ export const RecordShowContainer = ({
|
|||||||
</FieldContext.Provider>
|
</FieldContext.Provider>
|
||||||
))}
|
))}
|
||||||
</PropertyBox>
|
</PropertyBox>
|
||||||
|
<RecordDuplicatesFieldCardSection
|
||||||
|
objectRecordId={objectRecordId}
|
||||||
|
objectNameSingular={objectNameSingular}
|
||||||
|
/>
|
||||||
{relationFieldMetadataItems
|
{relationFieldMetadataItems
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const relationObjectMetadataItem = item.toRelationMetadata
|
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 { useCallback, useContext } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useTheme } from '@emotion/react';
|
||||||
import { css, useTheme } from '@emotion/react';
|
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import { useRecoilValue } from 'recoil';
|
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 { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
|
||||||
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
|
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
|
||||||
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
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 { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||||
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
||||||
import { SingleEntitySelectMenuItemsWithSearch } from '@/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch';
|
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 { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||||
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
|
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
|
||||||
import { Section } from '@/ui/layout/section/components/Section';
|
import { Section } from '@/ui/layout/section/components/Section';
|
||||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
|
||||||
import { FilterQueryParams } from '@/views/hooks/internal/useFiltersFromQueryParams';
|
import { FilterQueryParams } from '@/views/hooks/internal/useFiltersFromQueryParams';
|
||||||
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
|
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
|
||||||
|
|
||||||
@ -34,51 +33,6 @@ const StyledAddDropdown = styled(Dropdown)`
|
|||||||
margin-left: auto;
|
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`
|
const StyledCardNoContent = styled.div`
|
||||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
@ -181,46 +135,50 @@ export const RecordRelationFieldCardSection = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section>
|
||||||
<StyledHeader isDropdownOpen={isDropdownOpen}>
|
<RecordDetailSectionHeader
|
||||||
<StyledTitle>
|
title={fieldDefinition.label}
|
||||||
<StyledTitleLabel>{fieldDefinition.label}</StyledTitleLabel>
|
link={
|
||||||
{isFromManyObjects && (
|
isFromManyObjects
|
||||||
<StyledLink to={filterLinkHref}>
|
? {
|
||||||
All ({relationRecords.length})
|
to: filterLinkHref,
|
||||||
</StyledLink>
|
label: `All (${relationRecords.length})`,
|
||||||
)}
|
}
|
||||||
</StyledTitle>
|
: undefined
|
||||||
<DropdownScope dropdownScopeId={dropdownId}>
|
}
|
||||||
<StyledAddDropdown
|
hideRightAdornmentOnMouseLeave={!isDropdownOpen}
|
||||||
dropdownId={dropdownId}
|
rightAdornment={
|
||||||
dropdownPlacement="right-start"
|
<DropdownScope dropdownScopeId={dropdownId}>
|
||||||
onClose={handleCloseRelationPickerDropdown}
|
<StyledAddDropdown
|
||||||
clickableComponent={
|
dropdownId={dropdownId}
|
||||||
<LightIconButton
|
dropdownPlacement="right-start"
|
||||||
className="displayOnHover"
|
onClose={handleCloseRelationPickerDropdown}
|
||||||
Icon={isToOneObject ? IconPencil : IconPlus}
|
clickableComponent={
|
||||||
accent="tertiary"
|
<LightIconButton
|
||||||
/>
|
className="displayOnHover"
|
||||||
}
|
Icon={isToOneObject ? IconPencil : IconPlus}
|
||||||
dropdownComponents={
|
accent="tertiary"
|
||||||
<RelationPickerScope relationPickerScopeId={dropdownId}>
|
|
||||||
<SingleEntitySelectMenuItemsWithSearch
|
|
||||||
EmptyIcon={IconForbid}
|
|
||||||
onEntitySelected={handleRelationPickerEntitySelected}
|
|
||||||
selectedRelationRecordIds={relationRecordIds}
|
|
||||||
relationObjectNameSingular={
|
|
||||||
relationObjectMetadataNameSingular
|
|
||||||
}
|
|
||||||
relationPickerScopeId={dropdownId}
|
|
||||||
/>
|
/>
|
||||||
</RelationPickerScope>
|
}
|
||||||
}
|
dropdownComponents={
|
||||||
dropdownHotkeyScope={{
|
<RelationPickerScope relationPickerScopeId={dropdownId}>
|
||||||
scope: dropdownId,
|
<SingleEntitySelectMenuItemsWithSearch
|
||||||
}}
|
EmptyIcon={IconForbid}
|
||||||
/>
|
onEntitySelected={handleRelationPickerEntitySelected}
|
||||||
</DropdownScope>
|
selectedRelationRecordIds={relationRecordIds}
|
||||||
</StyledHeader>
|
relationObjectNameSingular={
|
||||||
|
relationObjectMetadataNameSingular
|
||||||
|
}
|
||||||
|
relationPickerScopeId={dropdownId}
|
||||||
|
/>
|
||||||
|
</RelationPickerScope>
|
||||||
|
}
|
||||||
|
dropdownHotkeyScope={{
|
||||||
|
scope: dropdownId,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DropdownScope>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{relationRecords.length === 0 && (
|
{relationRecords.length === 0 && (
|
||||||
<StyledCardNoContent>
|
<StyledCardNoContent>
|
||||||
<Icon size={theme.icon.size.sm} />
|
<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 { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
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 { IconButton, IconButtonVariant } from '../button/components/IconButton';
|
||||||
import { LightIconButton } from '../button/components/LightIconButton';
|
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 { useSelectableListHotKeys } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys';
|
||||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||||
import { SelectableListScope } from '@/ui/layout/selectable-list/scopes/SelectableListScope';
|
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 = {
|
type SelectableListProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { arrayToChunks } from '~/utils/array/array-to-chunks';
|
import { arrayToChunks } from '~/utils/array/arrayToChunks';
|
||||||
|
|
||||||
describe('arrayToChunks', () => {
|
describe('arrayToChunks', () => {
|
||||||
it('should split an array into subarrays of a given size', () => {
|
it('should split an array into subarrays of a given size', () => {
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { moveArrayItem } from '~/utils/array/moveArrayItem';
|
||||||
|
|
||||||
|
describe('moveArrayItem', () => {
|
||||||
|
it('should return an empty array if provided with empty array', () => {
|
||||||
|
expect(moveArrayItem([], { fromIndex: 0, toIndex: 0 })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same array if fromIndex is larger than array.length', () => {
|
||||||
|
expect(moveArrayItem([1, 2], { fromIndex: 3, toIndex: 0 })).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same array if toIndex is larger than array.length', () => {
|
||||||
|
expect(moveArrayItem([1, 2], { fromIndex: 0, toIndex: 3 })).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same array if fromIndex is smaller than 0', () => {
|
||||||
|
expect(moveArrayItem([1, 2], { fromIndex: -1, toIndex: 0 })).toEqual([
|
||||||
|
1, 2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same array if toIndex is smaller than 0', () => {
|
||||||
|
expect(moveArrayItem([1, 2], { fromIndex: 1, toIndex: -1 })).toEqual([
|
||||||
|
1, 2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should move array items based on fromIndex and toIndex', () => {
|
||||||
|
expect(moveArrayItem([1, 2, 3], { fromIndex: 0, toIndex: 1 })).toEqual([
|
||||||
|
2, 1, 3,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -13,6 +13,8 @@ export const getResolverName = (
|
|||||||
return `${camelCase(objectMetadata.namePlural)}`;
|
return `${camelCase(objectMetadata.namePlural)}`;
|
||||||
case 'findOne':
|
case 'findOne':
|
||||||
return `${camelCase(objectMetadata.nameSingular)}`;
|
return `${camelCase(objectMetadata.nameSingular)}`;
|
||||||
|
case 'findDuplicates':
|
||||||
|
return `${camelCase(objectMetadata.nameSingular)}Duplicates`;
|
||||||
case 'createMany':
|
case 'createMany':
|
||||||
return `create${pascalCase(objectMetadata.namePlural)}`;
|
return `create${pascalCase(objectMetadata.namePlural)}`;
|
||||||
case 'createOne':
|
case 'createOne':
|
||||||
|
|||||||
@ -0,0 +1,175 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import { RecordFilter } from 'src/workspace/workspace-query-builder/interfaces/record.interface';
|
||||||
|
import { FindDuplicatesResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||||
|
|
||||||
|
import { ArgsAliasFactory } from 'src/workspace/workspace-query-builder/factories/args-alias.factory';
|
||||||
|
import { FieldsStringFactory } from 'src/workspace/workspace-query-builder/factories/fields-string.factory';
|
||||||
|
import { FindDuplicatesQueryFactory } from 'src/workspace/workspace-query-builder/factories/find-duplicates-query.factory';
|
||||||
|
import { workspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/utils-test/workspace-query-builder-options';
|
||||||
|
|
||||||
|
describe('FindDuplicatesQueryFactory', () => {
|
||||||
|
let service: FindDuplicatesQueryFactory;
|
||||||
|
const argAliasCreate = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
FindDuplicatesQueryFactory,
|
||||||
|
{
|
||||||
|
provide: FieldsStringFactory,
|
||||||
|
useValue: {
|
||||||
|
create: jest.fn().mockResolvedValue('fieldsString'),
|
||||||
|
// Mock implementation of FieldsStringFactory methods if needed
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ArgsAliasFactory,
|
||||||
|
useValue: {
|
||||||
|
create: argAliasCreate,
|
||||||
|
// Mock implementation of ArgsAliasFactory methods if needed
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<FindDuplicatesQueryFactory>(
|
||||||
|
FindDuplicatesQueryFactory,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should return (first: 0) as a filter when args are missing', async () => {
|
||||||
|
const args: FindDuplicatesResolverArgs<RecordFilter> = {};
|
||||||
|
|
||||||
|
const query = await service.create(args, workspaceQueryBuilderOptions);
|
||||||
|
|
||||||
|
expect(query.trim()).toEqual(`query {
|
||||||
|
objectNameCollection(first: 0) {
|
||||||
|
fieldsString
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use firstName and lastName as a filter when both args are present', async () => {
|
||||||
|
argAliasCreate.mockReturnValue({
|
||||||
|
nameFirstName: 'John',
|
||||||
|
nameLastName: 'Doe',
|
||||||
|
});
|
||||||
|
|
||||||
|
const args: FindDuplicatesResolverArgs<RecordFilter> = {
|
||||||
|
data: {
|
||||||
|
name: {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
},
|
||||||
|
} as unknown as RecordFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = await service.create(args, {
|
||||||
|
...workspaceQueryBuilderOptions,
|
||||||
|
objectMetadataItem: {
|
||||||
|
...workspaceQueryBuilderOptions.objectMetadataItem,
|
||||||
|
nameSingular: 'person',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query.trim()).toEqual(`query {
|
||||||
|
personCollection(filter: {or:[{nameFirstName:{ilike:\"%John%\"},nameLastName:{ilike:\"%Doe%\"}}]}) {
|
||||||
|
fieldsString
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return (first: 0) as a filter when only firstName is present', async () => {
|
||||||
|
argAliasCreate.mockReturnValue({
|
||||||
|
nameFirstName: 'John',
|
||||||
|
});
|
||||||
|
|
||||||
|
const args: FindDuplicatesResolverArgs<RecordFilter> = {
|
||||||
|
data: {
|
||||||
|
name: {
|
||||||
|
firstName: 'John',
|
||||||
|
},
|
||||||
|
} as unknown as RecordFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = await service.create(args, {
|
||||||
|
...workspaceQueryBuilderOptions,
|
||||||
|
objectMetadataItem: {
|
||||||
|
...workspaceQueryBuilderOptions.objectMetadataItem,
|
||||||
|
nameSingular: 'person',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query.trim()).toEqual(`query {
|
||||||
|
personCollection(first: 0) {
|
||||||
|
fieldsString
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use "currentRecord" as query args when its present', async () => {
|
||||||
|
argAliasCreate.mockReturnValue({
|
||||||
|
nameFirstName: 'John',
|
||||||
|
});
|
||||||
|
|
||||||
|
const args: FindDuplicatesResolverArgs<RecordFilter> = {
|
||||||
|
id: 'uuid',
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = await service.create(
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
...workspaceQueryBuilderOptions,
|
||||||
|
objectMetadataItem: {
|
||||||
|
...workspaceQueryBuilderOptions.objectMetadataItem,
|
||||||
|
nameSingular: 'person',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nameFirstName: 'Peter',
|
||||||
|
nameLastName: 'Parker',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(query.trim()).toEqual(`query {
|
||||||
|
personCollection(filter: {id:{neq:\"uuid\"},or:[{nameFirstName:{ilike:\"%Peter%\"},nameLastName:{ilike:\"%Parker%\"}}]}) {
|
||||||
|
fieldsString
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildQueryForExistingRecord', () => {
|
||||||
|
it(`should include all the fields that exist for person inside "duplicateCriteriaCollection" constant`, async () => {
|
||||||
|
const query = service.buildQueryForExistingRecord('uuid', {
|
||||||
|
...workspaceQueryBuilderOptions,
|
||||||
|
objectMetadataItem: {
|
||||||
|
...workspaceQueryBuilderOptions.objectMetadataItem,
|
||||||
|
nameSingular: 'person',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query.trim()).toEqual(`query {
|
||||||
|
personCollection(filter: { id: { eq: \"uuid\" }}){
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
__typename
|
||||||
|
nameFirstName
|
||||||
|
nameLastName
|
||||||
|
linkedinLinkUrl
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,6 +10,7 @@ import { FindOneQueryFactory } from './find-one-query.factory';
|
|||||||
import { UpdateOneQueryFactory } from './update-one-query.factory';
|
import { UpdateOneQueryFactory } from './update-one-query.factory';
|
||||||
import { UpdateManyQueryFactory } from './update-many-query.factory';
|
import { UpdateManyQueryFactory } from './update-many-query.factory';
|
||||||
import { DeleteManyQueryFactory } from './delete-many-query.factory';
|
import { DeleteManyQueryFactory } from './delete-many-query.factory';
|
||||||
|
import { FindDuplicatesQueryFactory } from './find-duplicates-query.factory';
|
||||||
|
|
||||||
export const workspaceQueryBuilderFactories = [
|
export const workspaceQueryBuilderFactories = [
|
||||||
ArgsAliasFactory,
|
ArgsAliasFactory,
|
||||||
@ -21,6 +22,7 @@ export const workspaceQueryBuilderFactories = [
|
|||||||
FieldsStringFactory,
|
FieldsStringFactory,
|
||||||
FindManyQueryFactory,
|
FindManyQueryFactory,
|
||||||
FindOneQueryFactory,
|
FindOneQueryFactory,
|
||||||
|
FindDuplicatesQueryFactory,
|
||||||
UpdateOneQueryFactory,
|
UpdateOneQueryFactory,
|
||||||
UpdateManyQueryFactory,
|
UpdateManyQueryFactory,
|
||||||
DeleteManyQueryFactory,
|
DeleteManyQueryFactory,
|
||||||
|
|||||||
@ -0,0 +1,146 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import isEmpty from 'lodash.isempty';
|
||||||
|
|
||||||
|
import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
|
||||||
|
import { RecordFilter } from 'src/workspace/workspace-query-builder/interfaces/record.interface';
|
||||||
|
import { FindDuplicatesResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||||
|
import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface';
|
||||||
|
|
||||||
|
import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util';
|
||||||
|
import { stringifyWithoutKeyQuote } from 'src/workspace/workspace-query-builder/utils/stringify-without-key-quote.util';
|
||||||
|
import { ArgsAliasFactory } from 'src/workspace/workspace-query-builder/factories/args-alias.factory';
|
||||||
|
import { duplicateCriteriaCollection } from 'src/workspace/workspace-resolver-builder/constants/duplicate-criteria.constants';
|
||||||
|
|
||||||
|
import { FieldsStringFactory } from './fields-string.factory';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FindDuplicatesQueryFactory {
|
||||||
|
private readonly logger = new Logger(FindDuplicatesQueryFactory.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly fieldsStringFactory: FieldsStringFactory,
|
||||||
|
private readonly argsAliasFactory: ArgsAliasFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create<Filter extends RecordFilter = RecordFilter>(
|
||||||
|
args: FindDuplicatesResolverArgs<Filter>,
|
||||||
|
options: WorkspaceQueryBuilderOptions,
|
||||||
|
currentRecord?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const fieldsString = await this.fieldsStringFactory.create(
|
||||||
|
options.info,
|
||||||
|
options.fieldMetadataCollection,
|
||||||
|
options.objectMetadataCollection,
|
||||||
|
);
|
||||||
|
|
||||||
|
const argsData = this.getFindDuplicateBy<Filter>(
|
||||||
|
args,
|
||||||
|
options,
|
||||||
|
currentRecord,
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicateCondition = this.buildDuplicateCondition(
|
||||||
|
options.objectMetadataItem,
|
||||||
|
argsData,
|
||||||
|
args.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filters = stringifyWithoutKeyQuote(duplicateCondition);
|
||||||
|
|
||||||
|
return `
|
||||||
|
query {
|
||||||
|
${computeObjectTargetTable(options.objectMetadataItem)}Collection${
|
||||||
|
isEmpty(duplicateCondition?.or)
|
||||||
|
? '(first: 0)'
|
||||||
|
: `(filter: ${filters})`
|
||||||
|
} {
|
||||||
|
${fieldsString}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFindDuplicateBy<Filter extends RecordFilter = RecordFilter>(
|
||||||
|
args: FindDuplicatesResolverArgs<Filter>,
|
||||||
|
options: WorkspaceQueryBuilderOptions,
|
||||||
|
currentRecord?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (currentRecord) {
|
||||||
|
return currentRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.argsAliasFactory.create(
|
||||||
|
args.data ?? {},
|
||||||
|
options.fieldMetadataCollection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildQueryForExistingRecord(
|
||||||
|
id: string,
|
||||||
|
options: WorkspaceQueryBuilderOptions,
|
||||||
|
) {
|
||||||
|
return `
|
||||||
|
query {
|
||||||
|
${computeObjectTargetTable(
|
||||||
|
options.objectMetadataItem,
|
||||||
|
)}Collection(filter: { id: { eq: "${id}" }}){
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
__typename
|
||||||
|
${this.getApplicableDuplicateCriteriaCollection(
|
||||||
|
options.objectMetadataItem,
|
||||||
|
)
|
||||||
|
.flatMap((dc) => dc.columnNames)
|
||||||
|
.join('\n')}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDuplicateCondition(
|
||||||
|
objectMetadataItem: ObjectMetadataInterface,
|
||||||
|
argsData?: Record<string, unknown>,
|
||||||
|
filteringByExistingRecordId?: string,
|
||||||
|
) {
|
||||||
|
if (!argsData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const criteriaCollection =
|
||||||
|
this.getApplicableDuplicateCriteriaCollection(objectMetadataItem);
|
||||||
|
|
||||||
|
const criteriaWithMatchingArgs = criteriaCollection.filter((criteria) =>
|
||||||
|
criteria.columnNames.every((columnName) => !!argsData[columnName]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterCriteria = criteriaWithMatchingArgs.map((criteria) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
criteria.columnNames.map((columnName) => [
|
||||||
|
columnName,
|
||||||
|
{ ilike: `%${argsData[columnName]}%` },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// when filtering by an existing record, we need to filter that explicit record out
|
||||||
|
...(filteringByExistingRecordId && {
|
||||||
|
id: { neq: filteringByExistingRecordId },
|
||||||
|
}),
|
||||||
|
// keep condition as "or" to get results by more duplicate criteria
|
||||||
|
or: filterCriteria,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getApplicableDuplicateCriteriaCollection(
|
||||||
|
objectMetadataItem: ObjectMetadataInterface,
|
||||||
|
) {
|
||||||
|
return duplicateCriteriaCollection.filter(
|
||||||
|
(duplicateCriteria) =>
|
||||||
|
duplicateCriteria.objectName === objectMetadataItem.nameSingular,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,3 +19,8 @@ export enum OrderByDirection {
|
|||||||
export type RecordOrderBy = {
|
export type RecordOrderBy = {
|
||||||
[Property in keyof Record]?: OrderByDirection;
|
[Property in keyof Record]?: OrderByDirection;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface RecordDuplicateCriteria {
|
||||||
|
objectName: string;
|
||||||
|
columnNames: string[];
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
import { GraphQLResolveInfo } from 'graphql';
|
||||||
|
|
||||||
|
import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface';
|
||||||
|
import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
|
||||||
|
|
||||||
|
import { objectMetadataItem } from 'src/utils/utils-test/object-metadata-item';
|
||||||
|
|
||||||
|
export const workspaceQueryBuilderOptions: WorkspaceQueryBuilderOptions = {
|
||||||
|
fieldMetadataCollection: [],
|
||||||
|
info: {} as GraphQLResolveInfo,
|
||||||
|
objectMetadataCollection: [],
|
||||||
|
objectMetadataItem: objectMetadataItem as ObjectMetadataInterface,
|
||||||
|
};
|
||||||
@ -14,6 +14,7 @@ import {
|
|||||||
DeleteOneResolverArgs,
|
DeleteOneResolverArgs,
|
||||||
UpdateManyResolverArgs,
|
UpdateManyResolverArgs,
|
||||||
DeleteManyResolverArgs,
|
DeleteManyResolverArgs,
|
||||||
|
FindDuplicatesResolverArgs,
|
||||||
} from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
} from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||||
|
|
||||||
import { FindManyQueryFactory } from './factories/find-many-query.factory';
|
import { FindManyQueryFactory } from './factories/find-many-query.factory';
|
||||||
@ -29,6 +30,7 @@ import {
|
|||||||
DeleteManyQueryFactory,
|
DeleteManyQueryFactory,
|
||||||
DeleteManyQueryFactoryOptions,
|
DeleteManyQueryFactoryOptions,
|
||||||
} from './factories/delete-many-query.factory';
|
} from './factories/delete-many-query.factory';
|
||||||
|
import { FindDuplicatesQueryFactory } from './factories/find-duplicates-query.factory';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceQueryBuilderFactory {
|
export class WorkspaceQueryBuilderFactory {
|
||||||
@ -37,6 +39,7 @@ export class WorkspaceQueryBuilderFactory {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly findManyQueryFactory: FindManyQueryFactory,
|
private readonly findManyQueryFactory: FindManyQueryFactory,
|
||||||
private readonly findOneQueryFactory: FindOneQueryFactory,
|
private readonly findOneQueryFactory: FindOneQueryFactory,
|
||||||
|
private readonly findDuplicatesQueryFactory: FindDuplicatesQueryFactory,
|
||||||
private readonly createManyQueryFactory: CreateManyQueryFactory,
|
private readonly createManyQueryFactory: CreateManyQueryFactory,
|
||||||
private readonly updateOneQueryFactory: UpdateOneQueryFactory,
|
private readonly updateOneQueryFactory: UpdateOneQueryFactory,
|
||||||
private readonly deleteOneQueryFactory: DeleteOneQueryFactory,
|
private readonly deleteOneQueryFactory: DeleteOneQueryFactory,
|
||||||
@ -61,6 +64,28 @@ export class WorkspaceQueryBuilderFactory {
|
|||||||
return this.findOneQueryFactory.create<Filter>(args, options);
|
return this.findOneQueryFactory.create<Filter>(args, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findDuplicates<Filter extends RecordFilter = RecordFilter>(
|
||||||
|
args: FindDuplicatesResolverArgs<Filter>,
|
||||||
|
options: WorkspaceQueryBuilderOptions,
|
||||||
|
existingRecord?: Record<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.findDuplicatesQueryFactory.create<Filter>(
|
||||||
|
args,
|
||||||
|
options,
|
||||||
|
existingRecord,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
findDuplicatesExistingRecord(
|
||||||
|
id: string,
|
||||||
|
options: WorkspaceQueryBuilderOptions,
|
||||||
|
): string {
|
||||||
|
return this.findDuplicatesQueryFactory.buildQueryForExistingRecord(
|
||||||
|
id,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
createMany<Record extends IRecord = IRecord>(
|
createMany<Record extends IRecord = IRecord>(
|
||||||
args: CreateManyResolverArgs<Record>,
|
args: CreateManyResolverArgs<Record>,
|
||||||
options: WorkspaceQueryBuilderOptions,
|
options: WorkspaceQueryBuilderOptions,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
CreateOneResolverArgs,
|
CreateOneResolverArgs,
|
||||||
DeleteManyResolverArgs,
|
DeleteManyResolverArgs,
|
||||||
DeleteOneResolverArgs,
|
DeleteOneResolverArgs,
|
||||||
|
FindDuplicatesResolverArgs,
|
||||||
FindManyResolverArgs,
|
FindManyResolverArgs,
|
||||||
FindOneResolverArgs,
|
FindOneResolverArgs,
|
||||||
UpdateManyResolverArgs,
|
UpdateManyResolverArgs,
|
||||||
@ -16,6 +17,7 @@ export type ExecutePreHookMethod =
|
|||||||
| 'deleteOne'
|
| 'deleteOne'
|
||||||
| 'findMany'
|
| 'findMany'
|
||||||
| 'findOne'
|
| 'findOne'
|
||||||
|
| 'findDuplicates'
|
||||||
| 'updateMany'
|
| 'updateMany'
|
||||||
| 'updateOne';
|
| 'updateOne';
|
||||||
|
|
||||||
@ -45,4 +47,6 @@ export type WorkspacePreQueryHookPayload<T> = T extends 'createMany'
|
|||||||
? UpdateManyResolverArgs
|
? UpdateManyResolverArgs
|
||||||
: T extends 'updateOne'
|
: T extends 'updateOne'
|
||||||
? UpdateOneResolverArgs
|
? UpdateOneResolverArgs
|
||||||
: never;
|
: T extends 'findDuplicates'
|
||||||
|
? FindDuplicatesResolverArgs
|
||||||
|
: never;
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
|
import isEmpty from 'lodash.isempty';
|
||||||
|
|
||||||
import { IConnection } from 'src/utils/pagination/interfaces/connection.interface';
|
import { IConnection } from 'src/utils/pagination/interfaces/connection.interface';
|
||||||
import {
|
import {
|
||||||
Record as IRecord,
|
Record as IRecord,
|
||||||
@ -17,6 +19,7 @@ import {
|
|||||||
CreateOneResolverArgs,
|
CreateOneResolverArgs,
|
||||||
DeleteManyResolverArgs,
|
DeleteManyResolverArgs,
|
||||||
DeleteOneResolverArgs,
|
DeleteOneResolverArgs,
|
||||||
|
FindDuplicatesResolverArgs,
|
||||||
FindManyResolverArgs,
|
FindManyResolverArgs,
|
||||||
FindOneResolverArgs,
|
FindOneResolverArgs,
|
||||||
UpdateManyResolverArgs,
|
UpdateManyResolverArgs,
|
||||||
@ -40,6 +43,7 @@ import { ObjectRecordCreateEvent } from 'src/integrations/event-emitter/types/ob
|
|||||||
import { ObjectRecordUpdateEvent } from 'src/integrations/event-emitter/types/object-record-update.event';
|
import { ObjectRecordUpdateEvent } from 'src/integrations/event-emitter/types/object-record-update.event';
|
||||||
import { WorkspacePreQueryHookService } from 'src/workspace/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.service';
|
import { WorkspacePreQueryHookService } from 'src/workspace/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.service';
|
||||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
import { NotFoundError } from 'src/filters/utils/graphql-errors.util';
|
||||||
|
|
||||||
import { WorkspaceQueryRunnerOptions } from './interfaces/query-runner-option.interface';
|
import { WorkspaceQueryRunnerOptions } from './interfaces/query-runner-option.interface';
|
||||||
import {
|
import {
|
||||||
@ -136,6 +140,74 @@ export class WorkspaceQueryRunnerService {
|
|||||||
return parsedResult?.edges?.[0]?.node;
|
return parsedResult?.edges?.[0]?.node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findDuplicates<TRecord extends IRecord = IRecord>(
|
||||||
|
args: FindDuplicatesResolverArgs<TRecord>,
|
||||||
|
options: WorkspaceQueryRunnerOptions,
|
||||||
|
): Promise<IConnection<TRecord> | undefined> {
|
||||||
|
if (!args.data && !args.id) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'You have to provide either "data" or "id" argument',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.id && isEmpty(args.data)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'The "data" condition can not be empty when ID input not provided',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspaceId, userId, objectMetadataItem } = options;
|
||||||
|
|
||||||
|
let existingRecord: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
if (args.id) {
|
||||||
|
const existingRecordQuery =
|
||||||
|
this.workspaceQueryBuilderFactory.findDuplicatesExistingRecord(
|
||||||
|
args.id,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingRecordResult = await this.execute(
|
||||||
|
existingRecordQuery,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedResult = this.parseResult<Record<string, unknown>>(
|
||||||
|
existingRecordResult,
|
||||||
|
objectMetadataItem,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
existingRecord = parsedResult?.edges?.[0]?.node;
|
||||||
|
|
||||||
|
if (!existingRecord) {
|
||||||
|
throw new NotFoundError(`Object with id ${args.id} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = await this.workspaceQueryBuilderFactory.findDuplicates(
|
||||||
|
args,
|
||||||
|
options,
|
||||||
|
existingRecord,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.workspacePreQueryHookService.executePreHooks(
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
objectMetadataItem.nameSingular,
|
||||||
|
'findDuplicates',
|
||||||
|
args,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await this.execute(query, workspaceId);
|
||||||
|
|
||||||
|
return this.parseResult<IConnection<TRecord>>(
|
||||||
|
result,
|
||||||
|
objectMetadataItem,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async createMany<Record extends IRecord = IRecord>(
|
async createMany<Record extends IRecord = IRecord>(
|
||||||
args: CreateManyResolverArgs<Record>,
|
args: CreateManyResolverArgs<Record>,
|
||||||
options: WorkspaceQueryRunnerOptions,
|
options: WorkspaceQueryRunnerOptions,
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { RecordDuplicateCriteria } from 'src/workspace/workspace-query-builder/interfaces/record.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* objectName: directly reference the name of the object from the metadata tables.
|
||||||
|
* columnNames: reference the column names not the field names.
|
||||||
|
* So if we need to reference a custom field, we should directly add the column name like `_customColumn`.
|
||||||
|
* If we need to terence a composite field, we should add all children of the composite like `nameFirstName` and `nameLastName`
|
||||||
|
*/
|
||||||
|
export const duplicateCriteriaCollection: RecordDuplicateCriteria[] = [
|
||||||
|
{
|
||||||
|
objectName: 'company',
|
||||||
|
columnNames: ['domainName'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
objectName: 'company',
|
||||||
|
columnNames: ['name'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
objectName: 'person',
|
||||||
|
columnNames: ['nameFirstName', 'nameLastName'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
objectName: 'person',
|
||||||
|
columnNames: ['linkedinLinkUrl'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
objectName: 'person',
|
||||||
|
columnNames: ['email'],
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { UpdateManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/update-many-resolver.factory';
|
import { UpdateManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/update-many-resolver.factory';
|
||||||
|
|
||||||
|
import { FindDuplicatesResolverFactory } from './find-duplicates-resolver.factory';
|
||||||
import { FindManyResolverFactory } from './find-many-resolver.factory';
|
import { FindManyResolverFactory } from './find-many-resolver.factory';
|
||||||
import { FindOneResolverFactory } from './find-one-resolver.factory';
|
import { FindOneResolverFactory } from './find-one-resolver.factory';
|
||||||
import { CreateManyResolverFactory } from './create-many-resolver.factory';
|
import { CreateManyResolverFactory } from './create-many-resolver.factory';
|
||||||
@ -12,6 +13,7 @@ import { ExecuteQuickActionOnOneResolverFactory } from './execute-quick-action-o
|
|||||||
export const workspaceResolverBuilderFactories = [
|
export const workspaceResolverBuilderFactories = [
|
||||||
FindManyResolverFactory,
|
FindManyResolverFactory,
|
||||||
FindOneResolverFactory,
|
FindOneResolverFactory,
|
||||||
|
FindDuplicatesResolverFactory,
|
||||||
CreateManyResolverFactory,
|
CreateManyResolverFactory,
|
||||||
CreateOneResolverFactory,
|
CreateOneResolverFactory,
|
||||||
UpdateOneResolverFactory,
|
UpdateOneResolverFactory,
|
||||||
@ -25,6 +27,7 @@ export const workspaceResolverBuilderMethodNames = {
|
|||||||
queries: [
|
queries: [
|
||||||
FindManyResolverFactory.methodName,
|
FindManyResolverFactory.methodName,
|
||||||
FindOneResolverFactory.methodName,
|
FindOneResolverFactory.methodName,
|
||||||
|
FindDuplicatesResolverFactory.methodName,
|
||||||
],
|
],
|
||||||
mutations: [
|
mutations: [
|
||||||
CreateManyResolverFactory.methodName,
|
CreateManyResolverFactory.methodName,
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FindDuplicatesResolverArgs,
|
||||||
|
Resolver,
|
||||||
|
} from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||||
|
import { WorkspaceSchemaBuilderContext } from 'src/workspace/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
|
||||||
|
import { WorkspaceResolverBuilderFactoryInterface } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
|
||||||
|
|
||||||
|
import { WorkspaceQueryRunnerService } from 'src/workspace/workspace-query-runner/workspace-query-runner.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FindDuplicatesResolverFactory
|
||||||
|
implements WorkspaceResolverBuilderFactoryInterface
|
||||||
|
{
|
||||||
|
public static methodName = 'findDuplicates' as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(
|
||||||
|
context: WorkspaceSchemaBuilderContext,
|
||||||
|
): Resolver<FindDuplicatesResolverArgs> {
|
||||||
|
const internalContext = context;
|
||||||
|
|
||||||
|
return (_source, args, context, info) => {
|
||||||
|
return this.workspaceQueryRunnerService.findDuplicates(args, {
|
||||||
|
objectMetadataItem: internalContext.objectMetadataItem,
|
||||||
|
workspaceId: internalContext.workspaceId,
|
||||||
|
userId: internalContext.userId,
|
||||||
|
info,
|
||||||
|
fieldMetadataCollection: internalContext.fieldMetadataCollection,
|
||||||
|
objectMetadataCollection: internalContext.objectMetadataCollection,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,11 @@ export interface FindOneResolverArgs<Filter = any> {
|
|||||||
filter?: Filter;
|
filter?: Filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FindDuplicatesResolverArgs<Data extends Record = Record> {
|
||||||
|
id?: string;
|
||||||
|
data?: Data;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateOneResolverArgs<Data extends Record = Record> {
|
export interface CreateOneResolverArgs<Data extends Record = Record> {
|
||||||
data: Data;
|
data: Data;
|
||||||
}
|
}
|
||||||
@ -81,5 +86,6 @@ export type ResolverArgs =
|
|||||||
| DeleteOneResolverArgs
|
| DeleteOneResolverArgs
|
||||||
| FindManyResolverArgs
|
| FindManyResolverArgs
|
||||||
| FindOneResolverArgs
|
| FindOneResolverArgs
|
||||||
|
| FindDuplicatesResolverArgs
|
||||||
| UpdateManyResolverArgs
|
| UpdateManyResolverArgs
|
||||||
| UpdateOneResolverArgs;
|
| UpdateOneResolverArgs;
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { UpdateManyResolverFactory } from 'src/workspace/workspace-resolver-buil
|
|||||||
import { DeleteManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/delete-many-resolver.factory';
|
import { DeleteManyResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/delete-many-resolver.factory';
|
||||||
import { ExecuteQuickActionOnOneResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/execute-quick-action-on-one-resolver.factory';
|
import { ExecuteQuickActionOnOneResolverFactory } from 'src/workspace/workspace-resolver-builder/factories/execute-quick-action-on-one-resolver.factory';
|
||||||
|
|
||||||
|
import { FindDuplicatesResolverFactory } from './factories/find-duplicates-resolver.factory';
|
||||||
import { FindManyResolverFactory } from './factories/find-many-resolver.factory';
|
import { FindManyResolverFactory } from './factories/find-many-resolver.factory';
|
||||||
import { FindOneResolverFactory } from './factories/find-one-resolver.factory';
|
import { FindOneResolverFactory } from './factories/find-one-resolver.factory';
|
||||||
import { CreateManyResolverFactory } from './factories/create-many-resolver.factory';
|
import { CreateManyResolverFactory } from './factories/create-many-resolver.factory';
|
||||||
@ -28,6 +29,7 @@ export class WorkspaceResolverFactory {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly findManyResolverFactory: FindManyResolverFactory,
|
private readonly findManyResolverFactory: FindManyResolverFactory,
|
||||||
private readonly findOneResolverFactory: FindOneResolverFactory,
|
private readonly findOneResolverFactory: FindOneResolverFactory,
|
||||||
|
private readonly findDuplicatesResolverFactory: FindDuplicatesResolverFactory,
|
||||||
private readonly createManyResolverFactory: CreateManyResolverFactory,
|
private readonly createManyResolverFactory: CreateManyResolverFactory,
|
||||||
private readonly createOneResolverFactory: CreateOneResolverFactory,
|
private readonly createOneResolverFactory: CreateOneResolverFactory,
|
||||||
private readonly updateOneResolverFactory: UpdateOneResolverFactory,
|
private readonly updateOneResolverFactory: UpdateOneResolverFactory,
|
||||||
@ -49,6 +51,7 @@ export class WorkspaceResolverFactory {
|
|||||||
>([
|
>([
|
||||||
['findMany', this.findManyResolverFactory],
|
['findMany', this.findManyResolverFactory],
|
||||||
['findOne', this.findOneResolverFactory],
|
['findOne', this.findOneResolverFactory],
|
||||||
|
['findDuplicates', this.findDuplicatesResolverFactory],
|
||||||
['createMany', this.createManyResolverFactory],
|
['createMany', this.createManyResolverFactory],
|
||||||
['createOne', this.createOneResolverFactory],
|
['createOne', this.createOneResolverFactory],
|
||||||
['updateOne', this.updateOneResolverFactory],
|
['updateOne', this.updateOneResolverFactory],
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export class RootTypeFactory {
|
|||||||
const args = getResolverArgs(methodName);
|
const args = getResolverArgs(methodName);
|
||||||
const objectType = this.typeDefinitionsStorage.getObjectTypeByKey(
|
const objectType = this.typeDefinitionsStorage.getObjectTypeByKey(
|
||||||
objectMetadata.id,
|
objectMetadata.id,
|
||||||
methodName === 'findMany'
|
['findMany', 'findDuplicates'].includes(methodName)
|
||||||
? ObjectTypeDefinitionKind.Connection
|
? ObjectTypeDefinitionKind.Connection
|
||||||
: ObjectTypeDefinitionKind.Plain,
|
: ObjectTypeDefinitionKind.Plain,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -69,6 +69,17 @@ export const getResolverArgs = (
|
|||||||
isNullable: false,
|
isNullable: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
case 'findDuplicates':
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
type: FieldMetadataType.UUID,
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
kind: InputTypeDefinitionKind.Create,
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
case 'deleteOne':
|
case 'deleteOne':
|
||||||
return {
|
return {
|
||||||
id: {
|
id: {
|
||||||
|
|||||||
Reference in New Issue
Block a user