Infinite scrolling in relation picker menu (#12051)

https://github.com/user-attachments/assets/4be785e0-ea8a-4c8e-840e-6fa0a663d7ba

Closes #11938

---------

Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
Abdul Rahman
2025-05-23 20:53:09 +05:30
committed by GitHub
parent 6ef9a3b4c9
commit af5762c8ba
37 changed files with 1867 additions and 562 deletions

View File

@ -1688,7 +1688,7 @@ export type Query = {
objects: ObjectConnection;
plans: Array<BillingPlanOutput>;
relationMetadata: RelationMetadataConnection;
search: Array<SearchRecord>;
search: SearchResultConnection;
validatePasswordResetToken: ValidatePasswordResetToken;
versionInfo: VersionInfo;
};
@ -1828,6 +1828,7 @@ export type QueryRelationMetadataArgs = {
export type QuerySearchArgs = {
after?: InputMaybe<Scalars['String']['input']>;
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']['input']>>;
filter?: InputMaybe<ObjectRecordFilterInput>;
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']['input']>>;
@ -2046,6 +2047,24 @@ export type SearchRecord = {
tsRankCD: Scalars['Float']['output'];
};
export type SearchResultConnection = {
__typename?: 'SearchResultConnection';
edges: Array<SearchResultEdge>;
pageInfo: SearchResultPageInfo;
};
export type SearchResultEdge = {
__typename?: 'SearchResultEdge';
cursor: Scalars['String']['output'];
node: SearchRecord;
};
export type SearchResultPageInfo = {
__typename?: 'SearchResultPageInfo';
endCursor?: Maybe<Scalars['String']['output']>;
hasNextPage: Scalars['Boolean']['output'];
};
export type SendInvitationsOutput = {
__typename?: 'SendInvitationsOutput';
errors: Array<Scalars['String']['output']>;

View File

@ -1539,7 +1539,7 @@ export type Query = {
object: Object;
objects: ObjectConnection;
plans: Array<BillingPlanOutput>;
search: Array<SearchRecord>;
search: SearchResultConnection;
validatePasswordResetToken: ValidatePasswordResetToken;
versionInfo: VersionInfo;
};
@ -1626,6 +1626,7 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = {
export type QuerySearchArgs = {
after?: InputMaybe<Scalars['String']>;
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
filter?: InputMaybe<ObjectRecordFilterInput>;
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
@ -1830,6 +1831,24 @@ export type SearchRecord = {
tsRankCD: Scalars['Float'];
};
export type SearchResultConnection = {
__typename?: 'SearchResultConnection';
edges: Array<SearchResultEdge>;
pageInfo: SearchResultPageInfo;
};
export type SearchResultEdge = {
__typename?: 'SearchResultEdge';
cursor: Scalars['String'];
node: SearchRecord;
};
export type SearchResultPageInfo = {
__typename?: 'SearchResultPageInfo';
endCursor?: Maybe<Scalars['String']>;
hasNextPage: Scalars['Boolean'];
};
export type SendInvitationsOutput = {
__typename?: 'SendInvitationsOutput';
errors: Array<Scalars['String']>;
@ -2721,7 +2740,7 @@ export type SearchQueryVariables = Exact<{
}>;
export type SearchQuery = { __typename?: 'Query', search: Array<{ __typename?: 'SearchRecord', recordId: string, objectNameSingular: string, label: string, imageUrl?: string | null, tsRankCD: number, tsRank: number }> };
export type SearchQuery = { __typename?: 'Query', search: { __typename?: 'SearchResultConnection', edges: Array<{ __typename?: 'SearchResultEdge', cursor: string, node: { __typename?: 'SearchRecord', recordId: string, objectNameSingular: string, label: string, imageUrl?: string | null, tsRankCD: number, tsRank: number } }>, pageInfo: { __typename?: 'SearchResultPageInfo', hasNextPage: boolean, endCursor?: string | null } } };
export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
@ -4608,12 +4627,21 @@ export const SearchDocument = gql`
includedObjectNameSingulars: $includedObjectNameSingulars
filter: $filter
) {
recordId
objectNameSingular
label
imageUrl
tsRankCD
tsRank
edges {
node {
recordId
objectNameSingular
label
imageUrl
tsRankCD
tsRank
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;

View File

@ -172,7 +172,13 @@ export const NoResultsSearchFallback: Story = {
graphql.query('Search', () => {
return HttpResponse.json({
data: {
search: [],
search: {
edges: [],
pageInfo: {
hasNextPage: false,
endCursor: null,
},
},
},
});
}),

View File

@ -1,9 +1,10 @@
import gql from 'graphql-tag';
export const search = gql`
export const SEARCH_QUERY = gql`
query Search(
$searchInput: String!
$limit: Int!
$after: String
$excludedObjectNameSingulars: [String!]
$includedObjectNameSingulars: [String!]
$filter: ObjectRecordFilterInput
@ -11,16 +12,26 @@ export const search = gql`
search(
searchInput: $searchInput
limit: $limit
after: $after
excludedObjectNameSingulars: $excludedObjectNameSingulars
includedObjectNameSingulars: $includedObjectNameSingulars
filter: $filter
) {
recordId
objectNameSingular
label
imageUrl
tsRankCD
tsRank
edges {
node {
recordId
objectNameSingular
label
imageUrl
tsRankCD
tsRank
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;

View File

@ -31,69 +31,71 @@ export const useCommandMenuSearchRecords = () => {
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
const actionItems = useMemo(() => {
return (searchData?.search ?? []).map((searchRecord, index) => {
const baseAction = {
type: ActionType.Navigation,
scope: ActionScope.Global,
key: searchRecord.recordId,
label: searchRecord.label,
position: index,
Icon: () => (
<Avatar
type={
searchRecord.objectNameSingular === 'company'
? 'squared'
: 'rounded'
}
avatarUrl={searchRecord.imageUrl}
placeholderColorSeed={searchRecord.recordId}
placeholder={searchRecord.label}
/>
),
shouldBeRegistered: () => true,
description: capitalize(searchRecord.objectNameSingular),
shouldCloseCommandMenuOnClick: true,
};
return (searchData?.search.edges.map((edge) => edge.node) ?? []).map(
(searchRecord, index) => {
const baseAction = {
type: ActionType.Navigation,
scope: ActionScope.Global,
key: searchRecord.recordId,
label: searchRecord.label,
position: index,
Icon: () => (
<Avatar
type={
searchRecord.objectNameSingular === 'company'
? 'squared'
: 'rounded'
}
avatarUrl={searchRecord.imageUrl}
placeholderColorSeed={searchRecord.recordId}
placeholder={searchRecord.label}
/>
),
shouldBeRegistered: () => true,
description: capitalize(searchRecord.objectNameSingular),
shouldCloseCommandMenuOnClick: true,
};
if (
[CoreObjectNameSingular.Task, CoreObjectNameSingular.Note].includes(
searchRecord.objectNameSingular as CoreObjectNameSingular,
)
) {
return {
...baseAction,
component: (
<Action
onClick={() => {
searchRecord.objectNameSingular === 'task'
? openRecordInCommandMenu({
recordId: searchRecord.recordId,
objectNameSingular: CoreObjectNameSingular.Task,
})
: openRecordInCommandMenu({
recordId: searchRecord.recordId,
objectNameSingular: CoreObjectNameSingular.Note,
});
}}
preventCommandMenuClosing
/>
),
};
}
if (
[CoreObjectNameSingular.Task, CoreObjectNameSingular.Note].includes(
searchRecord.objectNameSingular as CoreObjectNameSingular,
)
) {
return {
...baseAction,
component: (
<Action
onClick={() => {
searchRecord.objectNameSingular === 'task'
? openRecordInCommandMenu({
recordId: searchRecord.recordId,
objectNameSingular: CoreObjectNameSingular.Task,
})
: openRecordInCommandMenu({
recordId: searchRecord.recordId,
objectNameSingular: CoreObjectNameSingular.Note,
});
<ActionLink
to={AppPath.RecordShowPage}
params={{
objectNameSingular: searchRecord.objectNameSingular,
objectRecordId: searchRecord.recordId,
}}
preventCommandMenuClosing
/>
),
};
}
return {
...baseAction,
component: (
<ActionLink
to={AppPath.RecordShowPage}
params={{
objectNameSingular: searchRecord.objectNameSingular,
objectRecordId: searchRecord.recordId,
}}
/>
),
};
});
},
);
}, [searchData, openRecordInCommandMenu]);
return {

View File

@ -65,7 +65,7 @@ export const useObjectRecordSearchRecords = ({
const effectiveData = loading ? previousData : data;
const searchRecords = useMemo(
() => effectiveData?.search || [],
() => effectiveData?.search.edges.map((edge) => edge.node) || [],
[effectiveData],
);

View File

@ -1,26 +1,21 @@
import { MultipleRecordPickerMenuItems } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems';
import { MultipleRecordPickerItemsDisplay } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerItemsDisplay';
import { MultipleRecordPickerOnClickOutsideEffect } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerOnClickOutsideEffect';
import { MultipleRecordPickerSearchInput } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerSearchInput';
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState';
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
import { multipleRecordPickerPickableMorphItemsLengthComponentSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPickableMorphItemsLengthComponentSelector';
import { MultipleRecordPickerHotkeyScope } from '@/object-record/record-picker/multiple-record-picker/types/MultipleRecordPickerHotkeyScope';
import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId';
import { RecordPickerLayoutDirection } from '@/object-record/record-picker/types/RecordPickerLayoutDirection';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled';
import { useRef } from 'react';
import { useRecoilCallback } from 'recoil';
@ -59,16 +54,6 @@ export const MultipleRecordPicker = ({
selectableListComponentInstanceId,
);
const multipleRecordPickerIsLoading = useRecoilComponentValueV2(
multipleRecordPickerIsLoadingComponentState,
componentInstanceId,
);
const itemsLength = useRecoilComponentValueV2(
multipleRecordPickerPickableMorphItemsLengthComponentSelector,
componentInstanceId,
);
const multipleRecordPickerSearchFilterState =
useRecoilComponentCallbackStateV2(
multipleRecordPickerSearchFilterComponentState,
@ -106,13 +91,16 @@ export const MultipleRecordPicker = ({
[multipleRecordPickerSearchFilterState, onCreate],
);
const createNewButton = isDefined(onCreate) && (
<CreateNewButton
onClick={handleCreateNewButtonClick}
LeftIcon={IconPlus}
text="Add New"
/>
);
const createNewButtonSection =
isDefined(onCreate) && !hasObjectReadOnlyPermission ? (
<DropdownMenuItemsContainer scrollable={false}>
<CreateNewButton
onClick={handleCreateNewButtonClick}
LeftIcon={IconPlus}
text="Add New"
/>
</DropdownMenuItemsContainer>
) : null;
return (
<MultipleRecordPickerComponentInstanceContext.Provider
@ -125,43 +113,15 @@ export const MultipleRecordPicker = ({
<DropdownMenu ref={containerRef} data-select-disable width={200}>
{layoutDirection === 'search-bar-on-bottom' && (
<>
{isDefined(onCreate) && !hasObjectReadOnlyPermission && (
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
)}
<DropdownMenuSeparator />
{itemsLength > 0 && (
<MultipleRecordPickerMenuItems onChange={onChange} />
)}
{multipleRecordPickerIsLoading && (
<>
<DropdownMenuSkeletonItem />
<DropdownMenuSeparator />
</>
)}
{itemsLength > 0 && <DropdownMenuSeparator />}
{createNewButtonSection}
<MultipleRecordPickerItemsDisplay onChange={onChange} />
</>
)}
<MultipleRecordPickerSearchInput />
{layoutDirection === 'search-bar-on-top' && (
<>
<DropdownMenuSeparator />
{multipleRecordPickerIsLoading && (
<>
<DropdownMenuSkeletonItem />
<DropdownMenuSeparator />
</>
)}
{itemsLength > 0 && (
<MultipleRecordPickerMenuItems onChange={onChange} />
)}
{itemsLength > 0 && <DropdownMenuSeparator />}
{isDefined(onCreate) && (
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
)}
<MultipleRecordPickerItemsDisplay onChange={onChange} />
{createNewButtonSection}
</>
)}
</DropdownMenu>

View File

@ -0,0 +1,96 @@
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState';
import { multipleRecordPickerPaginationState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPaginationState';
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
import { multipleRecordPickerPaginationSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPaginationSelector';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled';
import { useCallback } from 'react';
import { useInView } from 'react-intersection-observer';
import { useRecoilCallback } from 'recoil';
import { GRAY_SCALE } from 'twenty-ui/theme';
const StyledText = styled.div`
align-items: center;
box-shadow: none;
color: ${GRAY_SCALE.gray40};
display: flex;
height: 32px;
margin-left: ${({ theme }) => theme.spacing(8)};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledIntersectionObserver = styled.div`
height: 1px;
`;
export const MultipleRecordPickerFetchMoreLoader = () => {
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
MultipleRecordPickerComponentInstanceContext,
);
const paginationState = useRecoilComponentValueV2(
multipleRecordPickerPaginationSelector,
componentInstanceId,
);
const isLoading = useRecoilComponentValueV2(
multipleRecordPickerIsLoadingComponentState,
componentInstanceId,
);
const searchFilter = useRecoilComponentValueV2(
multipleRecordPickerSearchFilterComponentState,
componentInstanceId,
);
const { performSearch } = useMultipleRecordPickerPerformSearch();
const fetchMore = useRecoilCallback(
({ snapshot }) =>
async () => {
const paginationState = snapshot
.getLoadable(
multipleRecordPickerPaginationState.atomFamily({
instanceId: componentInstanceId,
}),
)
.getValue();
if (isLoading || !paginationState.hasNextPage) {
return;
}
performSearch({
multipleRecordPickerInstanceId: componentInstanceId,
forceSearchFilter: searchFilter,
loadMore: true,
});
},
[componentInstanceId, performSearch, searchFilter, isLoading],
);
const { ref } = useInView({
onChange: useCallback(
(inView: boolean) => {
if (inView) {
fetchMore();
}
},
[fetchMore],
),
});
if (!paginationState.hasNextPage) {
return null;
}
return (
<div>
<StyledIntersectionObserver ref={ref} />
{isLoading && <StyledText>Loading more...</StyledText>}
</div>
);
};

View File

@ -0,0 +1,41 @@
import { MultipleRecordPickerMenuItems } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems';
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState';
import { multipleRecordPickerPickableMorphItemsLengthComponentSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPickableMorphItemsLengthComponentSelector';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const MultipleRecordPickerItemsDisplay = ({
onChange,
}: {
onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
}) => {
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
MultipleRecordPickerComponentInstanceContext,
);
const isLoading = useRecoilComponentValueV2(
multipleRecordPickerIsLoadingComponentState,
componentInstanceId,
);
const itemsLength = useRecoilComponentValueV2(
multipleRecordPickerPickableMorphItemsLengthComponentSelector,
componentInstanceId,
);
return (
<>
<DropdownMenuSeparator />
{isLoading && itemsLength === 0 ? (
<DropdownMenuSkeletonItem />
) : (
<MultipleRecordPickerMenuItems onChange={onChange} />
)}
<DropdownMenuSeparator />
</>
);
};

View File

@ -1,5 +1,6 @@
import styled from '@emotion/styled';
import { MultipleRecordPickerFetchMoreLoader } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerFetchMoreLoader';
import { MultipleRecordPickerMenuItem } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItem';
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
@ -21,6 +22,14 @@ export const StyledSelectableItem = styled(SelectableListItem)`
width: 100%;
`;
const StyledEmptyText = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)};
`;
type MultipleRecordPickerMenuItemsProps = {
onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
};
@ -77,25 +86,30 @@ export const MultipleRecordPickerMenuItems = ({
return (
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
selectableListInstanceId={selectableListComponentInstanceId}
selectableItemIdArray={pickableRecordIds}
hotkeyScope={MultipleRecordPickerHotkeyScope.MultipleRecordPicker}
>
{pickableRecordIds.map((recordId) => {
return (
<MultipleRecordPickerMenuItem
key={recordId}
recordId={recordId}
onChange={(morphItem) => {
handleChange(morphItem);
onChange?.(morphItem);
resetSelectedItem();
}}
/>
);
})}
</SelectableList>
{pickableRecordIds.length === 0 ? (
<StyledEmptyText>No results found</StyledEmptyText>
) : (
<SelectableList
selectableListInstanceId={selectableListComponentInstanceId}
selectableItemIdArray={pickableRecordIds}
hotkeyScope={MultipleRecordPickerHotkeyScope.MultipleRecordPicker}
>
{pickableRecordIds.map((recordId) => {
return (
<MultipleRecordPickerMenuItem
key={recordId}
recordId={recordId}
onChange={(morphItem) => {
handleChange(morphItem);
onChange?.(morphItem);
resetSelectedItem();
}}
/>
);
})}
<MultipleRecordPickerFetchMoreLoader />
</SelectableList>
)}
</DropdownMenuItemsContainer>
);
};

View File

@ -4,7 +4,8 @@ import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
import { useDebouncedCallback } from 'use-debounce';
export const MultipleRecordPickerSearchInput = () => {
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
@ -16,17 +17,33 @@ export const MultipleRecordPickerSearchInput = () => {
const { performSearch } = useMultipleRecordPickerPerformSearch();
const handleFilterChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setRecordPickerSearchFilter(event.currentTarget.value);
performSearch({
multipleRecordPickerInstanceId: componentInstanceId,
forceSearchFilter: event.currentTarget.value,
});
},
[componentInstanceId, performSearch, setRecordPickerSearchFilter],
const debouncedSearch = useDebouncedCallback(
useRecoilCallback(
({ set }) =>
(searchFilter: string) => {
set(
multipleRecordPickerSearchFilterComponentState.atomFamily({
instanceId: componentInstanceId,
}),
searchFilter,
);
performSearch({
multipleRecordPickerInstanceId: componentInstanceId,
forceSearchFilter: searchFilter,
});
},
[componentInstanceId, performSearch],
),
500,
);
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newSearchFilter = event.currentTarget.value;
setRecordPickerSearchFilter(newSearchFilter);
debouncedSearch(newSearchFilter);
};
return (
<DropdownMenuSearchInput
value={recordPickerSearchFilter}

View File

@ -1,7 +1,8 @@
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
import { search } from '@/command-menu/graphql/queries/search';
import { SEARCH_QUERY } from '@/command-menu/graphql/queries/search';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { usePerformCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/usePerformCombinedFindManyRecords';
import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState';
import { multipleRecordPickerPaginationState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPaginationState';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
@ -12,6 +13,9 @@ import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { SearchRecord } from '~/generated-metadata/graphql';
import { SearchResultEdge } from '~/generated/graphql';
const MULTIPLE_RECORD_PICKER_PAGE_SIZE = 30;
export const useMultipleRecordPickerPerformSearch = () => {
const client = useApolloClient();
@ -26,14 +30,40 @@ export const useMultipleRecordPickerPerformSearch = () => {
forceSearchFilter = '',
forceSearchableObjectMetadataItems = [],
forcePickableMorphItems = [],
loadMore = false,
}: {
multipleRecordPickerInstanceId: string;
forceSearchFilter?: string;
forceSearchableObjectMetadataItems?: ObjectMetadataItem[];
forcePickableMorphItems?: RecordPickerPickableMorphItem[];
loadMore?: boolean;
}) => {
const { getLoadable } = snapshot;
const paginationState = getLoadable(
multipleRecordPickerPaginationState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
).getValue();
set(
multipleRecordPickerIsLoadingComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
true,
);
set(
multipleRecordPickerPaginationState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
{
...paginationState,
endCursor: loadMore ? paginationState.endCursor : null,
hasNextPage: loadMore ? paginationState.hasNextPage : true,
},
);
const recordPickerSearchFilter = getLoadable(
multipleRecordPickerSearchFilterComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
@ -70,6 +100,7 @@ export const useMultipleRecordPickerPerformSearch = () => {
const [
searchRecordsFilteredOnPickedRecords,
searchRecordsExcludingPickedRecords,
pageInfo,
] = await performSearchQueries({
client,
searchFilter,
@ -77,28 +108,83 @@ export const useMultipleRecordPickerPerformSearch = () => {
pickedRecordIds: selectedPickableMorphItems.map(
({ recordId }) => recordId,
),
after: loadMore ? paginationState.endCursor : null,
});
const pickedMorphItems = pickableMorphItems.filter(
({ isSelected }) => isSelected,
const existingMorphItems = getLoadable(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
).getValue();
const allPickedItems = [
...existingMorphItems.filter(({ isSelected }) => isSelected),
...pickableMorphItems.filter(({ isSelected }) => isSelected),
];
const uniquePickedItems = allPickedItems.reduce(
(acc, item) => {
if (!acc.some((existing) => existing.recordId === item.recordId)) {
acc.push(item);
}
return acc;
},
[] as typeof allPickedItems,
);
// We update the existing pickedMorphItems to be matching the search filter
const updatedPickedMorphItems = pickedMorphItems.map((morphItem) => {
const record = searchRecordsFilteredOnPickedRecords.find(
({ recordId }) => recordId === morphItem.recordId,
);
const updatedPickedItems = uniquePickedItems.map((morphItem) => {
if (!searchFilter) {
return {
...morphItem,
isMatchingSearchFilter: true,
};
}
const isMatchingSearchFilter =
searchRecordsFilteredOnPickedRecords.some(
({ recordId }) => recordId === morphItem.recordId,
) ||
searchRecordsExcludingPickedRecords.some(
({ recordId }) => recordId === morphItem.recordId,
);
return {
...morphItem,
isMatchingSearchFilter: isDefined(record),
isMatchingSearchFilter,
};
});
const updatedNonPickedExistingItems = existingMorphItems
.filter((item) => !item.isSelected)
.map((morphItem) => {
if (!searchFilter) {
return {
...morphItem,
isMatchingSearchFilter: true,
};
}
const isMatchingSearchFilter =
searchRecordsFilteredOnPickedRecords.some(
({ recordId }) => recordId === morphItem.recordId,
) ||
searchRecordsExcludingPickedRecords.some(
({ recordId }) => recordId === morphItem.recordId,
);
return {
...morphItem,
isMatchingSearchFilter,
};
});
const searchRecordsFilteredOnPickedRecordsWithoutDuplicates =
searchRecordsFilteredOnPickedRecords.filter(
(searchRecord) =>
!updatedPickedMorphItems.some(
!updatedPickedItems.some(
({ recordId }) => recordId === searchRecord.recordId,
) &&
!updatedNonPickedExistingItems.some(
({ recordId }) => recordId === searchRecord.recordId,
),
);
@ -109,13 +195,17 @@ export const useMultipleRecordPickerPerformSearch = () => {
!searchRecordsFilteredOnPickedRecords.some(
({ recordId }) => recordId === searchRecord.recordId,
) &&
!pickedMorphItems.some(
!updatedPickedItems.some(
({ recordId }) => recordId === searchRecord.recordId,
) &&
!updatedNonPickedExistingItems.some(
({ recordId }) => recordId === searchRecord.recordId,
),
);
const morphItems = [
...updatedPickedMorphItems,
const newMorphItems = [
...updatedPickedItems,
...updatedNonPickedExistingItems,
...searchRecordsFilteredOnPickedRecordsWithoutDuplicates.map(
({ recordId, objectNameSingular }) => ({
isMatchingSearchFilter: true,
@ -140,6 +230,20 @@ export const useMultipleRecordPickerPerformSearch = () => {
),
];
const morphItems = loadMore
? newMorphItems.reduce(
(acc, item) => {
if (
!acc.some((existing) => existing.recordId === item.recordId)
) {
acc.push(item);
}
return acc;
},
[] as typeof newMorphItems,
)
: newMorphItems;
set(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
@ -234,6 +338,24 @@ export const useMultipleRecordPickerPerformSearch = () => {
},
);
}
set(
multipleRecordPickerPaginationState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
{
...paginationState,
endCursor: pageInfo.endCursor,
hasNextPage: pageInfo.hasNextPage,
},
);
set(
multipleRecordPickerIsLoadingComponentState.atomFamily({
instanceId: multipleRecordPickerInstanceId,
}),
false,
);
},
[client, performCombinedFindManyRecords],
);
@ -246,32 +368,46 @@ const performSearchQueries = async ({
searchFilter,
searchableObjectMetadataItems,
pickedRecordIds,
limit = MULTIPLE_RECORD_PICKER_PAGE_SIZE,
after = null,
}: {
client: ApolloClient<object>;
searchFilter: string;
searchableObjectMetadataItems: ObjectMetadataItem[];
pickedRecordIds: string[];
}): Promise<[SearchRecord[], SearchRecord[]]> => {
limit?: number;
after?: string | null;
}): Promise<
[
SearchRecord[],
SearchRecord[],
{ hasNextPage: boolean; endCursor: string | null },
]
> => {
if (searchableObjectMetadataItems.length === 0) {
return [[], []];
return [[], [], { hasNextPage: false, endCursor: null }];
}
const searchRecords = async (filter: any) => {
const { data } = await client.query({
query: search,
query: SEARCH_QUERY,
variables: {
searchInput: searchFilter,
includedObjectNameSingulars: searchableObjectMetadataItems.map(
({ nameSingular }) => nameSingular,
),
filter,
limit: MAX_SEARCH_RESULTS,
limit,
after,
},
});
return data.search;
return {
records: data.search.edges.map((edge: SearchResultEdge) => edge.node),
pageInfo: data.search.pageInfo,
};
};
const searchRecordsExcludingPickedRecords = await searchRecords(
const searchRecordsExcludingPickedRecordsResult = await searchRecords(
pickedRecordIds.length > 0
? {
not: {
@ -283,17 +419,18 @@ const performSearchQueries = async ({
: undefined,
);
const searchRecordsIncludingPickedRecords =
const searchRecordsIncludingPickedRecordsResult =
pickedRecordIds.length > 0
? await searchRecords({
id: {
in: pickedRecordIds,
},
})
: [];
: { records: [], pageInfo: { hasNextPage: false, endCursor: null } };
return [
searchRecordsIncludingPickedRecords,
searchRecordsExcludingPickedRecords,
searchRecordsIncludingPickedRecordsResult.records,
searchRecordsExcludingPickedRecordsResult.records,
searchRecordsExcludingPickedRecordsResult.pageInfo,
];
};

View File

@ -0,0 +1,17 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export type MultipleRecordPickerPaginationState = {
endCursor: string | null;
hasNextPage: boolean;
};
export const multipleRecordPickerPaginationState =
createComponentStateV2<MultipleRecordPickerPaginationState>({
key: 'multipleRecordPickerPaginationState',
defaultValue: {
endCursor: null,
hasNextPage: false,
},
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
});

View File

@ -0,0 +1,19 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerPaginationState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPaginationState';
import { createComponentSelectorV2 } from '@/ui/utilities/state/component-state/utils/createComponentSelectorV2';
export const multipleRecordPickerPaginationSelector = createComponentSelectorV2(
{
key: 'multipleRecordPickerPaginationSelector',
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
return get(
multipleRecordPickerPaginationState.atomFamily({
instanceId,
}),
);
},
},
);

View File

@ -186,44 +186,62 @@ export const graphqlMocks = {
graphql.query('Search', () => {
return HttpResponse.json({
data: {
search: [
{
__typename: 'SearchRecordDTO',
recordId: '20202020-2d40-4e49-8df4-9c6a049191de',
objectNameSingular: 'person',
label: 'Louis Duss',
imageUrl: '',
tsRankCD: 0.2,
tsRank: 0.12158542,
search: {
edges: [
{
node: {
__typename: 'SearchRecordDTO',
recordId: '20202020-2d40-4e49-8df4-9c6a049191de',
objectNameSingular: 'person',
label: 'Louis Duss',
imageUrl: '',
tsRankCD: 0.2,
tsRank: 0.12158542,
},
cursor: 'cursor-1',
},
{
node: {
__typename: 'SearchRecordDTO',
recordId: '20202020-3ec3-4fe3-8997-b76aa0bfa408',
objectNameSingular: 'company',
label: 'Linkedin',
imageUrl: 'https://twenty-icons.com/linkedin.com',
tsRankCD: 0.2,
tsRank: 0.12158542,
},
cursor: 'cursor-2',
},
{
node: {
__typename: 'SearchRecordDTO',
recordId: '20202020-3f74-492d-a101-2a70f50a1645',
objectNameSingular: 'company',
label: 'Libeo',
imageUrl: 'https://twenty-icons.com/libeo.io',
tsRankCD: 0.2,
tsRank: 0.12158542,
},
cursor: 'cursor-3',
},
{
node: {
__typename: 'SearchRecordDTO',
recordId: '20202020-ac73-4797-824e-87a1f5aea9e0',
objectNameSingular: 'person',
label: 'Sylvie Palmer',
imageUrl: '',
tsRankCD: 0.1,
tsRank: 0.06079271,
},
cursor: 'cursor-4',
},
],
pageInfo: {
hasNextPage: true,
endCursor: 'cursor-4',
},
{
__typename: 'SearchRecordDTO',
recordId: '20202020-3ec3-4fe3-8997-b76aa0bfa408',
objectNameSingular: 'company',
label: 'Linkedin',
imageUrl: 'https://twenty-icons.com/linkedin.com',
tsRankCD: 0.2,
tsRank: 0.12158542,
},
{
__typename: 'SearchRecordDTO',
recordId: '20202020-3f74-492d-a101-2a70f50a1645',
objectNameSingular: 'company',
label: 'Libeo',
imageUrl: 'https://twenty-icons.com/libeo.io',
tsRankCD: 0.2,
tsRank: 0.12158542,
},
{
__typename: 'SearchRecordDTO',
recordId: '20202020-ac73-4797-824e-87a1f5aea9e0',
objectNameSingular: 'person',
label: 'Sylvie Palmer',
imageUrl: '',
tsRankCD: 0.1,
tsRank: 0.06079271,
},
],
},
},
});
}),

View File

@ -141,26 +141,59 @@ export class DataSeedWorkspaceCommand extends CommandRunner {
workspaceId,
);
await this.seedStandardObjectRecords(mainDataSource, dataSourceMetadata);
await this.seedCustomObjects({
dataSourceMetadata,
});
await this.seederService.seedCustomObjects(
dataSourceMetadata.id,
workspaceId,
PETS_METADATA_SEEDS,
PETS_DATA_SEEDS,
);
await this.seederService.seedCustomObjects(
dataSourceMetadata.id,
workspaceId,
SURVEY_RESULTS_METADATA_SEEDS,
SURVEY_RESULTS_DATA_SEEDS,
);
await this.seedRecords({
mainDataSource,
dataSourceMetadata,
});
} catch (error) {
this.logger.error(error);
}
}
async seedCustomObjects({
dataSourceMetadata,
}: {
dataSourceMetadata: DataSourceEntity;
}) {
await this.seederService.seedCustomObjects(
dataSourceMetadata.id,
dataSourceMetadata.workspaceId,
PETS_METADATA_SEEDS,
);
await this.seederService.seedCustomObjects(
dataSourceMetadata.id,
dataSourceMetadata.workspaceId,
SURVEY_RESULTS_METADATA_SEEDS,
);
}
async seedRecords({
mainDataSource,
dataSourceMetadata,
}: {
mainDataSource: DataSource;
dataSourceMetadata: DataSourceEntity;
}) {
await this.seedStandardObjectRecords(mainDataSource, dataSourceMetadata);
await this.seederService.seedCustomObjectRecords(
dataSourceMetadata.workspaceId,
PETS_METADATA_SEEDS,
PETS_DATA_SEEDS,
);
await this.seederService.seedCustomObjectRecords(
dataSourceMetadata.workspaceId,
SURVEY_RESULTS_METADATA_SEEDS,
SURVEY_RESULTS_DATA_SEEDS,
);
}
async seedStandardObjectRecords(
mainDataSource: DataSource,
dataSourceMetadata: DataSourceEntity,

View File

@ -14,7 +14,7 @@ export interface CursorData {
[key: string]: any;
}
export const decodeCursor = (cursor: string): CursorData => {
export const decodeCursor = <T = CursorData>(cursor: string): T => {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString());
} catch (err) {
@ -45,6 +45,10 @@ export const encodeCursor = <T extends ObjectRecord = ObjectRecord>(
id: objectRecord.id,
};
return encodeCursorData(cursorData);
};
export const encodeCursorData = (cursorData: CursorData) => {
return Buffer.from(JSON.stringify(cursorData)).toString('base64');
};

View File

@ -3,13 +3,21 @@ import { Test, TestingModule } from '@nestjs/testing';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/search/__mocks__/mockObjectMetadataItemsWithFieldMaps';
import { SearchService } from 'src/engine/core-modules/search/services/search.service';
import { encodeCursorData } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
describe('SearchService', () => {
let service: SearchService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SearchService, { provide: FileService, useValue: {} }],
providers: [
SearchService,
{ provide: TwentyORMManager, useValue: {} },
{ provide: WorkspaceCacheStorageService, useValue: {} },
{ provide: FileService, useValue: {} },
],
}).compile();
service = module.get<SearchService>(SearchService);
@ -206,4 +214,236 @@ describe('SearchService', () => {
]);
});
});
describe('computeEdges', () => {
it('should compute edges properly', () => {
const sortedSlicedRecords = [
{
record: {
objectNameSingular: 'company',
tsRankCD: 0.9,
tsRank: 0.9,
recordId: 'companyId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.9, tsRank: 0.9 },
lastRecordIdsPerObject: {
company: 'companyId1',
},
}),
},
{
record: {
objectNameSingular: 'company',
tsRankCD: 0.89,
tsRank: 0.89,
recordId: 'companyId2',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.89, tsRank: 0.89 },
lastRecordIdsPerObject: {
company: 'companyId2',
},
}),
},
{
record: {
objectNameSingular: 'person',
tsRankCD: 0.87,
tsRank: 0.87,
recordId: 'personId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId1',
},
}),
},
{
record: {
objectNameSingular: 'person',
tsRankCD: 0.87,
tsRank: 0.87,
recordId: 'personId2',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
},
}),
},
{
record: {
objectNameSingular: 'opportunity',
tsRankCD: 0.87,
tsRank: 0.87,
recordId: 'opportunityId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
opportunity: 'opportunityId1',
},
}),
},
{
record: {
objectNameSingular: 'note',
tsRankCD: 0.2,
tsRank: 0.2,
recordId: 'noteId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.2, tsRank: 0.2 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
opportunity: 'opportunityId1',
note: 'noteId1',
},
}),
},
{
record: {
objectNameSingular: 'company',
tsRankCD: 0.1,
tsRank: 0.1,
recordId: 'companyId3',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.1, tsRank: 0.1 },
lastRecordIdsPerObject: {
company: 'companyId3',
person: 'personId2',
opportunity: 'opportunityId1',
note: 'noteId1',
},
}),
},
];
const edges = service.computeEdges({
sortedRecords: sortedSlicedRecords.map((r) => r.record),
});
expect(edges.map((e) => e.cursor)).toEqual(
sortedSlicedRecords.map((r) => r.expectedCursor),
);
});
it('should compute pageInfo properly with an input after cursor', () => {
const sortedSlicedRecords = [
{
record: {
objectNameSingular: 'person',
tsRankCD: 0.87,
tsRank: 0.87,
recordId: 'personId2',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
},
}),
},
{
record: {
objectNameSingular: 'opportunity',
tsRankCD: 0.87,
tsRank: 0.87,
recordId: 'opportunityId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
opportunity: 'opportunityId1',
},
}),
},
{
record: {
objectNameSingular: 'note',
tsRankCD: 0.2,
tsRank: 0.2,
recordId: 'noteId1',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.2, tsRank: 0.2 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId2',
opportunity: 'opportunityId1',
note: 'noteId1',
},
}),
},
{
record: {
objectNameSingular: 'company',
tsRankCD: 0.1,
tsRank: 0.1,
recordId: 'companyId3',
label: '',
imageUrl: '',
},
expectedCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.1, tsRank: 0.1 },
lastRecordIdsPerObject: {
company: 'companyId3',
person: 'personId2',
opportunity: 'opportunityId1',
note: 'noteId1',
},
}),
},
];
const afterCursor = encodeCursorData({
lastRanks: { tsRankCD: 0.87, tsRank: 0.87 },
lastRecordIdsPerObject: {
company: 'companyId2',
person: 'personId1',
},
});
const edges = service.computeEdges({
sortedRecords: sortedSlicedRecords.map((r) => r.record),
after: afterCursor,
});
expect(edges.map((e) => e.cursor)).toEqual(
sortedSlicedRecords.map((r) => r.expectedCursor),
);
});
});
});

View File

@ -1 +0,0 @@
export const RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS = 8;

View File

@ -1,6 +1,6 @@
import { ArgsType, Field, Int } from '@nestjs/graphql';
import { IsArray, IsInt, IsOptional, IsString } from 'class-validator';
import { IsArray, IsInt, IsOptional, IsString, Max } from 'class-validator';
import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input';
@ -12,8 +12,13 @@ export class SearchArgs {
@Field(() => Int)
@IsInt()
@Max(100, { message: 'Limit cannot exceed 100 items' })
limit: number;
@Field(() => String, { nullable: true })
@IsOptional()
after?: string;
@IsArray()
@Field(() => [String], { nullable: true })
@IsOptional()

View File

@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { SearchResultEdgeDTO } from 'src/engine/core-modules/search/dtos/search-result-edge.dto';
import { SearchResultPageInfoDTO } from 'src/engine/core-modules/search/dtos/search-result-page-info.dto';
@ObjectType('SearchResultConnection')
export class SearchResultConnectionDTO {
@Field(() => [SearchResultEdgeDTO])
edges: SearchResultEdgeDTO[];
@Field(() => SearchResultPageInfoDTO)
pageInfo: SearchResultPageInfoDTO;
}

View File

@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record.dto';
@ObjectType('SearchResultEdge')
export class SearchResultEdgeDTO {
@Field(() => SearchRecordDTO)
node: SearchRecordDTO;
@Field(() => String)
cursor: string;
}

View File

@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('SearchResultPageInfo')
export class SearchResultPageInfoDTO {
@Field(() => String, { nullable: true })
endCursor: string | null;
@Field(() => Boolean)
hasNextPage: boolean;
}

View File

@ -1,41 +1,19 @@
import { UseFilters } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';
import chunk from 'lodash.chunk';
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record-dto';
import { SearchApiExceptionFilter } from 'src/engine/core-modules/search/filters/search-api-exception.filter';
import { SearchService } from 'src/engine/core-modules/search/services/search.service';
import { RecordsWithObjectMetadataItem } from 'src/engine/core-modules/search/types/records-with-object-metadata-item';
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
WorkspaceMetadataCacheException,
WorkspaceMetadataCacheExceptionCode,
} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception';
import {
WorkspaceMetadataVersionException,
WorkspaceMetadataVersionExceptionCode,
} from 'src/engine/metadata-modules/workspace-metadata-version/exceptions/workspace-metadata-version.exception';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { SearchResultConnectionDTO } from 'src/engine/core-modules/search/dtos/search-result-connection.dto';
const OBJECT_METADATA_ITEMS_CHUNK_SIZE = 5;
@Resolver(() => [SearchRecordDTO])
@Resolver()
@UseFilters(SearchApiExceptionFilter)
export class SearchResolver {
constructor(
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly twentyORMManager: TwentyORMManager,
private readonly searchService: SearchService,
) {}
constructor(private readonly searchService: SearchService) {}
@Query(() => [SearchRecordDTO])
@Query(() => SearchResultConnectionDTO)
async search(
@AuthWorkspace() workspace: Workspace,
@Args()
@ -45,34 +23,11 @@ export class SearchResolver {
filter,
includedObjectNameSingulars,
excludedObjectNameSingulars,
after,
}: SearchArgs,
) {
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
if (currentCacheVersion === undefined) {
throw new WorkspaceMetadataVersionException(
`Metadata version not found for workspace ${workspace.id}`,
WorkspaceMetadataVersionExceptionCode.METADATA_VERSION_NOT_FOUND,
);
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspace.id,
currentCacheVersion,
);
if (!objectMetadataMaps) {
throw new WorkspaceMetadataCacheException(
`Object metadata map not found for workspace ${workspace.id} and metadata version ${currentCacheVersion}`,
WorkspaceMetadataCacheExceptionCode.OBJECT_METADATA_MAP_NOT_FOUND,
);
}
const objectMetadataItemWithFieldMaps = Object.values(
objectMetadataMaps.byId,
);
const objectMetadataItemWithFieldMaps =
await this.searchService.getObjectMetadataItemWithFieldMaps(workspace);
const filteredObjectMetadataItems =
this.searchService.filterObjectMetadataItems({
@ -81,42 +36,22 @@ export class SearchResolver {
excludedObjectNameSingulars: excludedObjectNameSingulars ?? [],
});
const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] =
[];
const allRecordsWithObjectMetadataItems =
await this.searchService.getAllRecordsWithObjectMetadataItems({
objectMetadataItemWithFieldMaps: filteredObjectMetadataItems,
searchInput,
limit,
filter,
includedObjectNameSingulars,
excludedObjectNameSingulars,
after,
});
const filteredObjectMetadataItemsChunks = chunk(
filteredObjectMetadataItems,
OBJECT_METADATA_ITEMS_CHUNK_SIZE,
);
for (const objectMetadataItemChunk of filteredObjectMetadataItemsChunks) {
const recordsWithObjectMetadataItems = await Promise.all(
objectMetadataItemChunk.map(async (objectMetadataItem) => {
const repository = await this.twentyORMManager.getRepository(
objectMetadataItem.nameSingular,
);
return {
objectMetadataItem,
records: await this.searchService.buildSearchQueryAndGetRecords({
entityManager: repository,
objectMetadataItem,
searchTerms: formatSearchTerms(searchInput, 'and'),
searchTermsOr: formatSearchTerms(searchInput, 'or'),
limit,
filter: filter ?? ({} as ObjectRecordFilter),
}),
};
}),
);
allRecordsWithObjectMetadataItems.push(...recordsWithObjectMetadataItems);
}
return this.searchService.computeSearchObjectResults(
allRecordsWithObjectMetadataItems,
return this.searchService.computeSearchObjectResults({
recordsWithObjectMetadataItems: allRecordsWithObjectMetadataItems,
workspaceId: workspace.id,
limit,
workspace.id,
);
after,
});
}
}

View File

@ -4,15 +4,17 @@ import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
import { Brackets, ObjectLiteral } from 'typeorm';
import chunk from 'lodash.chunk';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import {
ObjectRecord,
ObjectRecordFilter,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS } from 'src/engine/core-modules/search/constants/results-limit-by-object-without-search-terms';
import { STANDARD_OBJECTS_BY_PRIORITY_RANK } from 'src/engine/core-modules/search/constants/standard-objects-by-priority-rank';
import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input';
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record-dto';
import {
SearchException,
SearchExceptionCode,
@ -22,10 +24,124 @@ import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/searc
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import {
decodeCursor,
encodeCursorData,
} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import {
WorkspaceMetadataVersionException,
WorkspaceMetadataVersionExceptionCode,
} from 'src/engine/metadata-modules/workspace-metadata-version/exceptions/workspace-metadata-version.exception';
import {
WorkspaceMetadataCacheException,
WorkspaceMetadataCacheExceptionCode,
} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
import { SearchResultConnectionDTO } from 'src/engine/core-modules/search/dtos/search-result-connection.dto';
import { SearchResultEdgeDTO } from 'src/engine/core-modules/search/dtos/search-result-edge.dto';
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record.dto';
type LastRanks = { tsRankCD: number; tsRank: number };
export type SearchCursor = {
lastRanks: LastRanks;
lastRecordIdsPerObject: Record<string, string | undefined>;
};
const OBJECT_METADATA_ITEMS_CHUNK_SIZE = 5;
@Injectable()
export class SearchService {
constructor(private readonly fileService: FileService) {}
constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly fileService: FileService,
) {}
async getObjectMetadataItemWithFieldMaps(workspace: Workspace) {
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
if (currentCacheVersion === undefined) {
throw new WorkspaceMetadataVersionException(
`Metadata version not found for workspace ${workspace.id}`,
WorkspaceMetadataVersionExceptionCode.METADATA_VERSION_NOT_FOUND,
);
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspace.id,
currentCacheVersion,
);
if (!objectMetadataMaps) {
throw new WorkspaceMetadataCacheException(
`Object metadata map not found for workspace ${workspace.id} and metadata version ${currentCacheVersion}`,
WorkspaceMetadataCacheExceptionCode.OBJECT_METADATA_MAP_NOT_FOUND,
);
}
return Object.values(objectMetadataMaps.byId);
}
async getAllRecordsWithObjectMetadataItems({
objectMetadataItemWithFieldMaps,
includedObjectNameSingulars,
excludedObjectNameSingulars,
searchInput,
limit,
filter,
after,
}: {
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[];
} & SearchArgs) {
const filteredObjectMetadataItems = this.filterObjectMetadataItems({
objectMetadataItemWithFieldMaps,
includedObjectNameSingulars: includedObjectNameSingulars ?? [],
excludedObjectNameSingulars: excludedObjectNameSingulars ?? [],
});
const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] =
[];
const filteredObjectMetadataItemsChunks = chunk(
filteredObjectMetadataItems,
OBJECT_METADATA_ITEMS_CHUNK_SIZE,
);
for (const objectMetadataItemChunk of filteredObjectMetadataItemsChunks) {
const recordsWithObjectMetadataItems = await Promise.all(
objectMetadataItemChunk.map(async (objectMetadataItem) => {
const repository =
await this.twentyORMManager.getRepository<ObjectRecord>(
objectMetadataItem.nameSingular,
);
return {
objectMetadataItem,
records: await this.buildSearchQueryAndGetRecords({
entityManager: repository,
objectMetadataItem,
searchTerms: formatSearchTerms(searchInput, 'and'),
searchTermsOr: formatSearchTerms(searchInput, 'or'),
limit: limit as number,
filter: filter ?? ({} as ObjectRecordFilter),
after,
}),
};
}),
);
allRecordsWithObjectMetadataItems.push(...recordsWithObjectMetadataItems);
}
return allRecordsWithObjectMetadataItems;
}
filterObjectMetadataItems({
objectMetadataItemWithFieldMaps,
@ -60,6 +176,7 @@ export class SearchService {
searchTermsOr,
limit,
filter,
after,
}: {
entityManager: WorkspaceRepository<Entity>;
objectMetadataItem: ObjectMetadataItemWithFieldMaps;
@ -67,6 +184,7 @@ export class SearchService {
searchTermsOr: string;
limit: number;
filter: ObjectRecordFilterInput;
after?: string;
}) {
const queryBuilder = entityManager.createQueryBuilder();
@ -93,51 +211,102 @@ export class SearchService {
...(imageIdentifierField ? [imageIdentifierField] : []),
].map((field) => `"${field}"`);
const searchQuery = isNonEmptyString(searchTerms)
? queryBuilder
.select(fieldsToSelect)
.addSelect(
`ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`,
'tsRankCD',
)
.addSelect(
`ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`,
'tsRank',
)
.andWhere(
new Brackets((qb) => {
qb.where(
`"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTerms)`,
{ searchTerms },
).orWhere(
`"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTermsOr)`,
{ searchTermsOr },
);
}),
)
.orderBy(
`ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`,
'DESC',
)
.addOrderBy(
`ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTermsOr))`,
'DESC',
)
.setParameter('searchTerms', searchTerms)
.setParameter('searchTermsOr', searchTermsOr)
.take(limit)
: queryBuilder
.select(fieldsToSelect)
.addSelect('0', 'tsRankCD')
.addSelect('0', 'tsRank')
.andWhere(
new Brackets((qb) => {
qb.where(`"${SEARCH_VECTOR_FIELD.name}" IS NOT NULL`);
}),
)
.take(RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS);
const tsRankCDExpr = `ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`;
return await searchQuery.getRawMany();
const tsRankExpr = `ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTermsOr))`;
const cursorWhereCondition = this.computeCursorWhereCondition({
after,
objectMetadataNameSingular: objectMetadataItem.nameSingular,
tsRankExpr,
tsRankCDExpr,
});
queryBuilder
.select(fieldsToSelect)
.addSelect(tsRankCDExpr, 'tsRankCD')
.addSelect(tsRankExpr, 'tsRank');
if (isNonEmptyString(searchTerms)) {
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where(
`"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTerms)`,
{ searchTerms },
).orWhere(
`"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTermsOr)`,
{ searchTermsOr },
);
}),
);
} else {
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where(`"${SEARCH_VECTOR_FIELD.name}" IS NOT NULL`);
}),
);
}
if (cursorWhereCondition) {
queryBuilder.andWhere(cursorWhereCondition);
}
return await queryBuilder
.orderBy(tsRankCDExpr, 'DESC')
.addOrderBy(tsRankExpr, 'DESC')
.addOrderBy('id', 'ASC', 'NULLS FIRST')
.setParameter('searchTerms', searchTerms)
.setParameter('searchTermsOr', searchTermsOr)
.take(limit + 1) // We take one more to check if hasNextPage is true
.getRawMany();
}
computeCursorWhereCondition({
after,
objectMetadataNameSingular,
tsRankExpr,
tsRankCDExpr,
}: {
after?: string;
objectMetadataNameSingular: string;
tsRankExpr: string;
tsRankCDExpr: string;
}) {
if (after) {
const { lastRanks, lastRecordIdsPerObject } =
decodeCursor<SearchCursor>(after);
const lastRecordId = lastRecordIdsPerObject[objectMetadataNameSingular];
return new Brackets((qb) => {
qb.where(`${tsRankCDExpr} < :tsRankCDLt`, {
tsRankCDLt: lastRanks.tsRankCD,
})
.orWhere(
new Brackets((inner) => {
inner.andWhere(`${tsRankCDExpr} = :tsRankCDEq`, {
tsRankCDEq: lastRanks.tsRankCD,
});
inner.andWhere(`${tsRankExpr} < :tsRankLt`, {
tsRankLt: lastRanks.tsRank,
});
}),
)
.orWhere(
new Brackets((inner) => {
inner.andWhere(`${tsRankCDExpr} = :tsRankCDEq`, {
tsRankCDEq: lastRanks.tsRankCD,
});
inner.andWhere(`${tsRankExpr} = :tsRankEq`, {
tsRankEq: lastRanks.tsRank,
});
if (lastRecordId !== undefined) {
inner.andWhere('id > :lastRecordId', { lastRecordId });
}
}),
);
});
}
}
getLabelIdentifierColumns(
@ -220,11 +389,54 @@ export class SearchService {
: '';
}
computeSearchObjectResults(
recordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[],
limit: number,
workspaceId: string,
) {
computeEdges({
sortedRecords,
after,
}: {
sortedRecords: SearchRecordDTO[];
after?: string;
}): SearchResultEdgeDTO[] {
const recordEdges = [];
const lastRecordIdsPerObject = after
? {
...decodeCursor<SearchCursor>(after).lastRecordIdsPerObject,
}
: {};
for (const record of sortedRecords) {
const { objectNameSingular, tsRankCD, tsRank, recordId } = record;
lastRecordIdsPerObject[objectNameSingular] = recordId;
const lastRecordIdsPerObjectSnapshot = { ...lastRecordIdsPerObject };
recordEdges.push({
node: record,
cursor: encodeCursorData({
lastRanks: {
tsRankCD,
tsRank,
},
lastRecordIdsPerObject: lastRecordIdsPerObjectSnapshot,
}),
});
}
return recordEdges;
}
computeSearchObjectResults({
recordsWithObjectMetadataItems,
workspaceId,
limit,
after,
}: {
recordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[];
workspaceId: string;
limit: number;
after?: string;
}): SearchResultConnectionDTO {
const searchRecords = recordsWithObjectMetadataItems.flatMap(
({ objectMetadataItem, records }) => {
return records.map((record) => {
@ -244,7 +456,25 @@ export class SearchService {
},
);
return this.sortSearchObjectResults(searchRecords).slice(0, limit);
const sortedRecords = this.sortSearchObjectResults(searchRecords).slice(
0,
limit,
);
const hasNextPage = searchRecords.length > limit;
const recordEdges = this.computeEdges({ sortedRecords, after });
if (recordEdges.length === 0) {
return { edges: [], pageInfo: { endCursor: null, hasNextPage } };
}
const lastRecordEdge = recordEdges[recordEdges.length - 1];
return {
edges: recordEdges,
pageInfo: { endCursor: lastRecordEdge.cursor, hasNextPage },
};
}
sortSearchObjectResults(searchObjectResultsWithRank: SearchRecordDTO[]) {

View File

@ -23,42 +23,17 @@ export class SeederService {
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
) {}
public async seedCustomObjects(
dataSourceId: string,
public async seedCustomObjectRecords(
workspaceId: string,
objectMetadataSeed: ObjectMetadataSeed,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
objectRecordSeeds: Record<string, any>[],
): Promise<void> {
const createdObjectMetadata = await this.objectMetadataService.createOne({
...objectMetadataSeed,
dataSourceId,
) {
const { fieldMetadataSeeds, objectMetadata } = await this.getSeedMetadata(
workspaceId,
});
if (!createdObjectMetadata) {
throw new Error("Object metadata couldn't be created");
}
await this.fieldMetadataService.createMany(
objectMetadataSeed.fields.map((fieldMetadataSeed) => ({
...fieldMetadataSeed,
objectMetadataId: createdObjectMetadata.id,
workspaceId,
})),
objectMetadataSeed,
);
const objectMetadataAfterFieldCreation =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: { nameSingular: objectMetadataSeed.nameSingular },
});
if (!objectMetadataAfterFieldCreation) {
throw new Error(
"Object metadata couldn't be found after field creation.",
);
}
const schemaName =
this.workspaceDataSourceService.getSchemaName(workspaceId);
@ -67,24 +42,11 @@ export class SeederService {
const entityManager: EntityManager = mainDataSource.createEntityManager();
const filteredFieldMetadataSeeds = objectMetadataSeed.fields.filter(
(field) =>
objectMetadataAfterFieldCreation.fields.some(
(f) => f.name === field.name || f.name === `name`,
),
);
if (filteredFieldMetadataSeeds.length === 0) {
throw new Error('No fields found for seeding, check metadata file');
}
this.addNameFieldToFieldMetadataSeeds(filteredFieldMetadataSeeds);
const objectRecordSeedsAsSQLFlattenedSeeds = objectRecordSeeds.map(
(recordSeed) => {
const objectRecordSeedsAsSQLFlattenedSeeds = {};
for (const field of filteredFieldMetadataSeeds) {
for (const field of fieldMetadataSeeds) {
if (isCompositeFieldMetadataType(field.type)) {
const compositeFieldTypeDefinition = compositeTypeDefinitions.get(
field.type,
@ -165,7 +127,7 @@ export class SeederService {
.createQueryBuilder()
.insert()
.into(
`${schemaName}.${computeTableName(objectMetadataAfterFieldCreation.nameSingular, true)}`,
`${schemaName}.${computeTableName(objectMetadata.nameSingular, true)}`,
sqlColumnNames,
)
.orIgnore()
@ -174,6 +136,37 @@ export class SeederService {
.execute();
}
public async seedCustomObjects(
dataSourceId: string,
workspaceId: string,
objectMetadataSeed: ObjectMetadataSeed,
): Promise<void> {
const createdObjectMetadata = await this.objectMetadataService.createOne({
...objectMetadataSeed,
dataSourceId,
workspaceId,
});
if (!createdObjectMetadata) {
throw new Error("Object metadata couldn't be created");
}
await this.fieldMetadataService.createMany(
objectMetadataSeed.fields.map((fieldMetadataSeed) => ({
...fieldMetadataSeed,
objectMetadataId: createdObjectMetadata.id,
workspaceId,
})),
);
const { fieldMetadataSeeds } = await this.getSeedMetadata(
workspaceId,
objectMetadataSeed,
);
this.addNameFieldToFieldMetadataSeeds(fieldMetadataSeeds);
}
private addNameFieldToFieldMetadataSeeds(
arrayOfMetadataFields: Pick<CreateFieldInput, 'name' | 'type' | 'label'>[],
) {
@ -184,6 +177,34 @@ export class SeederService {
});
}
private async getSeedMetadata(
workspaceId: string,
objectMetadataSeed: ObjectMetadataSeed,
) {
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: { nameSingular: objectMetadataSeed.nameSingular },
});
if (!objectMetadata) {
throw new Error(
"Object metadata couldn't be found after field creation.",
);
}
const fieldMetadataSeeds = objectMetadataSeed.fields.filter((field) =>
objectMetadata.fields.some(
(f) => f.name === field.name || f.name === `name`,
),
);
if (fieldMetadataSeeds.length === 0) {
throw new Error('No fields found for seeding, check metadata file');
}
return { fieldMetadataSeeds, objectMetadata };
}
private turnCompositeSubFieldValueAsSQLValue(
fieldType: FieldMetadataType,
subFieldName: string,

View File

@ -244,6 +244,11 @@ export class WorkspaceManagerService {
dataSourceMetadata.id,
workspaceId,
PETS_METADATA_SEEDS,
);
await this.seederService.seedCustomObjectRecords(
workspaceId,
PETS_METADATA_SEEDS,
PETS_DATA_SEEDS,
);
@ -251,6 +256,11 @@ export class WorkspaceManagerService {
dataSourceMetadata.id,
workspaceId,
SURVEY_RESULTS_METADATA_SEEDS,
);
await this.seederService.seedCustomObjectRecords(
workspaceId,
SURVEY_RESULTS_METADATA_SEEDS,
SURVEY_RESULTS_DATA_SEEDS,
);
}

View File

@ -0,0 +1 @@
export const TEST_API_KEY_1_ID = '982fb60e-67d9-44a3-b35c-0e508f41d3d6';

View File

@ -0,0 +1,2 @@
export const TEST_PET_ID_1 = 'a4907cff-a582-4daf-8635-ad6c782c7c25';
export const TEST_PET_ID_2 = 'c4e97187-9b9b-4e1f-a3c5-b7883c590332';

View File

@ -1,89 +1,58 @@
import { randomUUID } from 'crypto';
import { OBJECT_MODEL_COMMON_FIELDS } from 'test/integration/constants/object-model-common-fields';
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { performCreateManyOperation } from 'test/integration/graphql/utils/perform-create-many-operation.utils';
import {
SearchFactoryParams,
searchFactory,
} from 'test/integration/graphql/utils/search-factory.util';
import {
LISTING_NAME_PLURAL,
LISTING_NAME_SINGULAR,
} from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { searchFactory } from 'test/integration/graphql/utils/search-factory.util';
import { EachTestingContext } from 'twenty-shared/testing';
import {
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
TEST_PERSON_3_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { TEST_API_KEY_1_ID } from 'test/integration/constants/test-api-key-ids.constant';
import { cleanTestDatabase } from 'test/integration/utils/clean-test-database';
import {
TEST_PET_ID_1,
TEST_PET_ID_2,
} from 'test/integration/constants/test-pet-ids.constants';
import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record-dto';
import { SearchResultEdgeDTO } from 'src/engine/core-modules/search/dtos/search-result-edge.dto';
import {
decodeCursor,
encodeCursorData,
} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { SearchCursor } from 'src/engine/core-modules/search/services/search.service';
import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
describe('SearchResolver', () => {
let listingObjectMetadataId: { objectMetadataId: string };
const [firstPerson, secondPerson, thirdPerson] = [
{ id: randomUUID(), name: { firstName: 'searchInput1' } },
{ id: randomUUID(), name: { firstName: 'searchInput2' } },
{ id: randomUUID(), name: { firstName: 'searchInput3' } },
{ id: TEST_PERSON_1_ID, name: { firstName: 'searchInput1' } },
{ id: TEST_PERSON_2_ID, name: { firstName: 'searchInput2' } },
{ id: TEST_PERSON_3_ID, name: { firstName: 'searchInput3' } },
];
const [apiKey] = [
{
id: randomUUID(),
id: TEST_API_KEY_1_ID,
name: 'record not searchable',
expiresAt: new Date(Date.now()),
},
];
const [firstListing, secondListing] = [
{ id: randomUUID(), name: 'searchInput1' },
{ id: randomUUID(), name: 'searchInput2' },
const [firstPet, secondPet] = [
{ id: TEST_PET_ID_1, name: 'searchInput1' },
{ id: TEST_PET_ID_2, name: 'searchInput2' },
];
const hasSearchRecord = (search: SearchRecordDTO[], recordId: string) => {
return search.some((item: SearchRecordDTO) => item.recordId === recordId);
};
beforeAll(async () => {
await cleanTestDatabase({ seed: false });
try {
const objectsMetadata = await findManyObjectMetadata({
input: {
filter: {},
paging: {
first: 1000,
},
},
});
const listingObjectMetadata = objectsMetadata.objects.find(
(object) => object.nameSingular === LISTING_NAME_SINGULAR,
);
if (listingObjectMetadata) {
listingObjectMetadataId = {
objectMetadataId: listingObjectMetadata.id,
};
} else {
const { data } = await createOneObjectMetadata({
input: {
labelSingular: LISTING_NAME_SINGULAR,
labelPlural: LISTING_NAME_PLURAL,
nameSingular: LISTING_NAME_SINGULAR,
namePlural: LISTING_NAME_PLURAL,
icon: 'IconBuildingSkyscraper',
},
});
listingObjectMetadataId = {
objectMetadataId: data.createOneObject.id,
};
}
await performCreateManyOperation(
LISTING_NAME_SINGULAR,
LISTING_NAME_PLURAL,
'pet',
'pets',
OBJECT_MODEL_COMMON_FIELDS,
[firstListing, secondListing],
[firstPet, secondPet],
);
await performCreateManyOperation('person', 'people', PERSON_GQL_FIELDS, [
@ -106,46 +75,17 @@ describe('SearchResolver', () => {
});
afterAll(async () => {
await makeGraphqlAPIRequest(
destroyManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
in: [firstPerson.id, secondPerson.id, thirdPerson.id],
},
},
}),
).catch((error) => {
// eslint-disable-next-line no-console
console.log(error);
});
await deleteOneObjectMetadata({
input: { idToDelete: listingObjectMetadataId.objectMetadataId },
}).catch((error) => {
// eslint-disable-next-line no-console
console.log(error);
});
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'apiKey',
gqlFields: OBJECT_MODEL_COMMON_FIELDS,
recordId: apiKey.id,
}),
).catch((error) => {
// eslint-disable-next-line no-console
console.log(error);
});
await cleanTestDatabase({ seed: true });
});
const testsUseCases: EachTestingContext<{
input: SearchFactoryParams;
input: SearchArgs;
eval: {
definedRecordIds: string[];
undefinedRecordIds: string[];
orderedRecordIds: string[];
pageInfo: {
hasNextPage: boolean;
decodedEndCursor: SearchCursor | null;
};
};
}>[] = [
{
@ -154,10 +94,26 @@ describe('SearchResolver', () => {
context: {
input: {
searchInput: '',
limit: 50,
},
eval: {
definedRecordIds: [firstListing.id, secondListing.id],
undefinedRecordIds: [apiKey.id],
orderedRecordIds: [
firstPerson.id,
secondPerson.id,
thirdPerson.id,
firstPet.id,
secondPet.id,
],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0, tsRankCD: 0 },
lastRecordIdsPerObject: {
person: thirdPerson.id,
pet: secondPet.id,
},
},
},
},
},
},
@ -166,10 +122,20 @@ describe('SearchResolver', () => {
context: {
input: {
searchInput: 'searchInput1',
limit: 50,
},
eval: {
definedRecordIds: [firstPerson.id, firstListing.id],
undefinedRecordIds: [secondPerson.id, secondListing.id],
orderedRecordIds: [firstPerson.id, firstPet.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: firstPerson.id,
pet: firstPet.id,
},
},
},
},
},
},
@ -178,11 +144,20 @@ describe('SearchResolver', () => {
context: {
input: {
searchInput: '',
includedObjectNameSingulars: [LISTING_NAME_SINGULAR],
includedObjectNameSingulars: ['pet'],
limit: 50,
},
eval: {
definedRecordIds: [firstListing.id, secondListing.id],
undefinedRecordIds: [firstPerson.id, secondPerson.id],
orderedRecordIds: [firstPet.id, secondPet.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0, tsRankCD: 0 },
lastRecordIdsPerObject: {
pet: secondPet.id,
},
},
},
},
},
},
@ -192,10 +167,19 @@ describe('SearchResolver', () => {
input: {
searchInput: '',
excludedObjectNameSingulars: ['person'],
limit: 50,
},
eval: {
definedRecordIds: [firstListing.id, secondListing.id],
undefinedRecordIds: [firstPerson.id, secondPerson.id],
orderedRecordIds: [firstPet.id, secondPet.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0, tsRankCD: 0 },
lastRecordIdsPerObject: {
pet: secondPet.id,
},
},
},
},
},
},
@ -204,13 +188,263 @@ describe('SearchResolver', () => {
context: {
input: {
searchInput: '',
filter: {
id: { eq: firstListing.id },
},
filter: { id: { eq: firstPet.id } },
limit: 50,
},
eval: {
definedRecordIds: [firstListing.id],
undefinedRecordIds: [secondListing.id],
orderedRecordIds: [firstPet.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0, tsRankCD: 0 },
lastRecordIdsPerObject: {
pet: firstPet.id,
},
},
},
},
},
},
{
title: 'should limit records number with limit',
context: {
input: {
searchInput: '',
limit: 4,
},
eval: {
orderedRecordIds: [
firstPerson.id,
secondPerson.id,
thirdPerson.id,
firstPet.id,
],
pageInfo: {
hasNextPage: true,
decodedEndCursor: {
lastRanks: { tsRank: 0, tsRankCD: 0 },
lastRecordIdsPerObject: {
pet: firstPet.id,
person: thirdPerson.id,
},
},
},
},
},
},
{
title: 'should return endCursor when paginating',
context: {
input: {
searchInput: '',
limit: 2,
},
eval: {
orderedRecordIds: [firstPerson.id, secondPerson.id],
pageInfo: {
hasNextPage: true,
decodedEndCursor: {
lastRanks: { tsRank: 0, tsRankCD: 0 },
lastRecordIdsPerObject: {
person: secondPerson.id,
},
},
},
},
},
},
{
title: 'should return endCursor when paginating with Cursor',
context: {
input: {
searchInput: '',
after: encodeCursorData({
lastRanks: { tsRank: 0, tsRankCD: 0 },
lastRecordIdsPerObject: {
person: secondPerson.id,
},
}),
limit: 2,
},
eval: {
orderedRecordIds: [thirdPerson.id, firstPet.id],
pageInfo: {
hasNextPage: true,
decodedEndCursor: {
lastRanks: { tsRank: 0, tsRankCD: 0 },
lastRecordIdsPerObject: {
pet: firstPet.id,
person: thirdPerson.id,
},
},
},
},
},
},
{
title: 'should limit records number with limit and searchInput',
context: {
input: {
searchInput: 'searchInput',
limit: 4,
},
eval: {
orderedRecordIds: [
firstPerson.id,
secondPerson.id,
thirdPerson.id,
firstPet.id,
],
pageInfo: {
hasNextPage: true,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
pet: firstPet.id,
person: thirdPerson.id,
},
},
},
},
},
},
{
title: 'should return endCursor when paginating with searchInput',
context: {
input: {
searchInput: 'searchInput',
limit: 2,
},
eval: {
orderedRecordIds: [firstPerson.id, secondPerson.id],
pageInfo: {
hasNextPage: true,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: secondPerson.id,
},
},
},
},
},
},
{
title:
'should return endCursor when paginating with searchInput with Cursor',
context: {
input: {
searchInput: 'searchInput',
after: encodeCursorData({
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: secondPerson.id,
},
}),
limit: 2,
},
eval: {
orderedRecordIds: [thirdPerson.id, firstPet.id],
pageInfo: {
hasNextPage: true,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
pet: firstPet.id,
person: thirdPerson.id,
},
},
},
},
},
},
{
title:
'should return endCursor when paginating with searchInput with Cursor and filter',
context: {
input: {
searchInput: 'searchInput',
after: encodeCursorData({
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: secondPerson.id,
},
}),
limit: 2,
filter: { id: { neq: firstPet.id } },
},
eval: {
orderedRecordIds: [thirdPerson.id, secondPet.id],
pageInfo: {
hasNextPage: false,
decodedEndCursor: {
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
lastRecordIdsPerObject: {
person: thirdPerson.id,
pet: secondPet.id,
},
},
},
},
},
},
{
title: 'should paginate properly with excludedObject',
context: {
input: {
searchInput: '',
excludedObjectNameSingulars: ['person'],
limit: 1,
},
eval: {
orderedRecordIds: [firstPet.id],
pageInfo: {
hasNextPage: true,
decodedEndCursor: {
lastRanks: { tsRank: 0, tsRankCD: 0 },
lastRecordIdsPerObject: {
pet: firstPet.id,
},
},
},
},
},
},
{
title: 'should paginate properly with included Objects only',
context: {
input: {
searchInput: '',
includedObjectNameSingulars: ['pet'],
limit: 1,
},
eval: {
orderedRecordIds: [firstPet.id],
pageInfo: {
hasNextPage: true,
decodedEndCursor: {
lastRanks: { tsRank: 0, tsRankCD: 0 },
lastRecordIdsPerObject: {
pet: firstPet.id,
},
},
},
},
},
},
{
title: 'should paginate properly when no records are returned',
context: {
input: {
searchInput: '',
limit: 0,
},
eval: {
orderedRecordIds: [],
pageInfo: {
hasNextPage: true,
decodedEndCursor: null,
},
},
},
},
@ -224,17 +458,127 @@ describe('SearchResolver', () => {
expect(response.body.data.search).toBeDefined();
const search = response.body.data.search;
const edges = search.edges;
const pageInfo = search.pageInfo;
context.eval.definedRecordIds.length > 0
? expect(search).not.toHaveLength(0)
: expect(search).toHaveLength(0);
context.eval.orderedRecordIds.length > 0
? expect(edges).not.toHaveLength(0)
: expect(edges).toHaveLength(0);
context.eval.definedRecordIds.forEach((recordId) => {
expect(hasSearchRecord(search, recordId)).toBeTruthy();
expect(
edges.map((edge: SearchResultEdgeDTO) => edge.node.recordId),
).toEqual(context.eval.orderedRecordIds);
expect(pageInfo).toBeDefined();
expect(context.eval.pageInfo.hasNextPage).toEqual(pageInfo.hasNextPage);
expect(context.eval.pageInfo.decodedEndCursor).toEqual(
pageInfo.endCursor
? decodeCursor(pageInfo.endCursor)
: pageInfo.endCursor,
);
});
it('should return cursor for each search edge', async () => {
const graphqlOperation = searchFactory({
searchInput: 'searchInput',
limit: 2,
});
context.eval.undefinedRecordIds.forEach((recordId) => {
expect(hasSearchRecord(search, recordId)).toBeFalsy();
const response = await makeGraphqlAPIRequest(graphqlOperation);
const expectedResult = {
edges: [
{
cursor: encodeCursorData({
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
lastRecordIdsPerObject: {
person: firstPerson.id,
},
}),
},
{
cursor: encodeCursorData({
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
lastRecordIdsPerObject: {
person: secondPerson.id,
},
}),
},
],
pageInfo: {
hasNextPage: true,
endCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
lastRecordIdsPerObject: {
person: secondPerson.id,
},
}),
},
};
expect({
...response.body.data.search,
edges: response.body.data.search.edges.map(
(edge: SearchResultEdgeDTO) => ({
cursor: edge.cursor,
}),
),
}).toEqual(expectedResult);
});
it('should return cursor for each search edge with after cursor input', async () => {
const graphqlOperation = searchFactory({
searchInput: 'searchInput',
limit: 2,
after: encodeCursorData({
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
lastRecordIdsPerObject: {
person: secondPerson.id,
},
}),
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
const expectedResult = {
edges: [
{
cursor: encodeCursorData({
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
lastRecordIdsPerObject: {
person: thirdPerson.id,
},
}),
},
{
cursor: encodeCursorData({
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
lastRecordIdsPerObject: {
person: thirdPerson.id,
pet: firstPet.id,
},
}),
},
],
pageInfo: {
hasNextPage: true,
endCursor: encodeCursorData({
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
lastRecordIdsPerObject: {
person: thirdPerson.id,
pet: firstPet.id,
},
}),
},
};
expect({
...response.body.data.search,
edges: response.body.data.search.edges.map(
(edge: SearchResultEdgeDTO) => ({
cursor: edge.cursor,
}),
),
}).toEqual(expectedResult);
});
});

View File

@ -1,24 +1,20 @@
import gql from 'graphql-tag';
import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input';
export type SearchFactoryParams = {
searchInput: string;
excludedObjectNameSingulars?: string[];
includedObjectNameSingulars?: string[];
filter?: ObjectRecordFilterInput;
};
import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
export const searchFactory = ({
searchInput,
excludedObjectNameSingulars,
includedObjectNameSingulars,
filter,
}: SearchFactoryParams) => ({
after,
limit = 50,
}: SearchArgs) => ({
query: gql`
query Search(
$searchInput: String!
$limit: Int!
$after: String
$excludedObjectNameSingulars: [String!]
$includedObjectNameSingulars: [String!]
$filter: ObjectRecordFilterInput
@ -26,22 +22,33 @@ export const searchFactory = ({
search(
searchInput: $searchInput
limit: $limit
after: $after
excludedObjectNameSingulars: $excludedObjectNameSingulars
includedObjectNameSingulars: $includedObjectNameSingulars
filter: $filter
) {
recordId
objectNameSingular
label
imageUrl
tsRankCD
tsRank
pageInfo {
hasNextPage
endCursor
}
edges {
node {
recordId
objectNameSingular
label
imageUrl
tsRankCD
tsRank
}
cursor
}
}
}
`,
variables: {
searchInput,
limit: 50,
limit,
after,
excludedObjectNameSingulars,
includedObjectNameSingulars,
filter,

View File

@ -1,2 +1,2 @@
export const LISTING_NAME_SINGULAR = 'listinga';
export const LISTING_NAME_PLURAL = 'listingas';
export const LISTING_NAME_SINGULAR = 'listing';
export const LISTING_NAME_PLURAL = 'listings';

View File

@ -4,9 +4,9 @@ import {
} from 'test/integration/constants/test-person-ids.constants';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
describe('Core REST API Update One endpoint', () => {
const updatedData = {

View File

@ -0,0 +1,44 @@
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces';
export const cleanTestDatabase = async ({ seed }: { seed: boolean }) => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
"Don't run this 'setupTest' function in a non test environment",
);
}
await Promise.all([
...[
'person',
'company',
'opportunity',
'workspaceMember',
'_pet',
'_surveyResult',
].map(
async (objectMetadataNameSingular) =>
await deleteAllRecords(objectMetadataNameSingular),
),
]);
if (!seed) {
return;
}
// @ts-expect-error legacy noImplicitAny
const mainDataSource = global.typeOrmService.getMainDataSource();
const dataSourceMetadata =
// @ts-expect-error legacy noImplicitAny
await global.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
SEED_APPLE_WORKSPACE_ID,
);
// @ts-expect-error legacy noImplicitAny
await global.dataSeedWorkspaceCommand.seedRecords({
mainDataSource,
dataSourceMetadata,
});
};

View File

@ -8,6 +8,7 @@ import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { ExceptionHandlerMockService } from 'src/engine/core-modules/exception-handler/mocks/exception-handler-mock.service';
import { MockedUnhandledExceptionFilter } from 'src/engine/core-modules/exception-handler/mocks/mock-unhandled-exception.filter';
import { CommandModule } from 'src/command/command.module';
interface TestingModuleCreatePreHook {
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
@ -32,7 +33,7 @@ export const createApp = async (
const stripeSDKMockService = new StripeSDKMockService();
const mockExceptionHandlerService = new ExceptionHandlerMockService();
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
imports: [AppModule],
imports: [AppModule, CommandModule],
providers: [
{
provide: APP_FILTER,

View File

@ -2,6 +2,9 @@ import { JestConfigWithTsJest } from 'ts-jest';
import 'tsconfig-paths/register';
import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
import { createApp } from './create-app';
@ -21,4 +24,10 @@ export default async (_, projectConfig: JestConfigWithTsJest) => {
global.app = app;
// @ts-expect-error legacy noImplicitAny
global.testDataSource = rawDataSource;
// @ts-expect-error legacy noImplicitAny
global.typeOrmService = app.get(TypeORMService);
// @ts-expect-error legacy noImplicitAny
global.dataSourceService = app.get(DataSourceService);
// @ts-expect-error legacy noImplicitAny
global.dataSeedWorkspaceCommand = app.get(DataSeedWorkspaceCommand);
};