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:
@ -1688,7 +1688,7 @@ export type Query = {
|
|||||||
objects: ObjectConnection;
|
objects: ObjectConnection;
|
||||||
plans: Array<BillingPlanOutput>;
|
plans: Array<BillingPlanOutput>;
|
||||||
relationMetadata: RelationMetadataConnection;
|
relationMetadata: RelationMetadataConnection;
|
||||||
search: Array<SearchRecord>;
|
search: SearchResultConnection;
|
||||||
validatePasswordResetToken: ValidatePasswordResetToken;
|
validatePasswordResetToken: ValidatePasswordResetToken;
|
||||||
versionInfo: VersionInfo;
|
versionInfo: VersionInfo;
|
||||||
};
|
};
|
||||||
@ -1828,6 +1828,7 @@ export type QueryRelationMetadataArgs = {
|
|||||||
|
|
||||||
|
|
||||||
export type QuerySearchArgs = {
|
export type QuerySearchArgs = {
|
||||||
|
after?: InputMaybe<Scalars['String']['input']>;
|
||||||
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']['input']>>;
|
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||||
filter?: InputMaybe<ObjectRecordFilterInput>;
|
filter?: InputMaybe<ObjectRecordFilterInput>;
|
||||||
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']['input']>>;
|
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||||
@ -2046,6 +2047,24 @@ export type SearchRecord = {
|
|||||||
tsRankCD: Scalars['Float']['output'];
|
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 = {
|
export type SendInvitationsOutput = {
|
||||||
__typename?: 'SendInvitationsOutput';
|
__typename?: 'SendInvitationsOutput';
|
||||||
errors: Array<Scalars['String']['output']>;
|
errors: Array<Scalars['String']['output']>;
|
||||||
|
|||||||
@ -1539,7 +1539,7 @@ export type Query = {
|
|||||||
object: Object;
|
object: Object;
|
||||||
objects: ObjectConnection;
|
objects: ObjectConnection;
|
||||||
plans: Array<BillingPlanOutput>;
|
plans: Array<BillingPlanOutput>;
|
||||||
search: Array<SearchRecord>;
|
search: SearchResultConnection;
|
||||||
validatePasswordResetToken: ValidatePasswordResetToken;
|
validatePasswordResetToken: ValidatePasswordResetToken;
|
||||||
versionInfo: VersionInfo;
|
versionInfo: VersionInfo;
|
||||||
};
|
};
|
||||||
@ -1626,6 +1626,7 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = {
|
|||||||
|
|
||||||
|
|
||||||
export type QuerySearchArgs = {
|
export type QuerySearchArgs = {
|
||||||
|
after?: InputMaybe<Scalars['String']>;
|
||||||
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
|
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
|
||||||
filter?: InputMaybe<ObjectRecordFilterInput>;
|
filter?: InputMaybe<ObjectRecordFilterInput>;
|
||||||
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
|
includedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
|
||||||
@ -1830,6 +1831,24 @@ export type SearchRecord = {
|
|||||||
tsRankCD: Scalars['Float'];
|
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 = {
|
export type SendInvitationsOutput = {
|
||||||
__typename?: 'SendInvitationsOutput';
|
__typename?: 'SendInvitationsOutput';
|
||||||
errors: Array<Scalars['String']>;
|
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; }>;
|
export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
@ -4608,12 +4627,21 @@ export const SearchDocument = gql`
|
|||||||
includedObjectNameSingulars: $includedObjectNameSingulars
|
includedObjectNameSingulars: $includedObjectNameSingulars
|
||||||
filter: $filter
|
filter: $filter
|
||||||
) {
|
) {
|
||||||
recordId
|
edges {
|
||||||
objectNameSingular
|
node {
|
||||||
label
|
recordId
|
||||||
imageUrl
|
objectNameSingular
|
||||||
tsRankCD
|
label
|
||||||
tsRank
|
imageUrl
|
||||||
|
tsRankCD
|
||||||
|
tsRank
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -172,7 +172,13 @@ export const NoResultsSearchFallback: Story = {
|
|||||||
graphql.query('Search', () => {
|
graphql.query('Search', () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: {
|
data: {
|
||||||
search: [],
|
search: {
|
||||||
|
edges: [],
|
||||||
|
pageInfo: {
|
||||||
|
hasNextPage: false,
|
||||||
|
endCursor: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import gql from 'graphql-tag';
|
import gql from 'graphql-tag';
|
||||||
|
|
||||||
export const search = gql`
|
export const SEARCH_QUERY = gql`
|
||||||
query Search(
|
query Search(
|
||||||
$searchInput: String!
|
$searchInput: String!
|
||||||
$limit: Int!
|
$limit: Int!
|
||||||
|
$after: String
|
||||||
$excludedObjectNameSingulars: [String!]
|
$excludedObjectNameSingulars: [String!]
|
||||||
$includedObjectNameSingulars: [String!]
|
$includedObjectNameSingulars: [String!]
|
||||||
$filter: ObjectRecordFilterInput
|
$filter: ObjectRecordFilterInput
|
||||||
@ -11,16 +12,26 @@ export const search = gql`
|
|||||||
search(
|
search(
|
||||||
searchInput: $searchInput
|
searchInput: $searchInput
|
||||||
limit: $limit
|
limit: $limit
|
||||||
|
after: $after
|
||||||
excludedObjectNameSingulars: $excludedObjectNameSingulars
|
excludedObjectNameSingulars: $excludedObjectNameSingulars
|
||||||
includedObjectNameSingulars: $includedObjectNameSingulars
|
includedObjectNameSingulars: $includedObjectNameSingulars
|
||||||
filter: $filter
|
filter: $filter
|
||||||
) {
|
) {
|
||||||
recordId
|
edges {
|
||||||
objectNameSingular
|
node {
|
||||||
label
|
recordId
|
||||||
imageUrl
|
objectNameSingular
|
||||||
tsRankCD
|
label
|
||||||
tsRank
|
imageUrl
|
||||||
|
tsRankCD
|
||||||
|
tsRank
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -31,69 +31,71 @@ export const useCommandMenuSearchRecords = () => {
|
|||||||
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
|
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
|
||||||
|
|
||||||
const actionItems = useMemo(() => {
|
const actionItems = useMemo(() => {
|
||||||
return (searchData?.search ?? []).map((searchRecord, index) => {
|
return (searchData?.search.edges.map((edge) => edge.node) ?? []).map(
|
||||||
const baseAction = {
|
(searchRecord, index) => {
|
||||||
type: ActionType.Navigation,
|
const baseAction = {
|
||||||
scope: ActionScope.Global,
|
type: ActionType.Navigation,
|
||||||
key: searchRecord.recordId,
|
scope: ActionScope.Global,
|
||||||
label: searchRecord.label,
|
key: searchRecord.recordId,
|
||||||
position: index,
|
label: searchRecord.label,
|
||||||
Icon: () => (
|
position: index,
|
||||||
<Avatar
|
Icon: () => (
|
||||||
type={
|
<Avatar
|
||||||
searchRecord.objectNameSingular === 'company'
|
type={
|
||||||
? 'squared'
|
searchRecord.objectNameSingular === 'company'
|
||||||
: 'rounded'
|
? 'squared'
|
||||||
}
|
: 'rounded'
|
||||||
avatarUrl={searchRecord.imageUrl}
|
}
|
||||||
placeholderColorSeed={searchRecord.recordId}
|
avatarUrl={searchRecord.imageUrl}
|
||||||
placeholder={searchRecord.label}
|
placeholderColorSeed={searchRecord.recordId}
|
||||||
/>
|
placeholder={searchRecord.label}
|
||||||
),
|
/>
|
||||||
shouldBeRegistered: () => true,
|
),
|
||||||
description: capitalize(searchRecord.objectNameSingular),
|
shouldBeRegistered: () => true,
|
||||||
shouldCloseCommandMenuOnClick: 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 {
|
return {
|
||||||
...baseAction,
|
...baseAction,
|
||||||
component: (
|
component: (
|
||||||
<Action
|
<ActionLink
|
||||||
onClick={() => {
|
to={AppPath.RecordShowPage}
|
||||||
searchRecord.objectNameSingular === 'task'
|
params={{
|
||||||
? openRecordInCommandMenu({
|
objectNameSingular: searchRecord.objectNameSingular,
|
||||||
recordId: searchRecord.recordId,
|
objectRecordId: searchRecord.recordId,
|
||||||
objectNameSingular: CoreObjectNameSingular.Task,
|
|
||||||
})
|
|
||||||
: openRecordInCommandMenu({
|
|
||||||
recordId: searchRecord.recordId,
|
|
||||||
objectNameSingular: CoreObjectNameSingular.Note,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
preventCommandMenuClosing
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
|
);
|
||||||
return {
|
|
||||||
...baseAction,
|
|
||||||
component: (
|
|
||||||
<ActionLink
|
|
||||||
to={AppPath.RecordShowPage}
|
|
||||||
params={{
|
|
||||||
objectNameSingular: searchRecord.objectNameSingular,
|
|
||||||
objectRecordId: searchRecord.recordId,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [searchData, openRecordInCommandMenu]);
|
}, [searchData, openRecordInCommandMenu]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -65,7 +65,7 @@ export const useObjectRecordSearchRecords = ({
|
|||||||
const effectiveData = loading ? previousData : data;
|
const effectiveData = loading ? previousData : data;
|
||||||
|
|
||||||
const searchRecords = useMemo(
|
const searchRecords = useMemo(
|
||||||
() => effectiveData?.search || [],
|
() => effectiveData?.search.edges.map((edge) => edge.node) || [],
|
||||||
[effectiveData],
|
[effectiveData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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 { MultipleRecordPickerOnClickOutsideEffect } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerOnClickOutsideEffect';
|
||||||
import { MultipleRecordPickerSearchInput } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerSearchInput';
|
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 { 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 { 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 { MultipleRecordPickerHotkeyScope } from '@/object-record/record-picker/multiple-record-picker/types/MultipleRecordPickerHotkeyScope';
|
||||||
import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId';
|
import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId';
|
||||||
import { RecordPickerLayoutDirection } from '@/object-record/record-picker/types/RecordPickerLayoutDirection';
|
import { RecordPickerLayoutDirection } from '@/object-record/record-picker/types/RecordPickerLayoutDirection';
|
||||||
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
|
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
|
||||||
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
|
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
|
||||||
import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton';
|
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 { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
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 { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||||
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 { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||||
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
|
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 styled from '@emotion/styled';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useRecoilCallback } from 'recoil';
|
import { useRecoilCallback } from 'recoil';
|
||||||
@ -59,16 +54,6 @@ export const MultipleRecordPicker = ({
|
|||||||
selectableListComponentInstanceId,
|
selectableListComponentInstanceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const multipleRecordPickerIsLoading = useRecoilComponentValueV2(
|
|
||||||
multipleRecordPickerIsLoadingComponentState,
|
|
||||||
componentInstanceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const itemsLength = useRecoilComponentValueV2(
|
|
||||||
multipleRecordPickerPickableMorphItemsLengthComponentSelector,
|
|
||||||
componentInstanceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const multipleRecordPickerSearchFilterState =
|
const multipleRecordPickerSearchFilterState =
|
||||||
useRecoilComponentCallbackStateV2(
|
useRecoilComponentCallbackStateV2(
|
||||||
multipleRecordPickerSearchFilterComponentState,
|
multipleRecordPickerSearchFilterComponentState,
|
||||||
@ -106,13 +91,16 @@ export const MultipleRecordPicker = ({
|
|||||||
[multipleRecordPickerSearchFilterState, onCreate],
|
[multipleRecordPickerSearchFilterState, onCreate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createNewButton = isDefined(onCreate) && (
|
const createNewButtonSection =
|
||||||
<CreateNewButton
|
isDefined(onCreate) && !hasObjectReadOnlyPermission ? (
|
||||||
onClick={handleCreateNewButtonClick}
|
<DropdownMenuItemsContainer scrollable={false}>
|
||||||
LeftIcon={IconPlus}
|
<CreateNewButton
|
||||||
text="Add New"
|
onClick={handleCreateNewButtonClick}
|
||||||
/>
|
LeftIcon={IconPlus}
|
||||||
);
|
text="Add New"
|
||||||
|
/>
|
||||||
|
</DropdownMenuItemsContainer>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultipleRecordPickerComponentInstanceContext.Provider
|
<MultipleRecordPickerComponentInstanceContext.Provider
|
||||||
@ -125,43 +113,15 @@ export const MultipleRecordPicker = ({
|
|||||||
<DropdownMenu ref={containerRef} data-select-disable width={200}>
|
<DropdownMenu ref={containerRef} data-select-disable width={200}>
|
||||||
{layoutDirection === 'search-bar-on-bottom' && (
|
{layoutDirection === 'search-bar-on-bottom' && (
|
||||||
<>
|
<>
|
||||||
{isDefined(onCreate) && !hasObjectReadOnlyPermission && (
|
{createNewButtonSection}
|
||||||
<DropdownMenuItemsContainer scrollable={false}>
|
<MultipleRecordPickerItemsDisplay onChange={onChange} />
|
||||||
{createNewButton}
|
|
||||||
</DropdownMenuItemsContainer>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{itemsLength > 0 && (
|
|
||||||
<MultipleRecordPickerMenuItems onChange={onChange} />
|
|
||||||
)}
|
|
||||||
{multipleRecordPickerIsLoading && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSkeletonItem />
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{itemsLength > 0 && <DropdownMenuSeparator />}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<MultipleRecordPickerSearchInput />
|
<MultipleRecordPickerSearchInput />
|
||||||
{layoutDirection === 'search-bar-on-top' && (
|
{layoutDirection === 'search-bar-on-top' && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<MultipleRecordPickerItemsDisplay onChange={onChange} />
|
||||||
{multipleRecordPickerIsLoading && (
|
{createNewButtonSection}
|
||||||
<>
|
|
||||||
<DropdownMenuSkeletonItem />
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{itemsLength > 0 && (
|
|
||||||
<MultipleRecordPickerMenuItems onChange={onChange} />
|
|
||||||
)}
|
|
||||||
{itemsLength > 0 && <DropdownMenuSeparator />}
|
|
||||||
{isDefined(onCreate) && (
|
|
||||||
<DropdownMenuItemsContainer scrollable={false}>
|
|
||||||
{createNewButton}
|
|
||||||
</DropdownMenuItemsContainer>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import styled from '@emotion/styled';
|
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 { 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 { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
|
||||||
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
|
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
|
||||||
@ -21,6 +22,14 @@ export const StyledSelectableItem = styled(SelectableListItem)`
|
|||||||
width: 100%;
|
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 = {
|
type MultipleRecordPickerMenuItemsProps = {
|
||||||
onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
|
onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
|
||||||
};
|
};
|
||||||
@ -77,25 +86,30 @@ export const MultipleRecordPickerMenuItems = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItemsContainer hasMaxHeight>
|
<DropdownMenuItemsContainer hasMaxHeight>
|
||||||
<SelectableList
|
{pickableRecordIds.length === 0 ? (
|
||||||
selectableListInstanceId={selectableListComponentInstanceId}
|
<StyledEmptyText>No results found</StyledEmptyText>
|
||||||
selectableItemIdArray={pickableRecordIds}
|
) : (
|
||||||
hotkeyScope={MultipleRecordPickerHotkeyScope.MultipleRecordPicker}
|
<SelectableList
|
||||||
>
|
selectableListInstanceId={selectableListComponentInstanceId}
|
||||||
{pickableRecordIds.map((recordId) => {
|
selectableItemIdArray={pickableRecordIds}
|
||||||
return (
|
hotkeyScope={MultipleRecordPickerHotkeyScope.MultipleRecordPicker}
|
||||||
<MultipleRecordPickerMenuItem
|
>
|
||||||
key={recordId}
|
{pickableRecordIds.map((recordId) => {
|
||||||
recordId={recordId}
|
return (
|
||||||
onChange={(morphItem) => {
|
<MultipleRecordPickerMenuItem
|
||||||
handleChange(morphItem);
|
key={recordId}
|
||||||
onChange?.(morphItem);
|
recordId={recordId}
|
||||||
resetSelectedItem();
|
onChange={(morphItem) => {
|
||||||
}}
|
handleChange(morphItem);
|
||||||
/>
|
onChange?.(morphItem);
|
||||||
);
|
resetSelectedItem();
|
||||||
})}
|
}}
|
||||||
</SelectableList>
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<MultipleRecordPickerFetchMoreLoader />
|
||||||
|
</SelectableList>
|
||||||
|
)}
|
||||||
</DropdownMenuItemsContainer>
|
</DropdownMenuItemsContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/
|
|||||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||||
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
|
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 = () => {
|
export const MultipleRecordPickerSearchInput = () => {
|
||||||
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
|
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
|
||||||
@ -16,17 +17,33 @@ export const MultipleRecordPickerSearchInput = () => {
|
|||||||
|
|
||||||
const { performSearch } = useMultipleRecordPickerPerformSearch();
|
const { performSearch } = useMultipleRecordPickerPerformSearch();
|
||||||
|
|
||||||
const handleFilterChange = useCallback(
|
const debouncedSearch = useDebouncedCallback(
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
useRecoilCallback(
|
||||||
setRecordPickerSearchFilter(event.currentTarget.value);
|
({ set }) =>
|
||||||
performSearch({
|
(searchFilter: string) => {
|
||||||
multipleRecordPickerInstanceId: componentInstanceId,
|
set(
|
||||||
forceSearchFilter: event.currentTarget.value,
|
multipleRecordPickerSearchFilterComponentState.atomFamily({
|
||||||
});
|
instanceId: componentInstanceId,
|
||||||
},
|
}),
|
||||||
[componentInstanceId, performSearch, setRecordPickerSearchFilter],
|
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 (
|
return (
|
||||||
<DropdownMenuSearchInput
|
<DropdownMenuSearchInput
|
||||||
value={recordPickerSearchFilter}
|
value={recordPickerSearchFilter}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
|
import { SEARCH_QUERY } from '@/command-menu/graphql/queries/search';
|
||||||
import { search } from '@/command-menu/graphql/queries/search';
|
|
||||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||||
import { usePerformCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/usePerformCombinedFindManyRecords';
|
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 { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
|
||||||
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
|
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
|
||||||
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
|
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 { useRecoilCallback } from 'recoil';
|
||||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||||
import { SearchRecord } from '~/generated-metadata/graphql';
|
import { SearchRecord } from '~/generated-metadata/graphql';
|
||||||
|
import { SearchResultEdge } from '~/generated/graphql';
|
||||||
|
|
||||||
|
const MULTIPLE_RECORD_PICKER_PAGE_SIZE = 30;
|
||||||
|
|
||||||
export const useMultipleRecordPickerPerformSearch = () => {
|
export const useMultipleRecordPickerPerformSearch = () => {
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
@ -26,14 +30,40 @@ export const useMultipleRecordPickerPerformSearch = () => {
|
|||||||
forceSearchFilter = '',
|
forceSearchFilter = '',
|
||||||
forceSearchableObjectMetadataItems = [],
|
forceSearchableObjectMetadataItems = [],
|
||||||
forcePickableMorphItems = [],
|
forcePickableMorphItems = [],
|
||||||
|
loadMore = false,
|
||||||
}: {
|
}: {
|
||||||
multipleRecordPickerInstanceId: string;
|
multipleRecordPickerInstanceId: string;
|
||||||
forceSearchFilter?: string;
|
forceSearchFilter?: string;
|
||||||
forceSearchableObjectMetadataItems?: ObjectMetadataItem[];
|
forceSearchableObjectMetadataItems?: ObjectMetadataItem[];
|
||||||
forcePickableMorphItems?: RecordPickerPickableMorphItem[];
|
forcePickableMorphItems?: RecordPickerPickableMorphItem[];
|
||||||
|
loadMore?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { getLoadable } = snapshot;
|
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(
|
const recordPickerSearchFilter = getLoadable(
|
||||||
multipleRecordPickerSearchFilterComponentState.atomFamily({
|
multipleRecordPickerSearchFilterComponentState.atomFamily({
|
||||||
instanceId: multipleRecordPickerInstanceId,
|
instanceId: multipleRecordPickerInstanceId,
|
||||||
@ -70,6 +100,7 @@ export const useMultipleRecordPickerPerformSearch = () => {
|
|||||||
const [
|
const [
|
||||||
searchRecordsFilteredOnPickedRecords,
|
searchRecordsFilteredOnPickedRecords,
|
||||||
searchRecordsExcludingPickedRecords,
|
searchRecordsExcludingPickedRecords,
|
||||||
|
pageInfo,
|
||||||
] = await performSearchQueries({
|
] = await performSearchQueries({
|
||||||
client,
|
client,
|
||||||
searchFilter,
|
searchFilter,
|
||||||
@ -77,28 +108,83 @@ export const useMultipleRecordPickerPerformSearch = () => {
|
|||||||
pickedRecordIds: selectedPickableMorphItems.map(
|
pickedRecordIds: selectedPickableMorphItems.map(
|
||||||
({ recordId }) => recordId,
|
({ recordId }) => recordId,
|
||||||
),
|
),
|
||||||
|
after: loadMore ? paginationState.endCursor : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pickedMorphItems = pickableMorphItems.filter(
|
const existingMorphItems = getLoadable(
|
||||||
({ isSelected }) => isSelected,
|
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 updatedPickedItems = uniquePickedItems.map((morphItem) => {
|
||||||
const updatedPickedMorphItems = pickedMorphItems.map((morphItem) => {
|
if (!searchFilter) {
|
||||||
const record = searchRecordsFilteredOnPickedRecords.find(
|
return {
|
||||||
({ recordId }) => recordId === morphItem.recordId,
|
...morphItem,
|
||||||
);
|
isMatchingSearchFilter: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatchingSearchFilter =
|
||||||
|
searchRecordsFilteredOnPickedRecords.some(
|
||||||
|
({ recordId }) => recordId === morphItem.recordId,
|
||||||
|
) ||
|
||||||
|
searchRecordsExcludingPickedRecords.some(
|
||||||
|
({ recordId }) => recordId === morphItem.recordId,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...morphItem,
|
...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 =
|
const searchRecordsFilteredOnPickedRecordsWithoutDuplicates =
|
||||||
searchRecordsFilteredOnPickedRecords.filter(
|
searchRecordsFilteredOnPickedRecords.filter(
|
||||||
(searchRecord) =>
|
(searchRecord) =>
|
||||||
!updatedPickedMorphItems.some(
|
!updatedPickedItems.some(
|
||||||
|
({ recordId }) => recordId === searchRecord.recordId,
|
||||||
|
) &&
|
||||||
|
!updatedNonPickedExistingItems.some(
|
||||||
({ recordId }) => recordId === searchRecord.recordId,
|
({ recordId }) => recordId === searchRecord.recordId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -109,13 +195,17 @@ export const useMultipleRecordPickerPerformSearch = () => {
|
|||||||
!searchRecordsFilteredOnPickedRecords.some(
|
!searchRecordsFilteredOnPickedRecords.some(
|
||||||
({ recordId }) => recordId === searchRecord.recordId,
|
({ recordId }) => recordId === searchRecord.recordId,
|
||||||
) &&
|
) &&
|
||||||
!pickedMorphItems.some(
|
!updatedPickedItems.some(
|
||||||
|
({ recordId }) => recordId === searchRecord.recordId,
|
||||||
|
) &&
|
||||||
|
!updatedNonPickedExistingItems.some(
|
||||||
({ recordId }) => recordId === searchRecord.recordId,
|
({ recordId }) => recordId === searchRecord.recordId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const morphItems = [
|
const newMorphItems = [
|
||||||
...updatedPickedMorphItems,
|
...updatedPickedItems,
|
||||||
|
...updatedNonPickedExistingItems,
|
||||||
...searchRecordsFilteredOnPickedRecordsWithoutDuplicates.map(
|
...searchRecordsFilteredOnPickedRecordsWithoutDuplicates.map(
|
||||||
({ recordId, objectNameSingular }) => ({
|
({ recordId, objectNameSingular }) => ({
|
||||||
isMatchingSearchFilter: true,
|
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(
|
set(
|
||||||
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
|
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
|
||||||
instanceId: multipleRecordPickerInstanceId,
|
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],
|
[client, performCombinedFindManyRecords],
|
||||||
);
|
);
|
||||||
@ -246,32 +368,46 @@ const performSearchQueries = async ({
|
|||||||
searchFilter,
|
searchFilter,
|
||||||
searchableObjectMetadataItems,
|
searchableObjectMetadataItems,
|
||||||
pickedRecordIds,
|
pickedRecordIds,
|
||||||
|
limit = MULTIPLE_RECORD_PICKER_PAGE_SIZE,
|
||||||
|
after = null,
|
||||||
}: {
|
}: {
|
||||||
client: ApolloClient<object>;
|
client: ApolloClient<object>;
|
||||||
searchFilter: string;
|
searchFilter: string;
|
||||||
searchableObjectMetadataItems: ObjectMetadataItem[];
|
searchableObjectMetadataItems: ObjectMetadataItem[];
|
||||||
pickedRecordIds: string[];
|
pickedRecordIds: string[];
|
||||||
}): Promise<[SearchRecord[], SearchRecord[]]> => {
|
limit?: number;
|
||||||
|
after?: string | null;
|
||||||
|
}): Promise<
|
||||||
|
[
|
||||||
|
SearchRecord[],
|
||||||
|
SearchRecord[],
|
||||||
|
{ hasNextPage: boolean; endCursor: string | null },
|
||||||
|
]
|
||||||
|
> => {
|
||||||
if (searchableObjectMetadataItems.length === 0) {
|
if (searchableObjectMetadataItems.length === 0) {
|
||||||
return [[], []];
|
return [[], [], { hasNextPage: false, endCursor: null }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchRecords = async (filter: any) => {
|
const searchRecords = async (filter: any) => {
|
||||||
const { data } = await client.query({
|
const { data } = await client.query({
|
||||||
query: search,
|
query: SEARCH_QUERY,
|
||||||
variables: {
|
variables: {
|
||||||
searchInput: searchFilter,
|
searchInput: searchFilter,
|
||||||
includedObjectNameSingulars: searchableObjectMetadataItems.map(
|
includedObjectNameSingulars: searchableObjectMetadataItems.map(
|
||||||
({ nameSingular }) => nameSingular,
|
({ nameSingular }) => nameSingular,
|
||||||
),
|
),
|
||||||
filter,
|
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
|
pickedRecordIds.length > 0
|
||||||
? {
|
? {
|
||||||
not: {
|
not: {
|
||||||
@ -283,17 +419,18 @@ const performSearchQueries = async ({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchRecordsIncludingPickedRecords =
|
const searchRecordsIncludingPickedRecordsResult =
|
||||||
pickedRecordIds.length > 0
|
pickedRecordIds.length > 0
|
||||||
? await searchRecords({
|
? await searchRecords({
|
||||||
id: {
|
id: {
|
||||||
in: pickedRecordIds,
|
in: pickedRecordIds,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: [];
|
: { records: [], pageInfo: { hasNextPage: false, endCursor: null } };
|
||||||
|
|
||||||
return [
|
return [
|
||||||
searchRecordsIncludingPickedRecords,
|
searchRecordsIncludingPickedRecordsResult.records,
|
||||||
searchRecordsExcludingPickedRecords,
|
searchRecordsExcludingPickedRecordsResult.records,
|
||||||
|
searchRecordsExcludingPickedRecordsResult.pageInfo,
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -186,44 +186,62 @@ export const graphqlMocks = {
|
|||||||
graphql.query('Search', () => {
|
graphql.query('Search', () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: {
|
data: {
|
||||||
search: [
|
search: {
|
||||||
{
|
edges: [
|
||||||
__typename: 'SearchRecordDTO',
|
{
|
||||||
recordId: '20202020-2d40-4e49-8df4-9c6a049191de',
|
node: {
|
||||||
objectNameSingular: 'person',
|
__typename: 'SearchRecordDTO',
|
||||||
label: 'Louis Duss',
|
recordId: '20202020-2d40-4e49-8df4-9c6a049191de',
|
||||||
imageUrl: '',
|
objectNameSingular: 'person',
|
||||||
tsRankCD: 0.2,
|
label: 'Louis Duss',
|
||||||
tsRank: 0.12158542,
|
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -141,26 +141,59 @@ export class DataSeedWorkspaceCommand extends CommandRunner {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.seedStandardObjectRecords(mainDataSource, dataSourceMetadata);
|
await this.seedCustomObjects({
|
||||||
|
dataSourceMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
await this.seederService.seedCustomObjects(
|
await this.seedRecords({
|
||||||
dataSourceMetadata.id,
|
mainDataSource,
|
||||||
workspaceId,
|
dataSourceMetadata,
|
||||||
PETS_METADATA_SEEDS,
|
});
|
||||||
PETS_DATA_SEEDS,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.seederService.seedCustomObjects(
|
|
||||||
dataSourceMetadata.id,
|
|
||||||
workspaceId,
|
|
||||||
SURVEY_RESULTS_METADATA_SEEDS,
|
|
||||||
SURVEY_RESULTS_DATA_SEEDS,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(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(
|
async seedStandardObjectRecords(
|
||||||
mainDataSource: DataSource,
|
mainDataSource: DataSource,
|
||||||
dataSourceMetadata: DataSourceEntity,
|
dataSourceMetadata: DataSourceEntity,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export interface CursorData {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const decodeCursor = (cursor: string): CursorData => {
|
export const decodeCursor = <T = CursorData>(cursor: string): T => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(Buffer.from(cursor, 'base64').toString());
|
return JSON.parse(Buffer.from(cursor, 'base64').toString());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -45,6 +45,10 @@ export const encodeCursor = <T extends ObjectRecord = ObjectRecord>(
|
|||||||
id: objectRecord.id,
|
id: objectRecord.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return encodeCursorData(cursorData);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encodeCursorData = (cursorData: CursorData) => {
|
||||||
return Buffer.from(JSON.stringify(cursorData)).toString('base64');
|
return Buffer.from(JSON.stringify(cursorData)).toString('base64');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,13 +3,21 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||||
import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/search/__mocks__/mockObjectMetadataItemsWithFieldMaps';
|
import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/search/__mocks__/mockObjectMetadataItemsWithFieldMaps';
|
||||||
import { SearchService } from 'src/engine/core-modules/search/services/search.service';
|
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', () => {
|
describe('SearchService', () => {
|
||||||
let service: SearchService;
|
let service: SearchService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [SearchService, { provide: FileService, useValue: {} }],
|
providers: [
|
||||||
|
SearchService,
|
||||||
|
{ provide: TwentyORMManager, useValue: {} },
|
||||||
|
{ provide: WorkspaceCacheStorageService, useValue: {} },
|
||||||
|
{ provide: FileService, useValue: {} },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<SearchService>(SearchService);
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export const RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS = 8;
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { ArgsType, Field, Int } from '@nestjs/graphql';
|
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';
|
import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input';
|
||||||
|
|
||||||
@ -12,8 +12,13 @@ export class SearchArgs {
|
|||||||
|
|
||||||
@Field(() => Int)
|
@Field(() => Int)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
@Max(100, { message: 'Limit cannot exceed 100 items' })
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
after?: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@Field(() => [String], { nullable: true })
|
@Field(() => [String], { nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -1,41 +1,19 @@
|
|||||||
import { UseFilters } from '@nestjs/common';
|
import { UseFilters } from '@nestjs/common';
|
||||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
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 { 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 { SearchApiExceptionFilter } from 'src/engine/core-modules/search/filters/search-api-exception.filter';
|
||||||
import { SearchService } from 'src/engine/core-modules/search/services/search.service';
|
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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||||
import {
|
import { SearchResultConnectionDTO } from 'src/engine/core-modules/search/dtos/search-result-connection.dto';
|
||||||
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';
|
|
||||||
|
|
||||||
const OBJECT_METADATA_ITEMS_CHUNK_SIZE = 5;
|
@Resolver()
|
||||||
|
|
||||||
@Resolver(() => [SearchRecordDTO])
|
|
||||||
@UseFilters(SearchApiExceptionFilter)
|
@UseFilters(SearchApiExceptionFilter)
|
||||||
export class SearchResolver {
|
export class SearchResolver {
|
||||||
constructor(
|
constructor(private readonly searchService: SearchService) {}
|
||||||
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
|
|
||||||
private readonly twentyORMManager: TwentyORMManager,
|
|
||||||
private readonly searchService: SearchService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Query(() => [SearchRecordDTO])
|
@Query(() => SearchResultConnectionDTO)
|
||||||
async search(
|
async search(
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
@Args()
|
@Args()
|
||||||
@ -45,34 +23,11 @@ export class SearchResolver {
|
|||||||
filter,
|
filter,
|
||||||
includedObjectNameSingulars,
|
includedObjectNameSingulars,
|
||||||
excludedObjectNameSingulars,
|
excludedObjectNameSingulars,
|
||||||
|
after,
|
||||||
}: SearchArgs,
|
}: SearchArgs,
|
||||||
) {
|
) {
|
||||||
const currentCacheVersion =
|
const objectMetadataItemWithFieldMaps =
|
||||||
await this.workspaceCacheStorageService.getMetadataVersion(workspace.id);
|
await this.searchService.getObjectMetadataItemWithFieldMaps(workspace);
|
||||||
|
|
||||||
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 filteredObjectMetadataItems =
|
const filteredObjectMetadataItems =
|
||||||
this.searchService.filterObjectMetadataItems({
|
this.searchService.filterObjectMetadataItems({
|
||||||
@ -81,42 +36,22 @@ export class SearchResolver {
|
|||||||
excludedObjectNameSingulars: excludedObjectNameSingulars ?? [],
|
excludedObjectNameSingulars: excludedObjectNameSingulars ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] =
|
const allRecordsWithObjectMetadataItems =
|
||||||
[];
|
await this.searchService.getAllRecordsWithObjectMetadataItems({
|
||||||
|
objectMetadataItemWithFieldMaps: filteredObjectMetadataItems,
|
||||||
|
searchInput,
|
||||||
|
limit,
|
||||||
|
filter,
|
||||||
|
includedObjectNameSingulars,
|
||||||
|
excludedObjectNameSingulars,
|
||||||
|
after,
|
||||||
|
});
|
||||||
|
|
||||||
const filteredObjectMetadataItemsChunks = chunk(
|
return this.searchService.computeSearchObjectResults({
|
||||||
filteredObjectMetadataItems,
|
recordsWithObjectMetadataItems: allRecordsWithObjectMetadataItems,
|
||||||
OBJECT_METADATA_ITEMS_CHUNK_SIZE,
|
workspaceId: workspace.id,
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
|
||||||
limit,
|
limit,
|
||||||
workspace.id,
|
after,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,15 +4,17 @@ import { isNonEmptyString } from '@sniptt/guards';
|
|||||||
import { FieldMetadataType } from 'twenty-shared/types';
|
import { FieldMetadataType } from 'twenty-shared/types';
|
||||||
import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
|
import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
|
||||||
import { Brackets, ObjectLiteral } from 'typeorm';
|
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 { 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 { 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 { 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 { 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 {
|
import {
|
||||||
SearchException,
|
SearchException,
|
||||||
SearchExceptionCode,
|
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 { 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 { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util';
|
||||||
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
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()
|
@Injectable()
|
||||||
export class SearchService {
|
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({
|
filterObjectMetadataItems({
|
||||||
objectMetadataItemWithFieldMaps,
|
objectMetadataItemWithFieldMaps,
|
||||||
@ -60,6 +176,7 @@ export class SearchService {
|
|||||||
searchTermsOr,
|
searchTermsOr,
|
||||||
limit,
|
limit,
|
||||||
filter,
|
filter,
|
||||||
|
after,
|
||||||
}: {
|
}: {
|
||||||
entityManager: WorkspaceRepository<Entity>;
|
entityManager: WorkspaceRepository<Entity>;
|
||||||
objectMetadataItem: ObjectMetadataItemWithFieldMaps;
|
objectMetadataItem: ObjectMetadataItemWithFieldMaps;
|
||||||
@ -67,6 +184,7 @@ export class SearchService {
|
|||||||
searchTermsOr: string;
|
searchTermsOr: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
filter: ObjectRecordFilterInput;
|
filter: ObjectRecordFilterInput;
|
||||||
|
after?: string;
|
||||||
}) {
|
}) {
|
||||||
const queryBuilder = entityManager.createQueryBuilder();
|
const queryBuilder = entityManager.createQueryBuilder();
|
||||||
|
|
||||||
@ -93,51 +211,102 @@ export class SearchService {
|
|||||||
...(imageIdentifierField ? [imageIdentifierField] : []),
|
...(imageIdentifierField ? [imageIdentifierField] : []),
|
||||||
].map((field) => `"${field}"`);
|
].map((field) => `"${field}"`);
|
||||||
|
|
||||||
const searchQuery = isNonEmptyString(searchTerms)
|
const tsRankCDExpr = `ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(: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);
|
|
||||||
|
|
||||||
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(
|
getLabelIdentifierColumns(
|
||||||
@ -220,11 +389,54 @@ export class SearchService {
|
|||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
computeSearchObjectResults(
|
computeEdges({
|
||||||
recordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[],
|
sortedRecords,
|
||||||
limit: number,
|
after,
|
||||||
workspaceId: string,
|
}: {
|
||||||
) {
|
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(
|
const searchRecords = recordsWithObjectMetadataItems.flatMap(
|
||||||
({ objectMetadataItem, records }) => {
|
({ objectMetadataItem, records }) => {
|
||||||
return records.map((record) => {
|
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[]) {
|
sortSearchObjectResults(searchObjectResultsWithRank: SearchRecordDTO[]) {
|
||||||
|
|||||||
@ -23,42 +23,17 @@ export class SeederService {
|
|||||||
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
|
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async seedCustomObjects(
|
public async seedCustomObjectRecords(
|
||||||
dataSourceId: string,
|
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
objectMetadataSeed: ObjectMetadataSeed,
|
objectMetadataSeed: ObjectMetadataSeed,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
objectRecordSeeds: Record<string, any>[],
|
objectRecordSeeds: Record<string, any>[],
|
||||||
): Promise<void> {
|
) {
|
||||||
const createdObjectMetadata = await this.objectMetadataService.createOne({
|
const { fieldMetadataSeeds, objectMetadata } = await this.getSeedMetadata(
|
||||||
...objectMetadataSeed,
|
|
||||||
dataSourceId,
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
objectMetadataSeed,
|
||||||
|
|
||||||
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 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 =
|
const schemaName =
|
||||||
this.workspaceDataSourceService.getSchemaName(workspaceId);
|
this.workspaceDataSourceService.getSchemaName(workspaceId);
|
||||||
|
|
||||||
@ -67,24 +42,11 @@ export class SeederService {
|
|||||||
|
|
||||||
const entityManager: EntityManager = mainDataSource.createEntityManager();
|
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(
|
const objectRecordSeedsAsSQLFlattenedSeeds = objectRecordSeeds.map(
|
||||||
(recordSeed) => {
|
(recordSeed) => {
|
||||||
const objectRecordSeedsAsSQLFlattenedSeeds = {};
|
const objectRecordSeedsAsSQLFlattenedSeeds = {};
|
||||||
|
|
||||||
for (const field of filteredFieldMetadataSeeds) {
|
for (const field of fieldMetadataSeeds) {
|
||||||
if (isCompositeFieldMetadataType(field.type)) {
|
if (isCompositeFieldMetadataType(field.type)) {
|
||||||
const compositeFieldTypeDefinition = compositeTypeDefinitions.get(
|
const compositeFieldTypeDefinition = compositeTypeDefinitions.get(
|
||||||
field.type,
|
field.type,
|
||||||
@ -165,7 +127,7 @@ export class SeederService {
|
|||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.insert()
|
.insert()
|
||||||
.into(
|
.into(
|
||||||
`${schemaName}.${computeTableName(objectMetadataAfterFieldCreation.nameSingular, true)}`,
|
`${schemaName}.${computeTableName(objectMetadata.nameSingular, true)}`,
|
||||||
sqlColumnNames,
|
sqlColumnNames,
|
||||||
)
|
)
|
||||||
.orIgnore()
|
.orIgnore()
|
||||||
@ -174,6 +136,37 @@ export class SeederService {
|
|||||||
.execute();
|
.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(
|
private addNameFieldToFieldMetadataSeeds(
|
||||||
arrayOfMetadataFields: Pick<CreateFieldInput, 'name' | 'type' | 'label'>[],
|
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(
|
private turnCompositeSubFieldValueAsSQLValue(
|
||||||
fieldType: FieldMetadataType,
|
fieldType: FieldMetadataType,
|
||||||
subFieldName: string,
|
subFieldName: string,
|
||||||
|
|||||||
@ -244,6 +244,11 @@ export class WorkspaceManagerService {
|
|||||||
dataSourceMetadata.id,
|
dataSourceMetadata.id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
PETS_METADATA_SEEDS,
|
PETS_METADATA_SEEDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.seederService.seedCustomObjectRecords(
|
||||||
|
workspaceId,
|
||||||
|
PETS_METADATA_SEEDS,
|
||||||
PETS_DATA_SEEDS,
|
PETS_DATA_SEEDS,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -251,6 +256,11 @@ export class WorkspaceManagerService {
|
|||||||
dataSourceMetadata.id,
|
dataSourceMetadata.id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
SURVEY_RESULTS_METADATA_SEEDS,
|
SURVEY_RESULTS_METADATA_SEEDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.seederService.seedCustomObjectRecords(
|
||||||
|
workspaceId,
|
||||||
|
SURVEY_RESULTS_METADATA_SEEDS,
|
||||||
SURVEY_RESULTS_DATA_SEEDS,
|
SURVEY_RESULTS_DATA_SEEDS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export const TEST_API_KEY_1_ID = '982fb60e-67d9-44a3-b35c-0e508f41d3d6';
|
||||||
@ -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';
|
||||||
@ -1,89 +1,58 @@
|
|||||||
import { randomUUID } from 'crypto';
|
|
||||||
|
|
||||||
import { OBJECT_MODEL_COMMON_FIELDS } from 'test/integration/constants/object-model-common-fields';
|
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 { 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 { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||||
import { performCreateManyOperation } from 'test/integration/graphql/utils/perform-create-many-operation.utils';
|
import { performCreateManyOperation } from 'test/integration/graphql/utils/perform-create-many-operation.utils';
|
||||||
import {
|
import { searchFactory } from 'test/integration/graphql/utils/search-factory.util';
|
||||||
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 { EachTestingContext } from 'twenty-shared/testing';
|
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', () => {
|
describe('SearchResolver', () => {
|
||||||
let listingObjectMetadataId: { objectMetadataId: string };
|
|
||||||
const [firstPerson, secondPerson, thirdPerson] = [
|
const [firstPerson, secondPerson, thirdPerson] = [
|
||||||
{ id: randomUUID(), name: { firstName: 'searchInput1' } },
|
{ id: TEST_PERSON_1_ID, name: { firstName: 'searchInput1' } },
|
||||||
{ id: randomUUID(), name: { firstName: 'searchInput2' } },
|
{ id: TEST_PERSON_2_ID, name: { firstName: 'searchInput2' } },
|
||||||
{ id: randomUUID(), name: { firstName: 'searchInput3' } },
|
{ id: TEST_PERSON_3_ID, name: { firstName: 'searchInput3' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const [apiKey] = [
|
const [apiKey] = [
|
||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: TEST_API_KEY_1_ID,
|
||||||
name: 'record not searchable',
|
name: 'record not searchable',
|
||||||
expiresAt: new Date(Date.now()),
|
expiresAt: new Date(Date.now()),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const [firstListing, secondListing] = [
|
|
||||||
{ id: randomUUID(), name: 'searchInput1' },
|
const [firstPet, secondPet] = [
|
||||||
{ id: randomUUID(), name: 'searchInput2' },
|
{ 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 () => {
|
beforeAll(async () => {
|
||||||
|
await cleanTestDatabase({ seed: false });
|
||||||
|
|
||||||
try {
|
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(
|
await performCreateManyOperation(
|
||||||
LISTING_NAME_SINGULAR,
|
'pet',
|
||||||
LISTING_NAME_PLURAL,
|
'pets',
|
||||||
OBJECT_MODEL_COMMON_FIELDS,
|
OBJECT_MODEL_COMMON_FIELDS,
|
||||||
[firstListing, secondListing],
|
[firstPet, secondPet],
|
||||||
);
|
);
|
||||||
|
|
||||||
await performCreateManyOperation('person', 'people', PERSON_GQL_FIELDS, [
|
await performCreateManyOperation('person', 'people', PERSON_GQL_FIELDS, [
|
||||||
@ -106,46 +75,17 @@ describe('SearchResolver', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await makeGraphqlAPIRequest(
|
await cleanTestDatabase({ seed: true });
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const testsUseCases: EachTestingContext<{
|
const testsUseCases: EachTestingContext<{
|
||||||
input: SearchFactoryParams;
|
input: SearchArgs;
|
||||||
eval: {
|
eval: {
|
||||||
definedRecordIds: string[];
|
orderedRecordIds: string[];
|
||||||
undefinedRecordIds: string[];
|
pageInfo: {
|
||||||
|
hasNextPage: boolean;
|
||||||
|
decodedEndCursor: SearchCursor | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}>[] = [
|
}>[] = [
|
||||||
{
|
{
|
||||||
@ -154,10 +94,26 @@ describe('SearchResolver', () => {
|
|||||||
context: {
|
context: {
|
||||||
input: {
|
input: {
|
||||||
searchInput: '',
|
searchInput: '',
|
||||||
|
limit: 50,
|
||||||
},
|
},
|
||||||
eval: {
|
eval: {
|
||||||
definedRecordIds: [firstListing.id, secondListing.id],
|
orderedRecordIds: [
|
||||||
undefinedRecordIds: [apiKey.id],
|
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: {
|
context: {
|
||||||
input: {
|
input: {
|
||||||
searchInput: 'searchInput1',
|
searchInput: 'searchInput1',
|
||||||
|
limit: 50,
|
||||||
},
|
},
|
||||||
eval: {
|
eval: {
|
||||||
definedRecordIds: [firstPerson.id, firstListing.id],
|
orderedRecordIds: [firstPerson.id, firstPet.id],
|
||||||
undefinedRecordIds: [secondPerson.id, secondListing.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: {
|
context: {
|
||||||
input: {
|
input: {
|
||||||
searchInput: '',
|
searchInput: '',
|
||||||
includedObjectNameSingulars: [LISTING_NAME_SINGULAR],
|
includedObjectNameSingulars: ['pet'],
|
||||||
|
limit: 50,
|
||||||
},
|
},
|
||||||
eval: {
|
eval: {
|
||||||
definedRecordIds: [firstListing.id, secondListing.id],
|
orderedRecordIds: [firstPet.id, secondPet.id],
|
||||||
undefinedRecordIds: [firstPerson.id, secondPerson.id],
|
pageInfo: {
|
||||||
|
hasNextPage: false,
|
||||||
|
decodedEndCursor: {
|
||||||
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||||
|
lastRecordIdsPerObject: {
|
||||||
|
pet: secondPet.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -192,10 +167,19 @@ describe('SearchResolver', () => {
|
|||||||
input: {
|
input: {
|
||||||
searchInput: '',
|
searchInput: '',
|
||||||
excludedObjectNameSingulars: ['person'],
|
excludedObjectNameSingulars: ['person'],
|
||||||
|
limit: 50,
|
||||||
},
|
},
|
||||||
eval: {
|
eval: {
|
||||||
definedRecordIds: [firstListing.id, secondListing.id],
|
orderedRecordIds: [firstPet.id, secondPet.id],
|
||||||
undefinedRecordIds: [firstPerson.id, secondPerson.id],
|
pageInfo: {
|
||||||
|
hasNextPage: false,
|
||||||
|
decodedEndCursor: {
|
||||||
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
||||||
|
lastRecordIdsPerObject: {
|
||||||
|
pet: secondPet.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -204,13 +188,263 @@ describe('SearchResolver', () => {
|
|||||||
context: {
|
context: {
|
||||||
input: {
|
input: {
|
||||||
searchInput: '',
|
searchInput: '',
|
||||||
filter: {
|
filter: { id: { eq: firstPet.id } },
|
||||||
id: { eq: firstListing.id },
|
limit: 50,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
eval: {
|
eval: {
|
||||||
definedRecordIds: [firstListing.id],
|
orderedRecordIds: [firstPet.id],
|
||||||
undefinedRecordIds: [secondListing.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();
|
expect(response.body.data.search).toBeDefined();
|
||||||
|
|
||||||
const search = response.body.data.search;
|
const search = response.body.data.search;
|
||||||
|
const edges = search.edges;
|
||||||
|
const pageInfo = search.pageInfo;
|
||||||
|
|
||||||
context.eval.definedRecordIds.length > 0
|
context.eval.orderedRecordIds.length > 0
|
||||||
? expect(search).not.toHaveLength(0)
|
? expect(edges).not.toHaveLength(0)
|
||||||
: expect(search).toHaveLength(0);
|
: expect(edges).toHaveLength(0);
|
||||||
|
|
||||||
context.eval.definedRecordIds.forEach((recordId) => {
|
expect(
|
||||||
expect(hasSearchRecord(search, recordId)).toBeTruthy();
|
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) => {
|
const response = await makeGraphqlAPIRequest(graphqlOperation);
|
||||||
expect(hasSearchRecord(search, recordId)).toBeFalsy();
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,24 +1,20 @@
|
|||||||
import gql from 'graphql-tag';
|
import gql from 'graphql-tag';
|
||||||
|
|
||||||
import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input';
|
import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
|
||||||
|
|
||||||
export type SearchFactoryParams = {
|
|
||||||
searchInput: string;
|
|
||||||
excludedObjectNameSingulars?: string[];
|
|
||||||
includedObjectNameSingulars?: string[];
|
|
||||||
filter?: ObjectRecordFilterInput;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const searchFactory = ({
|
export const searchFactory = ({
|
||||||
searchInput,
|
searchInput,
|
||||||
excludedObjectNameSingulars,
|
excludedObjectNameSingulars,
|
||||||
includedObjectNameSingulars,
|
includedObjectNameSingulars,
|
||||||
filter,
|
filter,
|
||||||
}: SearchFactoryParams) => ({
|
after,
|
||||||
|
limit = 50,
|
||||||
|
}: SearchArgs) => ({
|
||||||
query: gql`
|
query: gql`
|
||||||
query Search(
|
query Search(
|
||||||
$searchInput: String!
|
$searchInput: String!
|
||||||
$limit: Int!
|
$limit: Int!
|
||||||
|
$after: String
|
||||||
$excludedObjectNameSingulars: [String!]
|
$excludedObjectNameSingulars: [String!]
|
||||||
$includedObjectNameSingulars: [String!]
|
$includedObjectNameSingulars: [String!]
|
||||||
$filter: ObjectRecordFilterInput
|
$filter: ObjectRecordFilterInput
|
||||||
@ -26,22 +22,33 @@ export const searchFactory = ({
|
|||||||
search(
|
search(
|
||||||
searchInput: $searchInput
|
searchInput: $searchInput
|
||||||
limit: $limit
|
limit: $limit
|
||||||
|
after: $after
|
||||||
excludedObjectNameSingulars: $excludedObjectNameSingulars
|
excludedObjectNameSingulars: $excludedObjectNameSingulars
|
||||||
includedObjectNameSingulars: $includedObjectNameSingulars
|
includedObjectNameSingulars: $includedObjectNameSingulars
|
||||||
filter: $filter
|
filter: $filter
|
||||||
) {
|
) {
|
||||||
recordId
|
pageInfo {
|
||||||
objectNameSingular
|
hasNextPage
|
||||||
label
|
endCursor
|
||||||
imageUrl
|
}
|
||||||
tsRankCD
|
edges {
|
||||||
tsRank
|
node {
|
||||||
|
recordId
|
||||||
|
objectNameSingular
|
||||||
|
label
|
||||||
|
imageUrl
|
||||||
|
tsRankCD
|
||||||
|
tsRank
|
||||||
|
}
|
||||||
|
cursor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
variables: {
|
variables: {
|
||||||
searchInput,
|
searchInput,
|
||||||
limit: 50,
|
limit,
|
||||||
|
after,
|
||||||
excludedObjectNameSingulars,
|
excludedObjectNameSingulars,
|
||||||
includedObjectNameSingulars,
|
includedObjectNameSingulars,
|
||||||
filter,
|
filter,
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export const LISTING_NAME_SINGULAR = 'listinga';
|
export const LISTING_NAME_SINGULAR = 'listing';
|
||||||
export const LISTING_NAME_PLURAL = 'listingas';
|
export const LISTING_NAME_PLURAL = 'listings';
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import {
|
|||||||
} from 'test/integration/constants/test-person-ids.constants';
|
} from 'test/integration/constants/test-person-ids.constants';
|
||||||
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
|
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
|
||||||
import { generateRecordName } from 'test/integration/utils/generate-record-name';
|
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_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 { 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', () => {
|
describe('Core REST API Update One endpoint', () => {
|
||||||
const updatedData = {
|
const updatedData = {
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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 { 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 { 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 { MockedUnhandledExceptionFilter } from 'src/engine/core-modules/exception-handler/mocks/mock-unhandled-exception.filter';
|
||||||
|
import { CommandModule } from 'src/command/command.module';
|
||||||
|
|
||||||
interface TestingModuleCreatePreHook {
|
interface TestingModuleCreatePreHook {
|
||||||
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
|
(moduleBuilder: TestingModuleBuilder): TestingModuleBuilder;
|
||||||
@ -32,7 +33,7 @@ export const createApp = async (
|
|||||||
const stripeSDKMockService = new StripeSDKMockService();
|
const stripeSDKMockService = new StripeSDKMockService();
|
||||||
const mockExceptionHandlerService = new ExceptionHandlerMockService();
|
const mockExceptionHandlerService = new ExceptionHandlerMockService();
|
||||||
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
|
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule, CommandModule],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: APP_FILTER,
|
provide: APP_FILTER,
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { JestConfigWithTsJest } from 'ts-jest';
|
|||||||
import 'tsconfig-paths/register';
|
import 'tsconfig-paths/register';
|
||||||
|
|
||||||
import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource';
|
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';
|
import { createApp } from './create-app';
|
||||||
|
|
||||||
@ -21,4 +24,10 @@ export default async (_, projectConfig: JestConfigWithTsJest) => {
|
|||||||
global.app = app;
|
global.app = app;
|
||||||
// @ts-expect-error legacy noImplicitAny
|
// @ts-expect-error legacy noImplicitAny
|
||||||
global.testDataSource = rawDataSource;
|
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);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user