diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 1a82b3567..73dc89395 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1688,7 +1688,7 @@ export type Query = { objects: ObjectConnection; plans: Array; relationMetadata: RelationMetadataConnection; - search: Array; + search: SearchResultConnection; validatePasswordResetToken: ValidatePasswordResetToken; versionInfo: VersionInfo; }; @@ -1828,6 +1828,7 @@ export type QueryRelationMetadataArgs = { export type QuerySearchArgs = { + after?: InputMaybe; excludedObjectNameSingulars?: InputMaybe>; filter?: InputMaybe; includedObjectNameSingulars?: InputMaybe>; @@ -2046,6 +2047,24 @@ export type SearchRecord = { tsRankCD: Scalars['Float']['output']; }; +export type SearchResultConnection = { + __typename?: 'SearchResultConnection'; + edges: Array; + pageInfo: SearchResultPageInfo; +}; + +export type SearchResultEdge = { + __typename?: 'SearchResultEdge'; + cursor: Scalars['String']['output']; + node: SearchRecord; +}; + +export type SearchResultPageInfo = { + __typename?: 'SearchResultPageInfo'; + endCursor?: Maybe; + hasNextPage: Scalars['Boolean']['output']; +}; + export type SendInvitationsOutput = { __typename?: 'SendInvitationsOutput'; errors: Array; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index e663c087c..893f5c9a8 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1539,7 +1539,7 @@ export type Query = { object: Object; objects: ObjectConnection; plans: Array; - search: Array; + search: SearchResultConnection; validatePasswordResetToken: ValidatePasswordResetToken; versionInfo: VersionInfo; }; @@ -1626,6 +1626,7 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = { export type QuerySearchArgs = { + after?: InputMaybe; excludedObjectNameSingulars?: InputMaybe>; filter?: InputMaybe; includedObjectNameSingulars?: InputMaybe>; @@ -1830,6 +1831,24 @@ export type SearchRecord = { tsRankCD: Scalars['Float']; }; +export type SearchResultConnection = { + __typename?: 'SearchResultConnection'; + edges: Array; + pageInfo: SearchResultPageInfo; +}; + +export type SearchResultEdge = { + __typename?: 'SearchResultEdge'; + cursor: Scalars['String']; + node: SearchRecord; +}; + +export type SearchResultPageInfo = { + __typename?: 'SearchResultPageInfo'; + endCursor?: Maybe; + hasNextPage: Scalars['Boolean']; +}; + export type SendInvitationsOutput = { __typename?: 'SendInvitationsOutput'; errors: Array; @@ -2721,7 +2740,7 @@ export type SearchQueryVariables = Exact<{ }>; -export type SearchQuery = { __typename?: 'Query', search: Array<{ __typename?: 'SearchRecord', recordId: string, objectNameSingular: string, label: string, imageUrl?: string | null, tsRankCD: number, tsRank: number }> }; +export type SearchQuery = { __typename?: 'Query', search: { __typename?: 'SearchResultConnection', edges: Array<{ __typename?: 'SearchResultEdge', cursor: string, node: { __typename?: 'SearchRecord', recordId: string, objectNameSingular: string, label: string, imageUrl?: string | null, tsRankCD: number, tsRank: number } }>, pageInfo: { __typename?: 'SearchResultPageInfo', hasNextPage: boolean, endCursor?: string | null } } }; export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; @@ -4608,12 +4627,21 @@ export const SearchDocument = gql` includedObjectNameSingulars: $includedObjectNameSingulars filter: $filter ) { - recordId - objectNameSingular - label - imageUrl - tsRankCD - tsRank + edges { + node { + recordId + objectNameSingular + label + imageUrl + tsRankCD + tsRank + } + cursor + } + pageInfo { + hasNextPage + endCursor + } } } `; diff --git a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx index c4f178886..5abf9fc14 100644 --- a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx @@ -172,7 +172,13 @@ export const NoResultsSearchFallback: Story = { graphql.query('Search', () => { return HttpResponse.json({ data: { - search: [], + search: { + edges: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, }, }); }), diff --git a/packages/twenty-front/src/modules/command-menu/graphql/queries/search.ts b/packages/twenty-front/src/modules/command-menu/graphql/queries/search.ts index 6b4019d8a..f96bc589f 100644 --- a/packages/twenty-front/src/modules/command-menu/graphql/queries/search.ts +++ b/packages/twenty-front/src/modules/command-menu/graphql/queries/search.ts @@ -1,9 +1,10 @@ import gql from 'graphql-tag'; -export const search = gql` +export const SEARCH_QUERY = gql` query Search( $searchInput: String! $limit: Int! + $after: String $excludedObjectNameSingulars: [String!] $includedObjectNameSingulars: [String!] $filter: ObjectRecordFilterInput @@ -11,16 +12,26 @@ export const search = gql` search( searchInput: $searchInput limit: $limit + after: $after excludedObjectNameSingulars: $excludedObjectNameSingulars includedObjectNameSingulars: $includedObjectNameSingulars filter: $filter ) { - recordId - objectNameSingular - label - imageUrl - tsRankCD - tsRank + edges { + node { + recordId + objectNameSingular + label + imageUrl + tsRankCD + tsRank + } + cursor + } + pageInfo { + hasNextPage + endCursor + } } } `; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuSearchRecords.tsx b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuSearchRecords.tsx index 0fef70540..799bdad98 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuSearchRecords.tsx +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuSearchRecords.tsx @@ -31,69 +31,71 @@ export const useCommandMenuSearchRecords = () => { const { openRecordInCommandMenu } = useOpenRecordInCommandMenu(); const actionItems = useMemo(() => { - return (searchData?.search ?? []).map((searchRecord, index) => { - const baseAction = { - type: ActionType.Navigation, - scope: ActionScope.Global, - key: searchRecord.recordId, - label: searchRecord.label, - position: index, - Icon: () => ( - - ), - shouldBeRegistered: () => true, - description: capitalize(searchRecord.objectNameSingular), - shouldCloseCommandMenuOnClick: true, - }; + return (searchData?.search.edges.map((edge) => edge.node) ?? []).map( + (searchRecord, index) => { + const baseAction = { + type: ActionType.Navigation, + scope: ActionScope.Global, + key: searchRecord.recordId, + label: searchRecord.label, + position: index, + Icon: () => ( + + ), + shouldBeRegistered: () => true, + description: capitalize(searchRecord.objectNameSingular), + shouldCloseCommandMenuOnClick: true, + }; + + if ( + [CoreObjectNameSingular.Task, CoreObjectNameSingular.Note].includes( + searchRecord.objectNameSingular as CoreObjectNameSingular, + ) + ) { + return { + ...baseAction, + component: ( + { + searchRecord.objectNameSingular === 'task' + ? openRecordInCommandMenu({ + recordId: searchRecord.recordId, + objectNameSingular: CoreObjectNameSingular.Task, + }) + : openRecordInCommandMenu({ + recordId: searchRecord.recordId, + objectNameSingular: CoreObjectNameSingular.Note, + }); + }} + preventCommandMenuClosing + /> + ), + }; + } - if ( - [CoreObjectNameSingular.Task, CoreObjectNameSingular.Note].includes( - searchRecord.objectNameSingular as CoreObjectNameSingular, - ) - ) { return { ...baseAction, component: ( - { - searchRecord.objectNameSingular === 'task' - ? openRecordInCommandMenu({ - recordId: searchRecord.recordId, - objectNameSingular: CoreObjectNameSingular.Task, - }) - : openRecordInCommandMenu({ - recordId: searchRecord.recordId, - objectNameSingular: CoreObjectNameSingular.Note, - }); + ), }; - } - - return { - ...baseAction, - component: ( - - ), - }; - }); + }, + ); }, [searchData, openRecordInCommandMenu]); return { diff --git a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordSearchRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordSearchRecords.ts index 89a3ef52c..44398ed22 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordSearchRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordSearchRecords.ts @@ -65,7 +65,7 @@ export const useObjectRecordSearchRecords = ({ const effectiveData = loading ? previousData : data; const searchRecords = useMemo( - () => effectiveData?.search || [], + () => effectiveData?.search.edges.map((edge) => edge.node) || [], [effectiveData], ); diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker.tsx b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker.tsx index 57de829e8..c7a4003e1 100644 --- a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker.tsx +++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker.tsx @@ -1,26 +1,21 @@ -import { MultipleRecordPickerMenuItems } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems'; +import { MultipleRecordPickerItemsDisplay } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerItemsDisplay'; import { MultipleRecordPickerOnClickOutsideEffect } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerOnClickOutsideEffect'; import { MultipleRecordPickerSearchInput } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerSearchInput'; import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext'; -import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState'; import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState'; -import { multipleRecordPickerPickableMorphItemsLengthComponentSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPickableMorphItemsLengthComponentSelector'; import { MultipleRecordPickerHotkeyScope } from '@/object-record/record-picker/multiple-record-picker/types/MultipleRecordPickerHotkeyScope'; import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId'; import { RecordPickerLayoutDirection } from '@/object-record/record-picker/types/RecordPickerLayoutDirection'; import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission'; import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton'; -import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; -import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import styled from '@emotion/styled'; import { useRef } from 'react'; import { useRecoilCallback } from 'recoil'; @@ -59,16 +54,6 @@ export const MultipleRecordPicker = ({ selectableListComponentInstanceId, ); - const multipleRecordPickerIsLoading = useRecoilComponentValueV2( - multipleRecordPickerIsLoadingComponentState, - componentInstanceId, - ); - - const itemsLength = useRecoilComponentValueV2( - multipleRecordPickerPickableMorphItemsLengthComponentSelector, - componentInstanceId, - ); - const multipleRecordPickerSearchFilterState = useRecoilComponentCallbackStateV2( multipleRecordPickerSearchFilterComponentState, @@ -106,13 +91,16 @@ export const MultipleRecordPicker = ({ [multipleRecordPickerSearchFilterState, onCreate], ); - const createNewButton = isDefined(onCreate) && ( - - ); + const createNewButtonSection = + isDefined(onCreate) && !hasObjectReadOnlyPermission ? ( + + + + ) : null; return ( {layoutDirection === 'search-bar-on-bottom' && ( <> - {isDefined(onCreate) && !hasObjectReadOnlyPermission && ( - - {createNewButton} - - )} - - {itemsLength > 0 && ( - - )} - {multipleRecordPickerIsLoading && ( - <> - - - - )} - {itemsLength > 0 && } + {createNewButtonSection} + )} {layoutDirection === 'search-bar-on-top' && ( <> - - {multipleRecordPickerIsLoading && ( - <> - - - - )} - {itemsLength > 0 && ( - - )} - {itemsLength > 0 && } - {isDefined(onCreate) && ( - - {createNewButton} - - )} + + {createNewButtonSection} )} diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerFetchMoreLoader.tsx new file mode 100644 index 000000000..7f4de8a5a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerFetchMoreLoader.tsx @@ -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 ( +
+ + {isLoading && Loading more...} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerItemsDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerItemsDisplay.tsx new file mode 100644 index 000000000..5649382a9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerItemsDisplay.tsx @@ -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 ( + <> + + {isLoading && itemsLength === 0 ? ( + + ) : ( + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems.tsx b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems.tsx index 08995df3d..842f9e774 100644 --- a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems.tsx +++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; +import { MultipleRecordPickerFetchMoreLoader } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerFetchMoreLoader'; import { MultipleRecordPickerMenuItem } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItem'; import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext'; import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState'; @@ -21,6 +22,14 @@ export const StyledSelectableItem = styled(SelectableListItem)` width: 100%; `; +const StyledEmptyText = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.light}; + display: flex; + justify-content: center; + padding: ${({ theme }) => theme.spacing(2)}; +`; + type MultipleRecordPickerMenuItemsProps = { onChange?: (morphItem: RecordPickerPickableMorphItem) => void; }; @@ -77,25 +86,30 @@ export const MultipleRecordPickerMenuItems = ({ return ( - - {pickableRecordIds.map((recordId) => { - return ( - { - handleChange(morphItem); - onChange?.(morphItem); - resetSelectedItem(); - }} - /> - ); - })} - + {pickableRecordIds.length === 0 ? ( + No results found + ) : ( + + {pickableRecordIds.map((recordId) => { + return ( + { + handleChange(morphItem); + onChange?.(morphItem); + resetSelectedItem(); + }} + /> + ); + })} + + + )} ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerSearchInput.tsx b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerSearchInput.tsx index ab47a54a6..f2c591dfb 100644 --- a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerSearchInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerSearchInput.tsx @@ -4,7 +4,8 @@ import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/ import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; -import { useCallback } from 'react'; +import { useRecoilCallback } from 'recoil'; +import { useDebouncedCallback } from 'use-debounce'; export const MultipleRecordPickerSearchInput = () => { const componentInstanceId = useAvailableComponentInstanceIdOrThrow( @@ -16,17 +17,33 @@ export const MultipleRecordPickerSearchInput = () => { const { performSearch } = useMultipleRecordPickerPerformSearch(); - const handleFilterChange = useCallback( - (event: React.ChangeEvent) => { - setRecordPickerSearchFilter(event.currentTarget.value); - performSearch({ - multipleRecordPickerInstanceId: componentInstanceId, - forceSearchFilter: event.currentTarget.value, - }); - }, - [componentInstanceId, performSearch, setRecordPickerSearchFilter], + const debouncedSearch = useDebouncedCallback( + useRecoilCallback( + ({ set }) => + (searchFilter: string) => { + set( + multipleRecordPickerSearchFilterComponentState.atomFamily({ + instanceId: componentInstanceId, + }), + searchFilter, + ); + + performSearch({ + multipleRecordPickerInstanceId: componentInstanceId, + forceSearchFilter: searchFilter, + }); + }, + [componentInstanceId, performSearch], + ), + 500, ); + const handleFilterChange = (event: React.ChangeEvent) => { + const newSearchFilter = event.currentTarget.value; + setRecordPickerSearchFilter(newSearchFilter); + debouncedSearch(newSearchFilter); + }; + return ( { const client = useApolloClient(); @@ -26,14 +30,40 @@ export const useMultipleRecordPickerPerformSearch = () => { forceSearchFilter = '', forceSearchableObjectMetadataItems = [], forcePickableMorphItems = [], + loadMore = false, }: { multipleRecordPickerInstanceId: string; forceSearchFilter?: string; forceSearchableObjectMetadataItems?: ObjectMetadataItem[]; forcePickableMorphItems?: RecordPickerPickableMorphItem[]; + loadMore?: boolean; }) => { const { getLoadable } = snapshot; + const paginationState = getLoadable( + multipleRecordPickerPaginationState.atomFamily({ + instanceId: multipleRecordPickerInstanceId, + }), + ).getValue(); + + set( + multipleRecordPickerIsLoadingComponentState.atomFamily({ + instanceId: multipleRecordPickerInstanceId, + }), + true, + ); + + set( + multipleRecordPickerPaginationState.atomFamily({ + instanceId: multipleRecordPickerInstanceId, + }), + { + ...paginationState, + endCursor: loadMore ? paginationState.endCursor : null, + hasNextPage: loadMore ? paginationState.hasNextPage : true, + }, + ); + const recordPickerSearchFilter = getLoadable( multipleRecordPickerSearchFilterComponentState.atomFamily({ instanceId: multipleRecordPickerInstanceId, @@ -70,6 +100,7 @@ export const useMultipleRecordPickerPerformSearch = () => { const [ searchRecordsFilteredOnPickedRecords, searchRecordsExcludingPickedRecords, + pageInfo, ] = await performSearchQueries({ client, searchFilter, @@ -77,28 +108,83 @@ export const useMultipleRecordPickerPerformSearch = () => { pickedRecordIds: selectedPickableMorphItems.map( ({ recordId }) => recordId, ), + after: loadMore ? paginationState.endCursor : null, }); - const pickedMorphItems = pickableMorphItems.filter( - ({ isSelected }) => isSelected, + const existingMorphItems = getLoadable( + multipleRecordPickerPickableMorphItemsComponentState.atomFamily({ + instanceId: multipleRecordPickerInstanceId, + }), + ).getValue(); + + const allPickedItems = [ + ...existingMorphItems.filter(({ isSelected }) => isSelected), + ...pickableMorphItems.filter(({ isSelected }) => isSelected), + ]; + + const uniquePickedItems = allPickedItems.reduce( + (acc, item) => { + if (!acc.some((existing) => existing.recordId === item.recordId)) { + acc.push(item); + } + return acc; + }, + [] as typeof allPickedItems, ); - // We update the existing pickedMorphItems to be matching the search filter - const updatedPickedMorphItems = pickedMorphItems.map((morphItem) => { - const record = searchRecordsFilteredOnPickedRecords.find( - ({ recordId }) => recordId === morphItem.recordId, - ); + const updatedPickedItems = uniquePickedItems.map((morphItem) => { + if (!searchFilter) { + return { + ...morphItem, + isMatchingSearchFilter: true, + }; + } + + const isMatchingSearchFilter = + searchRecordsFilteredOnPickedRecords.some( + ({ recordId }) => recordId === morphItem.recordId, + ) || + searchRecordsExcludingPickedRecords.some( + ({ recordId }) => recordId === morphItem.recordId, + ); return { ...morphItem, - isMatchingSearchFilter: isDefined(record), + isMatchingSearchFilter, }; }); + const updatedNonPickedExistingItems = existingMorphItems + .filter((item) => !item.isSelected) + .map((morphItem) => { + if (!searchFilter) { + return { + ...morphItem, + isMatchingSearchFilter: true, + }; + } + + const isMatchingSearchFilter = + searchRecordsFilteredOnPickedRecords.some( + ({ recordId }) => recordId === morphItem.recordId, + ) || + searchRecordsExcludingPickedRecords.some( + ({ recordId }) => recordId === morphItem.recordId, + ); + + return { + ...morphItem, + isMatchingSearchFilter, + }; + }); + const searchRecordsFilteredOnPickedRecordsWithoutDuplicates = searchRecordsFilteredOnPickedRecords.filter( (searchRecord) => - !updatedPickedMorphItems.some( + !updatedPickedItems.some( + ({ recordId }) => recordId === searchRecord.recordId, + ) && + !updatedNonPickedExistingItems.some( ({ recordId }) => recordId === searchRecord.recordId, ), ); @@ -109,13 +195,17 @@ export const useMultipleRecordPickerPerformSearch = () => { !searchRecordsFilteredOnPickedRecords.some( ({ recordId }) => recordId === searchRecord.recordId, ) && - !pickedMorphItems.some( + !updatedPickedItems.some( + ({ recordId }) => recordId === searchRecord.recordId, + ) && + !updatedNonPickedExistingItems.some( ({ recordId }) => recordId === searchRecord.recordId, ), ); - const morphItems = [ - ...updatedPickedMorphItems, + const newMorphItems = [ + ...updatedPickedItems, + ...updatedNonPickedExistingItems, ...searchRecordsFilteredOnPickedRecordsWithoutDuplicates.map( ({ recordId, objectNameSingular }) => ({ isMatchingSearchFilter: true, @@ -140,6 +230,20 @@ export const useMultipleRecordPickerPerformSearch = () => { ), ]; + const morphItems = loadMore + ? newMorphItems.reduce( + (acc, item) => { + if ( + !acc.some((existing) => existing.recordId === item.recordId) + ) { + acc.push(item); + } + return acc; + }, + [] as typeof newMorphItems, + ) + : newMorphItems; + set( multipleRecordPickerPickableMorphItemsComponentState.atomFamily({ instanceId: multipleRecordPickerInstanceId, @@ -234,6 +338,24 @@ export const useMultipleRecordPickerPerformSearch = () => { }, ); } + + set( + multipleRecordPickerPaginationState.atomFamily({ + instanceId: multipleRecordPickerInstanceId, + }), + { + ...paginationState, + endCursor: pageInfo.endCursor, + hasNextPage: pageInfo.hasNextPage, + }, + ); + + set( + multipleRecordPickerIsLoadingComponentState.atomFamily({ + instanceId: multipleRecordPickerInstanceId, + }), + false, + ); }, [client, performCombinedFindManyRecords], ); @@ -246,32 +368,46 @@ const performSearchQueries = async ({ searchFilter, searchableObjectMetadataItems, pickedRecordIds, + limit = MULTIPLE_RECORD_PICKER_PAGE_SIZE, + after = null, }: { client: ApolloClient; searchFilter: string; searchableObjectMetadataItems: ObjectMetadataItem[]; pickedRecordIds: string[]; -}): Promise<[SearchRecord[], SearchRecord[]]> => { + limit?: number; + after?: string | null; +}): Promise< + [ + SearchRecord[], + SearchRecord[], + { hasNextPage: boolean; endCursor: string | null }, + ] +> => { if (searchableObjectMetadataItems.length === 0) { - return [[], []]; + return [[], [], { hasNextPage: false, endCursor: null }]; } const searchRecords = async (filter: any) => { const { data } = await client.query({ - query: search, + query: SEARCH_QUERY, variables: { searchInput: searchFilter, includedObjectNameSingulars: searchableObjectMetadataItems.map( ({ nameSingular }) => nameSingular, ), filter, - limit: MAX_SEARCH_RESULTS, + limit, + after, }, }); - return data.search; + return { + records: data.search.edges.map((edge: SearchResultEdge) => edge.node), + pageInfo: data.search.pageInfo, + }; }; - const searchRecordsExcludingPickedRecords = await searchRecords( + const searchRecordsExcludingPickedRecordsResult = await searchRecords( pickedRecordIds.length > 0 ? { not: { @@ -283,17 +419,18 @@ const performSearchQueries = async ({ : undefined, ); - const searchRecordsIncludingPickedRecords = + const searchRecordsIncludingPickedRecordsResult = pickedRecordIds.length > 0 ? await searchRecords({ id: { in: pickedRecordIds, }, }) - : []; + : { records: [], pageInfo: { hasNextPage: false, endCursor: null } }; return [ - searchRecordsIncludingPickedRecords, - searchRecordsExcludingPickedRecords, + searchRecordsIncludingPickedRecordsResult.records, + searchRecordsExcludingPickedRecordsResult.records, + searchRecordsExcludingPickedRecordsResult.pageInfo, ]; }; diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPaginationState.ts b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPaginationState.ts new file mode 100644 index 000000000..d8f1774ec --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPaginationState.ts @@ -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({ + key: 'multipleRecordPickerPaginationState', + defaultValue: { + endCursor: null, + hasNextPage: false, + }, + componentInstanceContext: MultipleRecordPickerComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPaginationSelector.ts b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPaginationSelector.ts new file mode 100644 index 000000000..768ff2ac3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPaginationSelector.ts @@ -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, + }), + ); + }, + }, +); diff --git a/packages/twenty-front/src/testing/graphqlMocks.ts b/packages/twenty-front/src/testing/graphqlMocks.ts index 670c85e71..08af596ee 100644 --- a/packages/twenty-front/src/testing/graphqlMocks.ts +++ b/packages/twenty-front/src/testing/graphqlMocks.ts @@ -186,44 +186,62 @@ export const graphqlMocks = { graphql.query('Search', () => { return HttpResponse.json({ data: { - search: [ - { - __typename: 'SearchRecordDTO', - recordId: '20202020-2d40-4e49-8df4-9c6a049191de', - objectNameSingular: 'person', - label: 'Louis Duss', - imageUrl: '', - tsRankCD: 0.2, - tsRank: 0.12158542, + search: { + edges: [ + { + node: { + __typename: 'SearchRecordDTO', + recordId: '20202020-2d40-4e49-8df4-9c6a049191de', + objectNameSingular: 'person', + label: 'Louis Duss', + imageUrl: '', + tsRankCD: 0.2, + tsRank: 0.12158542, + }, + cursor: 'cursor-1', + }, + { + node: { + __typename: 'SearchRecordDTO', + recordId: '20202020-3ec3-4fe3-8997-b76aa0bfa408', + objectNameSingular: 'company', + label: 'Linkedin', + imageUrl: 'https://twenty-icons.com/linkedin.com', + tsRankCD: 0.2, + tsRank: 0.12158542, + }, + cursor: 'cursor-2', + }, + { + node: { + __typename: 'SearchRecordDTO', + recordId: '20202020-3f74-492d-a101-2a70f50a1645', + objectNameSingular: 'company', + label: 'Libeo', + imageUrl: 'https://twenty-icons.com/libeo.io', + tsRankCD: 0.2, + tsRank: 0.12158542, + }, + cursor: 'cursor-3', + }, + { + node: { + __typename: 'SearchRecordDTO', + recordId: '20202020-ac73-4797-824e-87a1f5aea9e0', + objectNameSingular: 'person', + label: 'Sylvie Palmer', + imageUrl: '', + tsRankCD: 0.1, + tsRank: 0.06079271, + }, + cursor: 'cursor-4', + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: 'cursor-4', }, - { - __typename: 'SearchRecordDTO', - recordId: '20202020-3ec3-4fe3-8997-b76aa0bfa408', - objectNameSingular: 'company', - label: 'Linkedin', - imageUrl: 'https://twenty-icons.com/linkedin.com', - tsRankCD: 0.2, - tsRank: 0.12158542, - }, - { - __typename: 'SearchRecordDTO', - recordId: '20202020-3f74-492d-a101-2a70f50a1645', - objectNameSingular: 'company', - label: 'Libeo', - imageUrl: 'https://twenty-icons.com/libeo.io', - tsRankCD: 0.2, - tsRank: 0.12158542, - }, - { - __typename: 'SearchRecordDTO', - recordId: '20202020-ac73-4797-824e-87a1f5aea9e0', - objectNameSingular: 'person', - label: 'Sylvie Palmer', - imageUrl: '', - tsRankCD: 0.1, - tsRank: 0.06079271, - }, - ], + }, }, }); }), diff --git a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts index 3ea8230a6..12f46a3ba 100644 --- a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts +++ b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts @@ -141,26 +141,59 @@ export class DataSeedWorkspaceCommand extends CommandRunner { workspaceId, ); - await this.seedStandardObjectRecords(mainDataSource, dataSourceMetadata); + await this.seedCustomObjects({ + dataSourceMetadata, + }); - await this.seederService.seedCustomObjects( - dataSourceMetadata.id, - workspaceId, - PETS_METADATA_SEEDS, - PETS_DATA_SEEDS, - ); - - await this.seederService.seedCustomObjects( - dataSourceMetadata.id, - workspaceId, - SURVEY_RESULTS_METADATA_SEEDS, - SURVEY_RESULTS_DATA_SEEDS, - ); + await this.seedRecords({ + mainDataSource, + dataSourceMetadata, + }); } catch (error) { this.logger.error(error); } } + async seedCustomObjects({ + dataSourceMetadata, + }: { + dataSourceMetadata: DataSourceEntity; + }) { + await this.seederService.seedCustomObjects( + dataSourceMetadata.id, + dataSourceMetadata.workspaceId, + PETS_METADATA_SEEDS, + ); + + await this.seederService.seedCustomObjects( + dataSourceMetadata.id, + dataSourceMetadata.workspaceId, + SURVEY_RESULTS_METADATA_SEEDS, + ); + } + + async seedRecords({ + mainDataSource, + dataSourceMetadata, + }: { + mainDataSource: DataSource; + dataSourceMetadata: DataSourceEntity; + }) { + await this.seedStandardObjectRecords(mainDataSource, dataSourceMetadata); + + await this.seederService.seedCustomObjectRecords( + dataSourceMetadata.workspaceId, + PETS_METADATA_SEEDS, + PETS_DATA_SEEDS, + ); + + await this.seederService.seedCustomObjectRecords( + dataSourceMetadata.workspaceId, + SURVEY_RESULTS_METADATA_SEEDS, + SURVEY_RESULTS_DATA_SEEDS, + ); + } + async seedStandardObjectRecords( mainDataSource: DataSource, dataSourceMetadata: DataSourceEntity, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts index afa0f80fb..1a027c8a8 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts @@ -14,7 +14,7 @@ export interface CursorData { [key: string]: any; } -export const decodeCursor = (cursor: string): CursorData => { +export const decodeCursor = (cursor: string): T => { try { return JSON.parse(Buffer.from(cursor, 'base64').toString()); } catch (err) { @@ -45,6 +45,10 @@ export const encodeCursor = ( id: objectRecord.id, }; + return encodeCursorData(cursorData); +}; + +export const encodeCursorData = (cursorData: CursorData) => { return Buffer.from(JSON.stringify(cursorData)).toString('base64'); }; diff --git a/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts b/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts index f2067bb75..1f84f37ad 100644 --- a/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/search/__tests__/search.service.spec.ts @@ -3,13 +3,21 @@ import { Test, TestingModule } from '@nestjs/testing'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/search/__mocks__/mockObjectMetadataItemsWithFieldMaps'; import { SearchService } from 'src/engine/core-modules/search/services/search.service'; +import { encodeCursorData } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; describe('SearchService', () => { let service: SearchService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [SearchService, { provide: FileService, useValue: {} }], + providers: [ + SearchService, + { provide: TwentyORMManager, useValue: {} }, + { provide: WorkspaceCacheStorageService, useValue: {} }, + { provide: FileService, useValue: {} }, + ], }).compile(); service = module.get(SearchService); @@ -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), + ); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/search/constants/results-limit-by-object-without-search-terms.ts b/packages/twenty-server/src/engine/core-modules/search/constants/results-limit-by-object-without-search-terms.ts deleted file mode 100644 index b99ae4e5c..000000000 --- a/packages/twenty-server/src/engine/core-modules/search/constants/results-limit-by-object-without-search-terms.ts +++ /dev/null @@ -1 +0,0 @@ -export const RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS = 8; diff --git a/packages/twenty-server/src/engine/core-modules/search/dtos/search-args.ts b/packages/twenty-server/src/engine/core-modules/search/dtos/search-args.ts index d6cb1ce78..764c2e3eb 100644 --- a/packages/twenty-server/src/engine/core-modules/search/dtos/search-args.ts +++ b/packages/twenty-server/src/engine/core-modules/search/dtos/search-args.ts @@ -1,6 +1,6 @@ import { ArgsType, Field, Int } from '@nestjs/graphql'; -import { IsArray, IsInt, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsInt, IsOptional, IsString, Max } from 'class-validator'; import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input'; @@ -12,8 +12,13 @@ export class SearchArgs { @Field(() => Int) @IsInt() + @Max(100, { message: 'Limit cannot exceed 100 items' }) limit: number; + @Field(() => String, { nullable: true }) + @IsOptional() + after?: string; + @IsArray() @Field(() => [String], { nullable: true }) @IsOptional() diff --git a/packages/twenty-server/src/engine/core-modules/search/dtos/search-record-dto.ts b/packages/twenty-server/src/engine/core-modules/search/dtos/search-record.dto.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/search/dtos/search-record-dto.ts rename to packages/twenty-server/src/engine/core-modules/search/dtos/search-record.dto.ts diff --git a/packages/twenty-server/src/engine/core-modules/search/dtos/search-result-connection.dto.ts b/packages/twenty-server/src/engine/core-modules/search/dtos/search-result-connection.dto.ts new file mode 100644 index 000000000..aad6d0ad1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/search/dtos/search-result-connection.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/search/dtos/search-result-edge.dto.ts b/packages/twenty-server/src/engine/core-modules/search/dtos/search-result-edge.dto.ts new file mode 100644 index 000000000..3cb13416c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/search/dtos/search-result-edge.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/search/dtos/search-result-page-info.dto.ts b/packages/twenty-server/src/engine/core-modules/search/dtos/search-result-page-info.dto.ts new file mode 100644 index 000000000..7019e600e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/search/dtos/search-result-page-info.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts b/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts index 7bce44e97..d13d2ef66 100644 --- a/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts @@ -1,41 +1,19 @@ import { UseFilters } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; -import chunk from 'lodash.chunk'; - -import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; - import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args'; -import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record-dto'; import { SearchApiExceptionFilter } from 'src/engine/core-modules/search/filters/search-api-exception.filter'; import { SearchService } from 'src/engine/core-modules/search/services/search.service'; -import { RecordsWithObjectMetadataItem } from 'src/engine/core-modules/search/types/records-with-object-metadata-item'; -import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; -import { - WorkspaceMetadataCacheException, - WorkspaceMetadataCacheExceptionCode, -} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception'; -import { - WorkspaceMetadataVersionException, - WorkspaceMetadataVersionExceptionCode, -} from 'src/engine/metadata-modules/workspace-metadata-version/exceptions/workspace-metadata-version.exception'; -import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; -import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { SearchResultConnectionDTO } from 'src/engine/core-modules/search/dtos/search-result-connection.dto'; -const OBJECT_METADATA_ITEMS_CHUNK_SIZE = 5; - -@Resolver(() => [SearchRecordDTO]) +@Resolver() @UseFilters(SearchApiExceptionFilter) export class SearchResolver { - constructor( - private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, - private readonly twentyORMManager: TwentyORMManager, - private readonly searchService: SearchService, - ) {} + constructor(private readonly searchService: SearchService) {} - @Query(() => [SearchRecordDTO]) + @Query(() => SearchResultConnectionDTO) async search( @AuthWorkspace() workspace: Workspace, @Args() @@ -45,34 +23,11 @@ export class SearchResolver { filter, includedObjectNameSingulars, excludedObjectNameSingulars, + after, }: SearchArgs, ) { - const currentCacheVersion = - await this.workspaceCacheStorageService.getMetadataVersion(workspace.id); - - if (currentCacheVersion === undefined) { - throw new WorkspaceMetadataVersionException( - `Metadata version not found for workspace ${workspace.id}`, - WorkspaceMetadataVersionExceptionCode.METADATA_VERSION_NOT_FOUND, - ); - } - - const objectMetadataMaps = - await this.workspaceCacheStorageService.getObjectMetadataMaps( - workspace.id, - currentCacheVersion, - ); - - if (!objectMetadataMaps) { - throw new WorkspaceMetadataCacheException( - `Object metadata map not found for workspace ${workspace.id} and metadata version ${currentCacheVersion}`, - WorkspaceMetadataCacheExceptionCode.OBJECT_METADATA_MAP_NOT_FOUND, - ); - } - - const objectMetadataItemWithFieldMaps = Object.values( - objectMetadataMaps.byId, - ); + const objectMetadataItemWithFieldMaps = + await this.searchService.getObjectMetadataItemWithFieldMaps(workspace); const filteredObjectMetadataItems = this.searchService.filterObjectMetadataItems({ @@ -81,42 +36,22 @@ export class SearchResolver { excludedObjectNameSingulars: excludedObjectNameSingulars ?? [], }); - const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] = - []; + const allRecordsWithObjectMetadataItems = + await this.searchService.getAllRecordsWithObjectMetadataItems({ + objectMetadataItemWithFieldMaps: filteredObjectMetadataItems, + searchInput, + limit, + filter, + includedObjectNameSingulars, + excludedObjectNameSingulars, + after, + }); - const filteredObjectMetadataItemsChunks = chunk( - filteredObjectMetadataItems, - OBJECT_METADATA_ITEMS_CHUNK_SIZE, - ); - - for (const objectMetadataItemChunk of filteredObjectMetadataItemsChunks) { - const recordsWithObjectMetadataItems = await Promise.all( - objectMetadataItemChunk.map(async (objectMetadataItem) => { - const repository = await this.twentyORMManager.getRepository( - objectMetadataItem.nameSingular, - ); - - return { - objectMetadataItem, - records: await this.searchService.buildSearchQueryAndGetRecords({ - entityManager: repository, - objectMetadataItem, - searchTerms: formatSearchTerms(searchInput, 'and'), - searchTermsOr: formatSearchTerms(searchInput, 'or'), - limit, - filter: filter ?? ({} as ObjectRecordFilter), - }), - }; - }), - ); - - allRecordsWithObjectMetadataItems.push(...recordsWithObjectMetadataItems); - } - - return this.searchService.computeSearchObjectResults( - allRecordsWithObjectMetadataItems, + return this.searchService.computeSearchObjectResults({ + recordsWithObjectMetadataItems: allRecordsWithObjectMetadataItems, + workspaceId: workspace.id, limit, - workspace.id, - ); + after, + }); } } diff --git a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts index 5340fdfc5..c0b9b80ca 100644 --- a/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts +++ b/packages/twenty-server/src/engine/core-modules/search/services/search.service.ts @@ -4,15 +4,17 @@ import { isNonEmptyString } from '@sniptt/guards'; import { FieldMetadataType } from 'twenty-shared/types'; import { getLogoUrlFromDomainName } from 'twenty-shared/utils'; import { Brackets, ObjectLiteral } from 'typeorm'; +import chunk from 'lodash.chunk'; -import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { + ObjectRecord, + ObjectRecordFilter, +} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; -import { RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS } from 'src/engine/core-modules/search/constants/results-limit-by-object-without-search-terms'; import { STANDARD_OBJECTS_BY_PRIORITY_RANK } from 'src/engine/core-modules/search/constants/standard-objects-by-priority-rank'; import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input'; -import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record-dto'; import { SearchException, SearchExceptionCode, @@ -22,10 +24,124 @@ import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/searc import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; +import { + decodeCursor, + encodeCursorData, +} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util'; +import { + WorkspaceMetadataVersionException, + WorkspaceMetadataVersionExceptionCode, +} from 'src/engine/metadata-modules/workspace-metadata-version/exceptions/workspace-metadata-version.exception'; +import { + WorkspaceMetadataCacheException, + WorkspaceMetadataCacheExceptionCode, +} from 'src/engine/metadata-modules/workspace-metadata-cache/exceptions/workspace-metadata-cache.exception'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms'; +import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args'; +import { SearchResultConnectionDTO } from 'src/engine/core-modules/search/dtos/search-result-connection.dto'; +import { SearchResultEdgeDTO } from 'src/engine/core-modules/search/dtos/search-result-edge.dto'; +import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record.dto'; + +type LastRanks = { tsRankCD: number; tsRank: number }; + +export type SearchCursor = { + lastRanks: LastRanks; + lastRecordIdsPerObject: Record; +}; + +const OBJECT_METADATA_ITEMS_CHUNK_SIZE = 5; @Injectable() export class SearchService { - constructor(private readonly fileService: FileService) {} + constructor( + private readonly twentyORMManager: TwentyORMManager, + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, + private readonly fileService: FileService, + ) {} + + async getObjectMetadataItemWithFieldMaps(workspace: Workspace) { + const currentCacheVersion = + await this.workspaceCacheStorageService.getMetadataVersion(workspace.id); + + if (currentCacheVersion === undefined) { + throw new WorkspaceMetadataVersionException( + `Metadata version not found for workspace ${workspace.id}`, + WorkspaceMetadataVersionExceptionCode.METADATA_VERSION_NOT_FOUND, + ); + } + + const objectMetadataMaps = + await this.workspaceCacheStorageService.getObjectMetadataMaps( + workspace.id, + currentCacheVersion, + ); + + if (!objectMetadataMaps) { + throw new WorkspaceMetadataCacheException( + `Object metadata map not found for workspace ${workspace.id} and metadata version ${currentCacheVersion}`, + WorkspaceMetadataCacheExceptionCode.OBJECT_METADATA_MAP_NOT_FOUND, + ); + } + + return Object.values(objectMetadataMaps.byId); + } + + async getAllRecordsWithObjectMetadataItems({ + objectMetadataItemWithFieldMaps, + includedObjectNameSingulars, + excludedObjectNameSingulars, + searchInput, + limit, + filter, + after, + }: { + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps[]; + } & SearchArgs) { + const filteredObjectMetadataItems = this.filterObjectMetadataItems({ + objectMetadataItemWithFieldMaps, + includedObjectNameSingulars: includedObjectNameSingulars ?? [], + excludedObjectNameSingulars: excludedObjectNameSingulars ?? [], + }); + + const allRecordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[] = + []; + + const filteredObjectMetadataItemsChunks = chunk( + filteredObjectMetadataItems, + OBJECT_METADATA_ITEMS_CHUNK_SIZE, + ); + + for (const objectMetadataItemChunk of filteredObjectMetadataItemsChunks) { + const recordsWithObjectMetadataItems = await Promise.all( + objectMetadataItemChunk.map(async (objectMetadataItem) => { + const repository = + await this.twentyORMManager.getRepository( + objectMetadataItem.nameSingular, + ); + + return { + objectMetadataItem, + records: await this.buildSearchQueryAndGetRecords({ + entityManager: repository, + objectMetadataItem, + searchTerms: formatSearchTerms(searchInput, 'and'), + searchTermsOr: formatSearchTerms(searchInput, 'or'), + limit: limit as number, + filter: filter ?? ({} as ObjectRecordFilter), + after, + }), + }; + }), + ); + + allRecordsWithObjectMetadataItems.push(...recordsWithObjectMetadataItems); + } + + return allRecordsWithObjectMetadataItems; + } filterObjectMetadataItems({ objectMetadataItemWithFieldMaps, @@ -60,6 +176,7 @@ export class SearchService { searchTermsOr, limit, filter, + after, }: { entityManager: WorkspaceRepository; objectMetadataItem: ObjectMetadataItemWithFieldMaps; @@ -67,6 +184,7 @@ export class SearchService { searchTermsOr: string; limit: number; filter: ObjectRecordFilterInput; + after?: string; }) { const queryBuilder = entityManager.createQueryBuilder(); @@ -93,51 +211,102 @@ export class SearchService { ...(imageIdentifierField ? [imageIdentifierField] : []), ].map((field) => `"${field}"`); - const searchQuery = isNonEmptyString(searchTerms) - ? queryBuilder - .select(fieldsToSelect) - .addSelect( - `ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`, - 'tsRankCD', - ) - .addSelect( - `ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`, - 'tsRank', - ) - .andWhere( - new Brackets((qb) => { - qb.where( - `"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTerms)`, - { searchTerms }, - ).orWhere( - `"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTermsOr)`, - { searchTermsOr }, - ); - }), - ) - .orderBy( - `ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`, - 'DESC', - ) - .addOrderBy( - `ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTermsOr))`, - 'DESC', - ) - .setParameter('searchTerms', searchTerms) - .setParameter('searchTermsOr', searchTermsOr) - .take(limit) - : queryBuilder - .select(fieldsToSelect) - .addSelect('0', 'tsRankCD') - .addSelect('0', 'tsRank') - .andWhere( - new Brackets((qb) => { - qb.where(`"${SEARCH_VECTOR_FIELD.name}" IS NOT NULL`); - }), - ) - .take(RESULTS_LIMIT_BY_OBJECT_WITHOUT_SEARCH_TERMS); + const tsRankCDExpr = `ts_rank_cd("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTerms))`; - return await searchQuery.getRawMany(); + const tsRankExpr = `ts_rank("${SEARCH_VECTOR_FIELD.name}", to_tsquery(:searchTermsOr))`; + + const cursorWhereCondition = this.computeCursorWhereCondition({ + after, + objectMetadataNameSingular: objectMetadataItem.nameSingular, + tsRankExpr, + tsRankCDExpr, + }); + + queryBuilder + .select(fieldsToSelect) + .addSelect(tsRankCDExpr, 'tsRankCD') + .addSelect(tsRankExpr, 'tsRank'); + + if (isNonEmptyString(searchTerms)) { + queryBuilder.andWhere( + new Brackets((qb) => { + qb.where( + `"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTerms)`, + { searchTerms }, + ).orWhere( + `"${SEARCH_VECTOR_FIELD.name}" @@ to_tsquery('simple', :searchTermsOr)`, + { searchTermsOr }, + ); + }), + ); + } else { + queryBuilder.andWhere( + new Brackets((qb) => { + qb.where(`"${SEARCH_VECTOR_FIELD.name}" IS NOT NULL`); + }), + ); + } + + if (cursorWhereCondition) { + queryBuilder.andWhere(cursorWhereCondition); + } + + return await queryBuilder + .orderBy(tsRankCDExpr, 'DESC') + .addOrderBy(tsRankExpr, 'DESC') + .addOrderBy('id', 'ASC', 'NULLS FIRST') + .setParameter('searchTerms', searchTerms) + .setParameter('searchTermsOr', searchTermsOr) + .take(limit + 1) // We take one more to check if hasNextPage is true + .getRawMany(); + } + + computeCursorWhereCondition({ + after, + objectMetadataNameSingular, + tsRankExpr, + tsRankCDExpr, + }: { + after?: string; + objectMetadataNameSingular: string; + tsRankExpr: string; + tsRankCDExpr: string; + }) { + if (after) { + const { lastRanks, lastRecordIdsPerObject } = + decodeCursor(after); + + const lastRecordId = lastRecordIdsPerObject[objectMetadataNameSingular]; + + return new Brackets((qb) => { + qb.where(`${tsRankCDExpr} < :tsRankCDLt`, { + tsRankCDLt: lastRanks.tsRankCD, + }) + .orWhere( + new Brackets((inner) => { + inner.andWhere(`${tsRankCDExpr} = :tsRankCDEq`, { + tsRankCDEq: lastRanks.tsRankCD, + }); + inner.andWhere(`${tsRankExpr} < :tsRankLt`, { + tsRankLt: lastRanks.tsRank, + }); + }), + ) + .orWhere( + new Brackets((inner) => { + inner.andWhere(`${tsRankCDExpr} = :tsRankCDEq`, { + tsRankCDEq: lastRanks.tsRankCD, + }); + inner.andWhere(`${tsRankExpr} = :tsRankEq`, { + tsRankEq: lastRanks.tsRank, + }); + if (lastRecordId !== undefined) { + inner.andWhere('id > :lastRecordId', { lastRecordId }); + } + }), + ); + }); + } } getLabelIdentifierColumns( @@ -220,11 +389,54 @@ export class SearchService { : ''; } - computeSearchObjectResults( - recordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[], - limit: number, - workspaceId: string, - ) { + computeEdges({ + sortedRecords, + after, + }: { + sortedRecords: SearchRecordDTO[]; + after?: string; + }): SearchResultEdgeDTO[] { + const recordEdges = []; + + const lastRecordIdsPerObject = after + ? { + ...decodeCursor(after).lastRecordIdsPerObject, + } + : {}; + + for (const record of sortedRecords) { + const { objectNameSingular, tsRankCD, tsRank, recordId } = record; + + lastRecordIdsPerObject[objectNameSingular] = recordId; + + const lastRecordIdsPerObjectSnapshot = { ...lastRecordIdsPerObject }; + + recordEdges.push({ + node: record, + cursor: encodeCursorData({ + lastRanks: { + tsRankCD, + tsRank, + }, + lastRecordIdsPerObject: lastRecordIdsPerObjectSnapshot, + }), + }); + } + + return recordEdges; + } + + computeSearchObjectResults({ + recordsWithObjectMetadataItems, + workspaceId, + limit, + after, + }: { + recordsWithObjectMetadataItems: RecordsWithObjectMetadataItem[]; + workspaceId: string; + limit: number; + after?: string; + }): SearchResultConnectionDTO { const searchRecords = recordsWithObjectMetadataItems.flatMap( ({ objectMetadataItem, records }) => { return records.map((record) => { @@ -244,7 +456,25 @@ export class SearchService { }, ); - return this.sortSearchObjectResults(searchRecords).slice(0, limit); + const sortedRecords = this.sortSearchObjectResults(searchRecords).slice( + 0, + limit, + ); + + const hasNextPage = searchRecords.length > limit; + + const recordEdges = this.computeEdges({ sortedRecords, after }); + + if (recordEdges.length === 0) { + return { edges: [], pageInfo: { endCursor: null, hasNextPage } }; + } + + const lastRecordEdge = recordEdges[recordEdges.length - 1]; + + return { + edges: recordEdges, + pageInfo: { endCursor: lastRecordEdge.cursor, hasNextPage }, + }; } sortSearchObjectResults(searchObjectResultsWithRank: SearchRecordDTO[]) { diff --git a/packages/twenty-server/src/engine/seeder/seeder.service.ts b/packages/twenty-server/src/engine/seeder/seeder.service.ts index 6ad6f5052..a4fd353e7 100644 --- a/packages/twenty-server/src/engine/seeder/seeder.service.ts +++ b/packages/twenty-server/src/engine/seeder/seeder.service.ts @@ -23,42 +23,17 @@ export class SeederService { private readonly workspaceDataSourceService: WorkspaceDataSourceService, ) {} - public async seedCustomObjects( - dataSourceId: string, + public async seedCustomObjectRecords( workspaceId: string, objectMetadataSeed: ObjectMetadataSeed, // eslint-disable-next-line @typescript-eslint/no-explicit-any objectRecordSeeds: Record[], - ): Promise { - const createdObjectMetadata = await this.objectMetadataService.createOne({ - ...objectMetadataSeed, - dataSourceId, + ) { + const { fieldMetadataSeeds, objectMetadata } = await this.getSeedMetadata( workspaceId, - }); - - if (!createdObjectMetadata) { - throw new Error("Object metadata couldn't be created"); - } - - await this.fieldMetadataService.createMany( - objectMetadataSeed.fields.map((fieldMetadataSeed) => ({ - ...fieldMetadataSeed, - objectMetadataId: createdObjectMetadata.id, - workspaceId, - })), + objectMetadataSeed, ); - const objectMetadataAfterFieldCreation = - await this.objectMetadataService.findOneWithinWorkspace(workspaceId, { - where: { nameSingular: objectMetadataSeed.nameSingular }, - }); - - if (!objectMetadataAfterFieldCreation) { - throw new Error( - "Object metadata couldn't be found after field creation.", - ); - } - const schemaName = this.workspaceDataSourceService.getSchemaName(workspaceId); @@ -67,24 +42,11 @@ export class SeederService { const entityManager: EntityManager = mainDataSource.createEntityManager(); - const filteredFieldMetadataSeeds = objectMetadataSeed.fields.filter( - (field) => - objectMetadataAfterFieldCreation.fields.some( - (f) => f.name === field.name || f.name === `name`, - ), - ); - - if (filteredFieldMetadataSeeds.length === 0) { - throw new Error('No fields found for seeding, check metadata file'); - } - - this.addNameFieldToFieldMetadataSeeds(filteredFieldMetadataSeeds); - const objectRecordSeedsAsSQLFlattenedSeeds = objectRecordSeeds.map( (recordSeed) => { const objectRecordSeedsAsSQLFlattenedSeeds = {}; - for (const field of filteredFieldMetadataSeeds) { + for (const field of fieldMetadataSeeds) { if (isCompositeFieldMetadataType(field.type)) { const compositeFieldTypeDefinition = compositeTypeDefinitions.get( field.type, @@ -165,7 +127,7 @@ export class SeederService { .createQueryBuilder() .insert() .into( - `${schemaName}.${computeTableName(objectMetadataAfterFieldCreation.nameSingular, true)}`, + `${schemaName}.${computeTableName(objectMetadata.nameSingular, true)}`, sqlColumnNames, ) .orIgnore() @@ -174,6 +136,37 @@ export class SeederService { .execute(); } + public async seedCustomObjects( + dataSourceId: string, + workspaceId: string, + objectMetadataSeed: ObjectMetadataSeed, + ): Promise { + const createdObjectMetadata = await this.objectMetadataService.createOne({ + ...objectMetadataSeed, + dataSourceId, + workspaceId, + }); + + if (!createdObjectMetadata) { + throw new Error("Object metadata couldn't be created"); + } + + await this.fieldMetadataService.createMany( + objectMetadataSeed.fields.map((fieldMetadataSeed) => ({ + ...fieldMetadataSeed, + objectMetadataId: createdObjectMetadata.id, + workspaceId, + })), + ); + + const { fieldMetadataSeeds } = await this.getSeedMetadata( + workspaceId, + objectMetadataSeed, + ); + + this.addNameFieldToFieldMetadataSeeds(fieldMetadataSeeds); + } + private addNameFieldToFieldMetadataSeeds( arrayOfMetadataFields: Pick[], ) { @@ -184,6 +177,34 @@ export class SeederService { }); } + private async getSeedMetadata( + workspaceId: string, + objectMetadataSeed: ObjectMetadataSeed, + ) { + const objectMetadata = + await this.objectMetadataService.findOneWithinWorkspace(workspaceId, { + where: { nameSingular: objectMetadataSeed.nameSingular }, + }); + + if (!objectMetadata) { + throw new Error( + "Object metadata couldn't be found after field creation.", + ); + } + + const fieldMetadataSeeds = objectMetadataSeed.fields.filter((field) => + objectMetadata.fields.some( + (f) => f.name === field.name || f.name === `name`, + ), + ); + + if (fieldMetadataSeeds.length === 0) { + throw new Error('No fields found for seeding, check metadata file'); + } + + return { fieldMetadataSeeds, objectMetadata }; + } + private turnCompositeSubFieldValueAsSQLValue( fieldType: FieldMetadataType, subFieldName: string, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts index 86e7f99c7..98ffd06ab 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts @@ -244,6 +244,11 @@ export class WorkspaceManagerService { dataSourceMetadata.id, workspaceId, PETS_METADATA_SEEDS, + ); + + await this.seederService.seedCustomObjectRecords( + workspaceId, + PETS_METADATA_SEEDS, PETS_DATA_SEEDS, ); @@ -251,6 +256,11 @@ export class WorkspaceManagerService { dataSourceMetadata.id, workspaceId, SURVEY_RESULTS_METADATA_SEEDS, + ); + + await this.seederService.seedCustomObjectRecords( + workspaceId, + SURVEY_RESULTS_METADATA_SEEDS, SURVEY_RESULTS_DATA_SEEDS, ); } diff --git a/packages/twenty-server/test/integration/constants/test-api-key-ids.constant.ts b/packages/twenty-server/test/integration/constants/test-api-key-ids.constant.ts new file mode 100644 index 000000000..0cb184e85 --- /dev/null +++ b/packages/twenty-server/test/integration/constants/test-api-key-ids.constant.ts @@ -0,0 +1 @@ +export const TEST_API_KEY_1_ID = '982fb60e-67d9-44a3-b35c-0e508f41d3d6'; diff --git a/packages/twenty-server/test/integration/constants/test-pet-ids.constants.ts b/packages/twenty-server/test/integration/constants/test-pet-ids.constants.ts new file mode 100644 index 000000000..b368761e2 --- /dev/null +++ b/packages/twenty-server/test/integration/constants/test-pet-ids.constants.ts @@ -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'; diff --git a/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts index ff47b901e..1949a6355 100644 --- a/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/search/search-resolver.integration-spec.ts @@ -1,89 +1,58 @@ -import { randomUUID } from 'crypto'; - import { OBJECT_MODEL_COMMON_FIELDS } from 'test/integration/constants/object-model-common-fields'; import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; -import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util'; -import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; import { performCreateManyOperation } from 'test/integration/graphql/utils/perform-create-many-operation.utils'; -import { - SearchFactoryParams, - searchFactory, -} from 'test/integration/graphql/utils/search-factory.util'; -import { - LISTING_NAME_PLURAL, - LISTING_NAME_SINGULAR, -} from 'test/integration/metadata/suites/object-metadata/constants/test-object-names.constant'; -import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; -import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; -import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { searchFactory } from 'test/integration/graphql/utils/search-factory.util'; import { EachTestingContext } from 'twenty-shared/testing'; +import { + TEST_PERSON_1_ID, + TEST_PERSON_2_ID, + TEST_PERSON_3_ID, +} from 'test/integration/constants/test-person-ids.constants'; +import { TEST_API_KEY_1_ID } from 'test/integration/constants/test-api-key-ids.constant'; +import { cleanTestDatabase } from 'test/integration/utils/clean-test-database'; +import { + TEST_PET_ID_1, + TEST_PET_ID_2, +} from 'test/integration/constants/test-pet-ids.constants'; -import { SearchRecordDTO } from 'src/engine/core-modules/search/dtos/search-record-dto'; +import { SearchResultEdgeDTO } from 'src/engine/core-modules/search/dtos/search-result-edge.dto'; +import { + decodeCursor, + encodeCursorData, +} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util'; +import { SearchCursor } from 'src/engine/core-modules/search/services/search.service'; +import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args'; describe('SearchResolver', () => { - let listingObjectMetadataId: { objectMetadataId: string }; const [firstPerson, secondPerson, thirdPerson] = [ - { id: randomUUID(), name: { firstName: 'searchInput1' } }, - { id: randomUUID(), name: { firstName: 'searchInput2' } }, - { id: randomUUID(), name: { firstName: 'searchInput3' } }, + { id: TEST_PERSON_1_ID, name: { firstName: 'searchInput1' } }, + { id: TEST_PERSON_2_ID, name: { firstName: 'searchInput2' } }, + { id: TEST_PERSON_3_ID, name: { firstName: 'searchInput3' } }, ]; + const [apiKey] = [ { - id: randomUUID(), + id: TEST_API_KEY_1_ID, name: 'record not searchable', expiresAt: new Date(Date.now()), }, ]; - const [firstListing, secondListing] = [ - { id: randomUUID(), name: 'searchInput1' }, - { id: randomUUID(), name: 'searchInput2' }, + + const [firstPet, secondPet] = [ + { id: TEST_PET_ID_1, name: 'searchInput1' }, + { id: TEST_PET_ID_2, name: 'searchInput2' }, ]; - const hasSearchRecord = (search: SearchRecordDTO[], recordId: string) => { - return search.some((item: SearchRecordDTO) => item.recordId === recordId); - }; - beforeAll(async () => { + await cleanTestDatabase({ seed: false }); + try { - const objectsMetadata = await findManyObjectMetadata({ - input: { - filter: {}, - paging: { - first: 1000, - }, - }, - }); - - const listingObjectMetadata = objectsMetadata.objects.find( - (object) => object.nameSingular === LISTING_NAME_SINGULAR, - ); - - if (listingObjectMetadata) { - listingObjectMetadataId = { - objectMetadataId: listingObjectMetadata.id, - }; - } else { - const { data } = await createOneObjectMetadata({ - input: { - labelSingular: LISTING_NAME_SINGULAR, - labelPlural: LISTING_NAME_PLURAL, - nameSingular: LISTING_NAME_SINGULAR, - namePlural: LISTING_NAME_PLURAL, - icon: 'IconBuildingSkyscraper', - }, - }); - - listingObjectMetadataId = { - objectMetadataId: data.createOneObject.id, - }; - } - await performCreateManyOperation( - LISTING_NAME_SINGULAR, - LISTING_NAME_PLURAL, + 'pet', + 'pets', OBJECT_MODEL_COMMON_FIELDS, - [firstListing, secondListing], + [firstPet, secondPet], ); await performCreateManyOperation('person', 'people', PERSON_GQL_FIELDS, [ @@ -106,46 +75,17 @@ describe('SearchResolver', () => { }); afterAll(async () => { - await makeGraphqlAPIRequest( - destroyManyOperationFactory({ - objectMetadataSingularName: 'person', - objectMetadataPluralName: 'people', - gqlFields: PERSON_GQL_FIELDS, - filter: { - id: { - in: [firstPerson.id, secondPerson.id, thirdPerson.id], - }, - }, - }), - ).catch((error) => { - // eslint-disable-next-line no-console - console.log(error); - }); - - await deleteOneObjectMetadata({ - input: { idToDelete: listingObjectMetadataId.objectMetadataId }, - }).catch((error) => { - // eslint-disable-next-line no-console - console.log(error); - }); - - await makeGraphqlAPIRequest( - destroyOneOperationFactory({ - objectMetadataSingularName: 'apiKey', - gqlFields: OBJECT_MODEL_COMMON_FIELDS, - recordId: apiKey.id, - }), - ).catch((error) => { - // eslint-disable-next-line no-console - console.log(error); - }); + await cleanTestDatabase({ seed: true }); }); const testsUseCases: EachTestingContext<{ - input: SearchFactoryParams; + input: SearchArgs; eval: { - definedRecordIds: string[]; - undefinedRecordIds: string[]; + orderedRecordIds: string[]; + pageInfo: { + hasNextPage: boolean; + decodedEndCursor: SearchCursor | null; + }; }; }>[] = [ { @@ -154,10 +94,26 @@ describe('SearchResolver', () => { context: { input: { searchInput: '', + limit: 50, }, eval: { - definedRecordIds: [firstListing.id, secondListing.id], - undefinedRecordIds: [apiKey.id], + orderedRecordIds: [ + firstPerson.id, + secondPerson.id, + thirdPerson.id, + firstPet.id, + secondPet.id, + ], + pageInfo: { + hasNextPage: false, + decodedEndCursor: { + lastRanks: { tsRank: 0, tsRankCD: 0 }, + lastRecordIdsPerObject: { + person: thirdPerson.id, + pet: secondPet.id, + }, + }, + }, }, }, }, @@ -166,10 +122,20 @@ describe('SearchResolver', () => { context: { input: { searchInput: 'searchInput1', + limit: 50, }, eval: { - definedRecordIds: [firstPerson.id, firstListing.id], - undefinedRecordIds: [secondPerson.id, secondListing.id], + orderedRecordIds: [firstPerson.id, firstPet.id], + pageInfo: { + hasNextPage: false, + decodedEndCursor: { + lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 }, + lastRecordIdsPerObject: { + person: firstPerson.id, + pet: firstPet.id, + }, + }, + }, }, }, }, @@ -178,11 +144,20 @@ describe('SearchResolver', () => { context: { input: { searchInput: '', - includedObjectNameSingulars: [LISTING_NAME_SINGULAR], + includedObjectNameSingulars: ['pet'], + limit: 50, }, eval: { - definedRecordIds: [firstListing.id, secondListing.id], - undefinedRecordIds: [firstPerson.id, secondPerson.id], + orderedRecordIds: [firstPet.id, secondPet.id], + pageInfo: { + hasNextPage: false, + decodedEndCursor: { + lastRanks: { tsRank: 0, tsRankCD: 0 }, + lastRecordIdsPerObject: { + pet: secondPet.id, + }, + }, + }, }, }, }, @@ -192,10 +167,19 @@ describe('SearchResolver', () => { input: { searchInput: '', excludedObjectNameSingulars: ['person'], + limit: 50, }, eval: { - definedRecordIds: [firstListing.id, secondListing.id], - undefinedRecordIds: [firstPerson.id, secondPerson.id], + orderedRecordIds: [firstPet.id, secondPet.id], + pageInfo: { + hasNextPage: false, + decodedEndCursor: { + lastRanks: { tsRank: 0, tsRankCD: 0 }, + lastRecordIdsPerObject: { + pet: secondPet.id, + }, + }, + }, }, }, }, @@ -204,13 +188,263 @@ describe('SearchResolver', () => { context: { input: { searchInput: '', - filter: { - id: { eq: firstListing.id }, - }, + filter: { id: { eq: firstPet.id } }, + limit: 50, }, eval: { - definedRecordIds: [firstListing.id], - undefinedRecordIds: [secondListing.id], + orderedRecordIds: [firstPet.id], + pageInfo: { + hasNextPage: false, + decodedEndCursor: { + lastRanks: { tsRank: 0, tsRankCD: 0 }, + lastRecordIdsPerObject: { + pet: firstPet.id, + }, + }, + }, + }, + }, + }, + { + title: 'should limit records number with limit', + context: { + input: { + searchInput: '', + limit: 4, + }, + eval: { + orderedRecordIds: [ + firstPerson.id, + secondPerson.id, + thirdPerson.id, + firstPet.id, + ], + pageInfo: { + hasNextPage: true, + decodedEndCursor: { + lastRanks: { tsRank: 0, tsRankCD: 0 }, + lastRecordIdsPerObject: { + pet: firstPet.id, + person: thirdPerson.id, + }, + }, + }, + }, + }, + }, + { + title: 'should return endCursor when paginating', + context: { + input: { + searchInput: '', + limit: 2, + }, + eval: { + orderedRecordIds: [firstPerson.id, secondPerson.id], + pageInfo: { + hasNextPage: true, + decodedEndCursor: { + lastRanks: { tsRank: 0, tsRankCD: 0 }, + lastRecordIdsPerObject: { + person: secondPerson.id, + }, + }, + }, + }, + }, + }, + { + title: 'should return endCursor when paginating with Cursor', + context: { + input: { + searchInput: '', + after: encodeCursorData({ + lastRanks: { tsRank: 0, tsRankCD: 0 }, + lastRecordIdsPerObject: { + person: secondPerson.id, + }, + }), + limit: 2, + }, + eval: { + orderedRecordIds: [thirdPerson.id, firstPet.id], + pageInfo: { + hasNextPage: true, + decodedEndCursor: { + lastRanks: { tsRank: 0, tsRankCD: 0 }, + lastRecordIdsPerObject: { + pet: firstPet.id, + person: thirdPerson.id, + }, + }, + }, + }, + }, + }, + { + title: 'should limit records number with limit and searchInput', + context: { + input: { + searchInput: 'searchInput', + limit: 4, + }, + eval: { + orderedRecordIds: [ + firstPerson.id, + secondPerson.id, + thirdPerson.id, + firstPet.id, + ], + pageInfo: { + hasNextPage: true, + decodedEndCursor: { + lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 }, + lastRecordIdsPerObject: { + pet: firstPet.id, + person: thirdPerson.id, + }, + }, + }, + }, + }, + }, + { + title: 'should return endCursor when paginating with searchInput', + context: { + input: { + searchInput: 'searchInput', + limit: 2, + }, + eval: { + orderedRecordIds: [firstPerson.id, secondPerson.id], + pageInfo: { + hasNextPage: true, + decodedEndCursor: { + lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 }, + lastRecordIdsPerObject: { + person: secondPerson.id, + }, + }, + }, + }, + }, + }, + { + title: + 'should return endCursor when paginating with searchInput with Cursor', + context: { + input: { + searchInput: 'searchInput', + after: encodeCursorData({ + lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 }, + lastRecordIdsPerObject: { + person: secondPerson.id, + }, + }), + limit: 2, + }, + eval: { + orderedRecordIds: [thirdPerson.id, firstPet.id], + pageInfo: { + hasNextPage: true, + decodedEndCursor: { + lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 }, + lastRecordIdsPerObject: { + pet: firstPet.id, + person: thirdPerson.id, + }, + }, + }, + }, + }, + }, + { + title: + 'should return endCursor when paginating with searchInput with Cursor and filter', + context: { + input: { + searchInput: 'searchInput', + after: encodeCursorData({ + lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 }, + lastRecordIdsPerObject: { + person: secondPerson.id, + }, + }), + limit: 2, + filter: { id: { neq: firstPet.id } }, + }, + eval: { + orderedRecordIds: [thirdPerson.id, secondPet.id], + pageInfo: { + hasNextPage: false, + decodedEndCursor: { + lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 }, + lastRecordIdsPerObject: { + person: thirdPerson.id, + pet: secondPet.id, + }, + }, + }, + }, + }, + }, + { + title: 'should paginate properly with excludedObject', + context: { + input: { + searchInput: '', + excludedObjectNameSingulars: ['person'], + limit: 1, + }, + eval: { + orderedRecordIds: [firstPet.id], + pageInfo: { + hasNextPage: true, + decodedEndCursor: { + lastRanks: { tsRank: 0, tsRankCD: 0 }, + lastRecordIdsPerObject: { + pet: firstPet.id, + }, + }, + }, + }, + }, + }, + { + title: 'should paginate properly with included Objects only', + context: { + input: { + searchInput: '', + includedObjectNameSingulars: ['pet'], + limit: 1, + }, + eval: { + orderedRecordIds: [firstPet.id], + pageInfo: { + hasNextPage: true, + decodedEndCursor: { + lastRanks: { tsRank: 0, tsRankCD: 0 }, + lastRecordIdsPerObject: { + pet: firstPet.id, + }, + }, + }, + }, + }, + }, + { + title: 'should paginate properly when no records are returned', + context: { + input: { + searchInput: '', + limit: 0, + }, + eval: { + orderedRecordIds: [], + pageInfo: { + hasNextPage: true, + decodedEndCursor: null, + }, }, }, }, @@ -224,17 +458,127 @@ describe('SearchResolver', () => { expect(response.body.data.search).toBeDefined(); const search = response.body.data.search; + const edges = search.edges; + const pageInfo = search.pageInfo; - context.eval.definedRecordIds.length > 0 - ? expect(search).not.toHaveLength(0) - : expect(search).toHaveLength(0); + context.eval.orderedRecordIds.length > 0 + ? expect(edges).not.toHaveLength(0) + : expect(edges).toHaveLength(0); - context.eval.definedRecordIds.forEach((recordId) => { - expect(hasSearchRecord(search, recordId)).toBeTruthy(); + expect( + edges.map((edge: SearchResultEdgeDTO) => edge.node.recordId), + ).toEqual(context.eval.orderedRecordIds); + + expect(pageInfo).toBeDefined(); + expect(context.eval.pageInfo.hasNextPage).toEqual(pageInfo.hasNextPage); + expect(context.eval.pageInfo.decodedEndCursor).toEqual( + pageInfo.endCursor + ? decodeCursor(pageInfo.endCursor) + : pageInfo.endCursor, + ); + }); + + it('should return cursor for each search edge', async () => { + const graphqlOperation = searchFactory({ + searchInput: 'searchInput', + limit: 2, }); - context.eval.undefinedRecordIds.forEach((recordId) => { - expect(hasSearchRecord(search, recordId)).toBeFalsy(); + const response = await makeGraphqlAPIRequest(graphqlOperation); + + const expectedResult = { + edges: [ + { + cursor: encodeCursorData({ + lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 }, + lastRecordIdsPerObject: { + person: firstPerson.id, + }, + }), + }, + { + cursor: encodeCursorData({ + lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 }, + lastRecordIdsPerObject: { + person: secondPerson.id, + }, + }), + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: encodeCursorData({ + lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 }, + lastRecordIdsPerObject: { + person: secondPerson.id, + }, + }), + }, + }; + + expect({ + ...response.body.data.search, + edges: response.body.data.search.edges.map( + (edge: SearchResultEdgeDTO) => ({ + cursor: edge.cursor, + }), + ), + }).toEqual(expectedResult); + }); + + it('should return cursor for each search edge with after cursor input', async () => { + const graphqlOperation = searchFactory({ + searchInput: 'searchInput', + limit: 2, + after: encodeCursorData({ + lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 }, + lastRecordIdsPerObject: { + person: secondPerson.id, + }, + }), }); + + const response = await makeGraphqlAPIRequest(graphqlOperation); + + const expectedResult = { + edges: [ + { + cursor: encodeCursorData({ + lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 }, + lastRecordIdsPerObject: { + person: thirdPerson.id, + }, + }), + }, + { + cursor: encodeCursorData({ + lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 }, + lastRecordIdsPerObject: { + person: thirdPerson.id, + pet: firstPet.id, + }, + }), + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: encodeCursorData({ + lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 }, + lastRecordIdsPerObject: { + person: thirdPerson.id, + pet: firstPet.id, + }, + }), + }, + }; + + expect({ + ...response.body.data.search, + edges: response.body.data.search.edges.map( + (edge: SearchResultEdgeDTO) => ({ + cursor: edge.cursor, + }), + ), + }).toEqual(expectedResult); }); }); diff --git a/packages/twenty-server/test/integration/graphql/utils/search-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/search-factory.util.ts index 75e365778..fd049ff4f 100644 --- a/packages/twenty-server/test/integration/graphql/utils/search-factory.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/search-factory.util.ts @@ -1,24 +1,20 @@ import gql from 'graphql-tag'; -import { ObjectRecordFilterInput } from 'src/engine/core-modules/search/dtos/object-record-filter-input'; - -export type SearchFactoryParams = { - searchInput: string; - excludedObjectNameSingulars?: string[]; - includedObjectNameSingulars?: string[]; - filter?: ObjectRecordFilterInput; -}; +import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args'; export const searchFactory = ({ searchInput, excludedObjectNameSingulars, includedObjectNameSingulars, filter, -}: SearchFactoryParams) => ({ + after, + limit = 50, +}: SearchArgs) => ({ query: gql` query Search( $searchInput: String! $limit: Int! + $after: String $excludedObjectNameSingulars: [String!] $includedObjectNameSingulars: [String!] $filter: ObjectRecordFilterInput @@ -26,22 +22,33 @@ export const searchFactory = ({ search( searchInput: $searchInput limit: $limit + after: $after excludedObjectNameSingulars: $excludedObjectNameSingulars includedObjectNameSingulars: $includedObjectNameSingulars filter: $filter ) { - recordId - objectNameSingular - label - imageUrl - tsRankCD - tsRank + pageInfo { + hasNextPage + endCursor + } + edges { + node { + recordId + objectNameSingular + label + imageUrl + tsRankCD + tsRank + } + cursor + } } } `, variables: { searchInput, - limit: 50, + limit, + after, excludedObjectNameSingulars, includedObjectNameSingulars, filter, diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/constants/test-object-names.constant.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/constants/test-object-names.constant.ts index 47fc3ba27..aa1a53407 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/constants/test-object-names.constant.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/constants/test-object-names.constant.ts @@ -1,2 +1,2 @@ -export const LISTING_NAME_SINGULAR = 'listinga'; -export const LISTING_NAME_PLURAL = 'listingas'; +export const LISTING_NAME_SINGULAR = 'listing'; +export const LISTING_NAME_PLURAL = 'listings'; diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-update.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-update.integration-spec.ts index e431a489c..b64943b84 100644 --- a/packages/twenty-server/test/integration/rest/suites/rest-api-core-update.integration-spec.ts +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-update.integration-spec.ts @@ -4,9 +4,9 @@ import { } from 'test/integration/constants/test-person-ids.constants'; import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util'; import { generateRecordName } from 'test/integration/utils/generate-record-name'; -import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants'; import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant'; +import { deleteAllRecords } from 'test/integration/utils/delete-all-records'; describe('Core REST API Update One endpoint', () => { const updatedData = { diff --git a/packages/twenty-server/test/integration/utils/clean-test-database.ts b/packages/twenty-server/test/integration/utils/clean-test-database.ts new file mode 100644 index 000000000..0dccf87fe --- /dev/null +++ b/packages/twenty-server/test/integration/utils/clean-test-database.ts @@ -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, + }); +}; diff --git a/packages/twenty-server/test/integration/utils/create-app.ts b/packages/twenty-server/test/integration/utils/create-app.ts index 2c68488fe..6c249c18e 100644 --- a/packages/twenty-server/test/integration/utils/create-app.ts +++ b/packages/twenty-server/test/integration/utils/create-app.ts @@ -8,6 +8,7 @@ import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe- import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { ExceptionHandlerMockService } from 'src/engine/core-modules/exception-handler/mocks/exception-handler-mock.service'; import { MockedUnhandledExceptionFilter } from 'src/engine/core-modules/exception-handler/mocks/mock-unhandled-exception.filter'; +import { CommandModule } from 'src/command/command.module'; interface TestingModuleCreatePreHook { (moduleBuilder: TestingModuleBuilder): TestingModuleBuilder; @@ -32,7 +33,7 @@ export const createApp = async ( const stripeSDKMockService = new StripeSDKMockService(); const mockExceptionHandlerService = new ExceptionHandlerMockService(); let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({ - imports: [AppModule], + imports: [AppModule, CommandModule], providers: [ { provide: APP_FILTER, diff --git a/packages/twenty-server/test/integration/utils/setup-test.ts b/packages/twenty-server/test/integration/utils/setup-test.ts index 0c6e2603e..de0332c0f 100644 --- a/packages/twenty-server/test/integration/utils/setup-test.ts +++ b/packages/twenty-server/test/integration/utils/setup-test.ts @@ -2,6 +2,9 @@ import { JestConfigWithTsJest } from 'ts-jest'; import 'tsconfig-paths/register'; import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource'; +import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command'; import { createApp } from './create-app'; @@ -21,4 +24,10 @@ export default async (_, projectConfig: JestConfigWithTsJest) => { global.app = app; // @ts-expect-error legacy noImplicitAny global.testDataSource = rawDataSource; + // @ts-expect-error legacy noImplicitAny + global.typeOrmService = app.get(TypeORMService); + // @ts-expect-error legacy noImplicitAny + global.dataSourceService = app.get(DataSourceService); + // @ts-expect-error legacy noImplicitAny + global.dataSeedWorkspaceCommand = app.get(DataSeedWorkspaceCommand); };